Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d227bce0a8 | ||
|
|
47a600eeb5 | ||
|
|
e30ff4d694 | ||
|
|
081bad1279 | ||
|
|
cbf5d219fc | ||
|
|
02bddc0baf | ||
|
|
ef562ef59a | ||
|
|
dc59e5c1de | ||
|
|
69200d8ac3 | ||
|
|
7ecce5da8f | ||
|
|
06f7afe25d | ||
|
|
083885c874 | ||
|
|
05d23e2dfc | ||
|
|
940840d69a | ||
|
|
f5a75d204f | ||
|
|
40cb6cef31 | ||
|
|
9752c6fd89 | ||
|
|
911f5801fc | ||
|
|
8768c6773f | ||
|
|
9e4fa13fdb | ||
|
|
b96ab1fb24 | ||
|
|
04bc2e734f | ||
|
|
b639f92da6 | ||
|
|
e99ef3b942 | ||
|
|
7cf0eec2db | ||
|
|
20b2fab994 | ||
|
|
c7c2340321 | ||
|
|
460d36c9b2 | ||
|
|
5bd4c60238 | ||
|
|
795020b55c | ||
|
|
a13d9a2c66 | ||
|
|
c2b6610e6d | ||
|
|
7d21b4290a | ||
|
|
7f990933df | ||
|
|
d121bd21f6 | ||
|
|
91eea2c5ff | ||
|
|
3666241aac | ||
|
|
567164ef7d | ||
|
|
e59ee04b03 | ||
|
|
1a3e884186 | ||
|
|
6e15a62378 | ||
|
|
05d2164dce | ||
|
|
c160647f0b | ||
|
|
d735e3d9db | ||
|
|
5a607c8c8b | ||
|
|
8e37120289 | ||
|
|
35315c2e81 | ||
|
|
845d121fb2 | ||
|
|
517ec996a9 | ||
|
|
7437b4fa7b | ||
|
|
6992790104 | ||
|
|
5c97b40237 | ||
|
|
326f6529f3 | ||
|
|
0f2ece4f88 | ||
|
|
e36b20d06e | ||
|
|
0cbea6d993 | ||
|
|
1eda8894d5 | ||
|
|
35aded99fe | ||
|
|
1a97e82ec8 | ||
|
|
bc66fec77a | ||
|
|
d9b54187ee | ||
|
|
3e4055c5dc | ||
|
|
8a56c920db | ||
|
|
22a8a3f92d | ||
|
|
3ee303244e | ||
|
|
71454946e5 | ||
|
|
5e8201fd22 | ||
|
|
2f67708fcf | ||
|
|
e23f44ad67 | ||
|
|
62ddb8b8b6 | ||
|
|
291b329067 |
78
cloudflare-workers/servers-api/README.md
Normal file
78
cloudflare-workers/servers-api/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Servers API - Cloudflare Worker
|
||||
|
||||
**Purpose:** Proxies Pterodactyl Panel API to provide live server status for firefrostgaming.com
|
||||
|
||||
**Deployed URL:** https://servers-api.firefrostgaming.workers.dev
|
||||
**Created:** April 3, 2026 by Chronicler #56 (The Velocity)
|
||||
**Retrieved from Cloudflare:** April 8, 2026 by Chronicler #68
|
||||
|
||||
---
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Receives request from website (firefrostgaming.com or pages.dev preview)
|
||||
2. Fetches server list from Pterodactyl Panel API
|
||||
3. Fetches live resource stats for each server
|
||||
4. Returns JSON with server name, status (Online/Offline), player count, description
|
||||
5. Caches response for 60 seconds
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Configure these in Cloudflare Workers dashboard:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `PANEL_URL` | Pterodactyl panel URL (https://panel.firefrostgaming.com) |
|
||||
| `CLIENT_API_KEY` | Pterodactyl client API key for webuser_api account |
|
||||
|
||||
---
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
Allowed origins:
|
||||
- `https://firefrostgaming.com`
|
||||
- `https://firefrost-website.pages.dev`
|
||||
|
||||
---
|
||||
|
||||
## Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"name": "All The Mods 10",
|
||||
"status": "Online",
|
||||
"players": 3,
|
||||
"description": "ATM10 modpack server"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
This Worker is deployed via Cloudflare dashboard. To update:
|
||||
|
||||
1. Edit code in Cloudflare Workers dashboard, OR
|
||||
2. Use Wrangler CLI: `wrangler deploy`
|
||||
|
||||
**Note:** This file is the source of truth. If editing in dashboard, sync changes back here.
|
||||
|
||||
---
|
||||
|
||||
## History
|
||||
|
||||
| Date | Change | By |
|
||||
|------|--------|-----|
|
||||
| 2026-04-03 | Initial creation | Chronicler #56 (The Velocity) |
|
||||
| 2026-04-08 | Added to git (was dashboard-only) | Chronicler #68 |
|
||||
|
||||
---
|
||||
|
||||
**Fire + Frost + Foundation = Where Love Builds Legacy** 💙🔥❄️
|
||||
103
cloudflare-workers/servers-api/index.js
Normal file
103
cloudflare-workers/servers-api/index.js
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Firefrost Gaming - Servers API Worker
|
||||
*
|
||||
* Cloudflare Worker that proxies Pterodactyl Panel API
|
||||
* to provide live server status for the website.
|
||||
*
|
||||
* Deployed: https://servers-api.firefrostgaming.workers.dev
|
||||
* Created: April 3, 2026 (Chronicler #56 - The Velocity)
|
||||
*
|
||||
* Environment Variables Required:
|
||||
* PANEL_URL - Pterodactyl panel URL (https://panel.firefrostgaming.com)
|
||||
* CLIENT_API_KEY - Pterodactyl client API key
|
||||
*/
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
// Determine allowed origin
|
||||
const origin = request.headers.get('Origin');
|
||||
const allowedOrigins = [
|
||||
'https://firefrostgaming.com',
|
||||
'https://firefrost-website.pages.dev'
|
||||
];
|
||||
const allowedOrigin = allowedOrigins.includes(origin)
|
||||
? origin
|
||||
: 'https://firefrostgaming.com';
|
||||
|
||||
// Handle CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const PANEL_URL = env.PANEL_URL;
|
||||
const API_KEY = env.CLIENT_API_KEY;
|
||||
|
||||
try {
|
||||
// Fetch server list
|
||||
const listResponse = await fetch(`${PANEL_URL}/api/client`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const listData = await listResponse.json();
|
||||
|
||||
if (!listData.data) throw new Error("Failed to fetch server list");
|
||||
|
||||
// Fetch live stats for all servers
|
||||
const serverPromises = listData.data.map(async (server) => {
|
||||
const id = server.attributes.identifier;
|
||||
|
||||
const statsResponse = await fetch(
|
||||
`${PANEL_URL}/api/client/servers/${id}/resources`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const stats = await statsResponse.json();
|
||||
const isRunning = stats.attributes?.current_state === 'running';
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: server.attributes.name,
|
||||
status: isRunning ? 'Online' : 'Offline',
|
||||
players: isRunning ? (stats.attributes?.resources?.players || 0) : 0,
|
||||
description: server.attributes.description
|
||||
};
|
||||
});
|
||||
|
||||
const finalServers = await Promise.all(serverPromises);
|
||||
|
||||
return new Response(JSON.stringify({ servers: finalServers }), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Cache-Control': 'public, s-maxage=60, max-age=60'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({
|
||||
error: "Servers temporarily unreachable",
|
||||
servers: []
|
||||
}), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': allowedOrigin
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
148
services/arbiter-3.0/DEPLOYMENT.md
Normal file
148
services/arbiter-3.0/DEPLOYMENT.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Arbiter 3.0 Deployment Guide
|
||||
|
||||
**Location:** Command Center (63.143.34.217)
|
||||
**Service:** `arbiter-3` (systemd)
|
||||
**Directory:** `/opt/arbiter-3.0`
|
||||
**Dashboard:** https://discord-bot.firefrostgaming.com/admin
|
||||
|
||||
---
|
||||
|
||||
## 🚀 One-Click Deploy (Trinity Console)
|
||||
|
||||
**For Holly, Meg, and Michael:**
|
||||
|
||||
1. Push your code changes to `firefrost-services` repo
|
||||
2. Open Trinity Console: https://discord-bot.firefrostgaming.com/admin
|
||||
3. Click the **"🚀 Deploy Arbiter"** button in the sidebar
|
||||
4. Wait for success confirmation
|
||||
|
||||
That's it! The button pulls latest code from Gitea and restarts the service.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ IMPORTANT: Arbiter is NOT a Git Repo
|
||||
|
||||
`/opt/arbiter-3.0` on Command Center is **not** a git repository. It's a deployment target.
|
||||
|
||||
**Source of truth:** `firefrost-services` repo → `services/arbiter-3.0/`
|
||||
|
||||
**Why?** Production servers shouldn't have git credentials or .git history. Deploy by copying files.
|
||||
|
||||
---
|
||||
|
||||
## Manual Deploy (SSH Required)
|
||||
|
||||
**On Command Center:**
|
||||
```bash
|
||||
bash /opt/arbiter-3.0/deploy.sh
|
||||
```
|
||||
|
||||
**Or remote curl:**
|
||||
```bash
|
||||
curl -fsSL https://git.firefrostgaming.com/firefrost-gaming/firefrost-services/raw/branch/main/services/arbiter-3.0/deploy.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## First-Time Server Setup
|
||||
|
||||
If setting up deploy button for the first time, Michael needs to run these on Command Center:
|
||||
|
||||
**1. Copy deploy script to /opt/scripts:**
|
||||
```bash
|
||||
sudo mkdir -p /opt/scripts
|
||||
sudo cp /opt/arbiter-3.0/deploy.sh /opt/scripts/deploy-arbiter.sh
|
||||
sudo chmod +x /opt/scripts/deploy-arbiter.sh
|
||||
```
|
||||
|
||||
**2. Configure sudoers (allow Arbiter to run deploy script):**
|
||||
```bash
|
||||
sudo visudo
|
||||
```
|
||||
Add this line:
|
||||
```
|
||||
architect ALL=(ALL) NOPASSWD: /opt/scripts/deploy-arbiter.sh
|
||||
```
|
||||
|
||||
**3. Create log file:**
|
||||
```bash
|
||||
sudo touch /var/log/trinity-deployments.log
|
||||
sudo chown architect:architect /var/log/trinity-deployments.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verify Deployment
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status arbiter-3
|
||||
|
||||
# Check logs
|
||||
journalctl -u arbiter-3 -n 50
|
||||
|
||||
# Check deployment log
|
||||
tail -20 /var/log/trinity-deployments.log
|
||||
|
||||
# Test dashboard
|
||||
curl -s https://discord-bot.firefrostgaming.com/admin | head -5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues
|
||||
|
||||
### "Deployment already in progress"
|
||||
**Cause:** Previous deploy didn't finish or crashed
|
||||
**Fix:** `rm -rf /tmp/arbiter_deploy.lock` then try again
|
||||
|
||||
### "fatal: not a git repository"
|
||||
**Cause:** Someone tried to `git pull` in `/opt/arbiter-3.0`
|
||||
**Fix:** Use the deploy script or manual copy method above
|
||||
|
||||
### "/tmp/firefrost-services already exists"
|
||||
**Cause:** Previous deploy didn't clean up
|
||||
**Fix:** `rm -rf /tmp/firefrost-services /tmp/firefrost-services-deploy-*` then try again
|
||||
|
||||
### Service fails to start
|
||||
**Check:** `journalctl -u arbiter-3 -n 50`
|
||||
**Common causes:**
|
||||
- Missing .env file
|
||||
- Database connection failed
|
||||
- Port already in use
|
||||
- Syntax error in code
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required in `/opt/arbiter-3.0/.env`:
|
||||
```
|
||||
DATABASE_URL=postgresql://...
|
||||
DISCORD_TOKEN=...
|
||||
DISCORD_CLIENT_ID=...
|
||||
DISCORD_CLIENT_SECRET=...
|
||||
PANEL_URL=https://panel.firefrostgaming.com
|
||||
PANEL_APPLICATION_KEY=...
|
||||
PANEL_CLIENT_KEY=...
|
||||
STRIPE_SECRET_KEY=...
|
||||
STRIPE_WEBHOOK_SECRET=...
|
||||
SESSION_SECRET=...
|
||||
MINECRAFT_NEST_IDS=1,5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For Chroniclers & Catalysts
|
||||
|
||||
**You cannot SSH to Command Center** (port 22 blocked from Claude sandbox).
|
||||
|
||||
**Workflow:**
|
||||
1. Make changes to `firefrost-services/services/arbiter-3.0/`
|
||||
2. Commit and push to Gitea
|
||||
3. Tell your human to click "🚀 Deploy Arbiter" in Trinity Console
|
||||
4. Verify via dashboard or ask them to check logs
|
||||
|
||||
---
|
||||
|
||||
**Fire + Frost + Foundation = Where Love Builds Legacy** 🔥❄️
|
||||
77
services/arbiter-3.0/deploy.sh
Normal file
77
services/arbiter-3.0/deploy.sh
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# Arbiter 3.0 Deployment Script
|
||||
# Run on Command Center: bash /opt/arbiter-3.0/deploy.sh
|
||||
# Or via Trinity Console Deploy button
|
||||
#
|
||||
# Usage: deploy.sh [username]
|
||||
# username: Optional - who triggered the deploy (for logging)
|
||||
|
||||
set -e # Exit on any error
|
||||
|
||||
REPO_URL="https://git.firefrostgaming.com/firefrost-gaming/firefrost-services.git"
|
||||
TEMP_DIR="/tmp/firefrost-services-deploy-$$"
|
||||
ARBITER_DIR="/opt/arbiter-3.0"
|
||||
SERVICE_NAME="arbiter-3"
|
||||
LOCKDIR="/tmp/arbiter_deploy.lock"
|
||||
LOG_FILE="/var/log/trinity-deployments.log"
|
||||
DEPLOY_USER="${1:-manual}"
|
||||
|
||||
# Logging function
|
||||
log() {
|
||||
echo "$1"
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
|
||||
}
|
||||
|
||||
# 1. Prevent concurrent deployments with a lock directory
|
||||
if ! mkdir "$LOCKDIR" 2>/dev/null; then
|
||||
echo "ERROR: Deployment already in progress." >&2
|
||||
exit 1
|
||||
fi
|
||||
# Ensure lock is removed when script exits (success or failure)
|
||||
trap 'rm -rf "$LOCKDIR"' EXIT
|
||||
|
||||
log "🔥❄️ Arbiter deployment started by: $DEPLOY_USER"
|
||||
|
||||
# Cleanup any old temp directories
|
||||
rm -rf /tmp/firefrost-services /tmp/firefrost-services-deploy-*
|
||||
|
||||
# Clone fresh
|
||||
log "📥 Cloning firefrost-services..."
|
||||
git clone --depth 1 "$REPO_URL" "$TEMP_DIR"
|
||||
|
||||
# Get commit info for logging
|
||||
COMMIT_HASH=$(cd "$TEMP_DIR" && git log -1 --format="%h - %s")
|
||||
log "📌 Deploying commit: $COMMIT_HASH"
|
||||
|
||||
# Copy arbiter files
|
||||
log "📋 Copying Arbiter files..."
|
||||
cp -r "$TEMP_DIR/services/arbiter-3.0/src/"* "$ARBITER_DIR/src/"
|
||||
cp -r "$TEMP_DIR/services/arbiter-3.0/migrations/"* "$ARBITER_DIR/migrations/" 2>/dev/null || true
|
||||
cp "$TEMP_DIR/services/arbiter-3.0/package.json" "$ARBITER_DIR/package.json" 2>/dev/null || true
|
||||
|
||||
# Check if package.json changed (need npm install)
|
||||
if ! cmp -s "$TEMP_DIR/services/arbiter-3.0/package.json" "$ARBITER_DIR/package.json.bak" 2>/dev/null; then
|
||||
log "📦 Dependencies changed, running npm install..."
|
||||
cd "$ARBITER_DIR"
|
||||
npm install --production --ignore-scripts
|
||||
cp "$ARBITER_DIR/package.json" "$ARBITER_DIR/package.json.bak"
|
||||
fi
|
||||
|
||||
# Cleanup temp files BEFORE restart
|
||||
log "🧹 Cleaning up temp files..."
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
# Restart service
|
||||
log "🔄 Restarting $SERVICE_NAME..."
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
# Verify
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
log "✅ Arbiter deployed successfully! Commit: $COMMIT_HASH"
|
||||
echo "SUCCESS: Deployed commit $COMMIT_HASH"
|
||||
else
|
||||
log "❌ Service failed to start after deploy"
|
||||
echo "ERROR: Service failed to start. Check: journalctl -u $SERVICE_NAME -n 50" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Task #94: Global Restart Scheduler
|
||||
-- Migration for arbiter_db
|
||||
-- Run: psql -U arbiter -d arbiter_db -f 094_global_restart_scheduler.sql
|
||||
|
||||
-- 1. Configuration for Node-wide Stagger Logic
|
||||
CREATE TABLE IF NOT EXISTS global_restart_config (
|
||||
id SERIAL PRIMARY KEY,
|
||||
node VARCHAR(10) UNIQUE NOT NULL, -- 'TX1', 'NC1'
|
||||
base_time TIME NOT NULL, -- e.g., '04:00:00' (UTC)
|
||||
interval_minutes INT DEFAULT 5, -- Stagger gap
|
||||
is_enabled BOOLEAN DEFAULT true, -- Global master switch per node
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by VARCHAR(50) -- Discord Username
|
||||
);
|
||||
|
||||
-- 2. Individual Server Execution State
|
||||
CREATE TABLE IF NOT EXISTS server_restart_schedules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id VARCHAR(50) UNIQUE NOT NULL, -- Pterodactyl 8-char short ID
|
||||
server_name VARCHAR(100) NOT NULL,
|
||||
node VARCHAR(10) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0, -- Manual boot order
|
||||
effective_time TIME, -- Calculated: base + (sort * interval)
|
||||
ptero_schedule_id INT DEFAULT NULL, -- ID of schedule on Pterodactyl
|
||||
skip_restart BOOLEAN DEFAULT false, -- Individual "Maintenance Mode"
|
||||
sync_status VARCHAR(20) DEFAULT 'PENDING', -- 'SUCCESS', 'PENDING', 'FAILED'
|
||||
last_error TEXT DEFAULT NULL, -- API error capture
|
||||
last_synced_at TIMESTAMP NULL,
|
||||
|
||||
CONSTRAINT fk_node_config
|
||||
FOREIGN KEY (node)
|
||||
REFERENCES global_restart_config(node)
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 3. Audit Trail for Sync Operations
|
||||
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id VARCHAR(50) NOT NULL,
|
||||
action VARCHAR(255) NOT NULL, -- e.g., 'Deleted Rogue Schedule', 'Created Schedule'
|
||||
status VARCHAR(20) NOT NULL, -- 'SUCCESS', 'FAILED'
|
||||
error_message TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 4. Performance Indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_server_node_order ON server_restart_schedules (node, sort_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_status ON server_restart_schedules (sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_logs_server ON sync_logs (server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_logs_created ON sync_logs (created_at);
|
||||
|
||||
-- 5. Initial Seed Data
|
||||
INSERT INTO global_restart_config (node, base_time, interval_minutes, updated_by)
|
||||
VALUES
|
||||
('TX1', '04:00:00', 5, 'The Wizard'),
|
||||
('NC1', '04:30:00', 5, 'The Wizard')
|
||||
ON CONFLICT (node) DO NOTHING;
|
||||
@@ -8,10 +8,13 @@
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"csurf": "^1.11.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"discord.js": "^14.14.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"ejs": "^3.1.9",
|
||||
|
||||
73
services/arbiter-3.0/scripts/add-category-emojis.js
Normal file
73
services/arbiter-3.0/scripts/add-category-emojis.js
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Add Emoji Prefixes to Categories
|
||||
* Adds consistent emoji prefixes to non-server categories
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
|
||||
|
||||
const RENAMES = [
|
||||
{ from: 'Welcome & Info', to: '📢 Welcome & Info' },
|
||||
{ from: 'Community Hub', to: '💬 Community Hub' },
|
||||
{ from: 'Voice Channels', to: '🔊 Voice Channels' },
|
||||
{ from: 'Support', to: '📞 Support' }
|
||||
];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🏷️ Add Emoji Prefixes to Categories');
|
||||
console.log('=====================================');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds]
|
||||
});
|
||||
|
||||
try {
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
await guild.channels.fetch();
|
||||
console.log(`✅ Found guild: ${guild.name}`);
|
||||
console.log('');
|
||||
|
||||
let renamed = 0;
|
||||
|
||||
for (const rename of RENAMES) {
|
||||
const category = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory && ch.name === rename.from
|
||||
);
|
||||
|
||||
if (!category) {
|
||||
console.log(`⚠️ Not found: ${rename.from}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await category.setName(rename.to, 'Adding emoji prefix - Chronicler #71');
|
||||
console.log(`✅ ${rename.from} → ${rename.to}`);
|
||||
renamed++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`✅ Renamed ${renamed} categories`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR:', error.message);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
109
services/arbiter-3.0/scripts/add-delserver-docs.js
Normal file
109
services/arbiter-3.0/scripts/add-delserver-docs.js
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Add /delserver documentation to #staff-commands
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType, EmbedBuilder } = require('discord.js');
|
||||
|
||||
async function main() {
|
||||
console.log('📋 Adding /delserver to #staff-commands');
|
||||
console.log('=======================================');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds]
|
||||
});
|
||||
|
||||
try {
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
await guild.channels.fetch();
|
||||
|
||||
// Find #staff-commands
|
||||
const channel = guild.channels.cache.find(
|
||||
ch => ch.name === 'staff-commands'
|
||||
);
|
||||
|
||||
if (!channel) {
|
||||
console.log('❌ #staff-commands not found!');
|
||||
return;
|
||||
}
|
||||
console.log(`✅ Found: #${channel.name}`);
|
||||
|
||||
// /delserver command embed
|
||||
const delServerEmbed = new EmbedBuilder()
|
||||
.setColor(0xDC3545) // Red for danger/delete
|
||||
.setTitle('🗑️ /delserver')
|
||||
.setDescription('Permanently deletes a server setup including all channels and the role.')
|
||||
.addFields(
|
||||
{
|
||||
name: '👥 Who Can Use',
|
||||
value: 'Staff, Moderators, Trinity only',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '📍 Where to Use',
|
||||
value: 'Any channel',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '📝 Usage',
|
||||
value: '```/delserver name:Server Name confirm:True```',
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '⚠️ Preview Mode',
|
||||
value: `Running without \`confirm:True\` shows a preview:
|
||||
\`\`\`/delserver name:Server Name\`\`\`
|
||||
This lists what would be deleted without actually deleting anything.`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '🗑️ What It Deletes',
|
||||
value: `• All channels in the category (chat, in-game, forum, voice)
|
||||
• The category itself
|
||||
• The server role`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '📋 After Running',
|
||||
value: `Don't forget to:
|
||||
|
||||
1. Remove the reaction emoji from <#1403980899464384572>
|
||||
2. Remove the role mapping from Carl-bot`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '💡 Notes',
|
||||
value: `• Always preview first before confirming
|
||||
• Give players at least 7 days notice before deleting
|
||||
• This is permanent — there is no undo
|
||||
• Forum posts and messages are lost forever`,
|
||||
inline: false
|
||||
}
|
||||
)
|
||||
.setFooter({ text: 'The Arbiter • Firefrost Gaming' });
|
||||
|
||||
await channel.send({ embeds: [delServerEmbed] });
|
||||
console.log('✅ Posted /delserver documentation');
|
||||
|
||||
console.log('');
|
||||
console.log('✅ Done! Don\'t forget to pin it.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR:', error.message);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
97
services/arbiter-3.0/scripts/archive-welcome-posts.js
Normal file
97
services/arbiter-3.0/scripts/archive-welcome-posts.js
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Archive Welcome Posts
|
||||
* Archives all welcome threads in forum channels so they don't
|
||||
* keep forums "active" when categories are collapsed
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('📦 Archive Welcome Posts');
|
||||
console.log('========================');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds]
|
||||
});
|
||||
|
||||
try {
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
await guild.channels.fetch();
|
||||
console.log(`✅ Found guild: ${guild.name}`);
|
||||
console.log('');
|
||||
|
||||
// Find all forum channels
|
||||
const forums = guild.channels.cache.filter(ch => ch.type === ChannelType.GuildForum);
|
||||
console.log(`Found ${forums.size} forum channels`);
|
||||
console.log('');
|
||||
|
||||
let archived = 0;
|
||||
let alreadyArchived = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const [id, forum] of forums) {
|
||||
// Skip if not a server forum (check for 🎮 parent or server-related name)
|
||||
const parent = forum.parent;
|
||||
if (!parent || !parent.name.includes('🎮')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📁 ${forum.name}`);
|
||||
|
||||
// Fetch active threads
|
||||
const activeThreads = await forum.threads.fetchActive();
|
||||
|
||||
for (const [threadId, thread] of activeThreads.threads) {
|
||||
// Look for welcome posts
|
||||
if (thread.name.toLowerCase().includes('welcome')) {
|
||||
if (thread.archived) {
|
||||
console.log(` ⏭️ Already archived: ${thread.name}`);
|
||||
alreadyArchived++;
|
||||
} else {
|
||||
try {
|
||||
await thread.setArchived(true, 'Archiving welcome post - Chronicler #71');
|
||||
console.log(` ✅ Archived: ${thread.name}`);
|
||||
archived++;
|
||||
await sleep(300);
|
||||
} catch (err) {
|
||||
console.log(` ❌ Failed to archive: ${thread.name} - ${err.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('📊 SUMMARY');
|
||||
console.log('==========');
|
||||
console.log(`Archived: ${archived}`);
|
||||
console.log(`Already archived: ${alreadyArchived}`);
|
||||
console.log(`Errors: ${errors}`);
|
||||
console.log('');
|
||||
console.log('✅ Done! Categories should now collapse cleanly.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR:', error.message);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
211
services/arbiter-3.0/scripts/create-staff-commands-channel.js
Normal file
211
services/arbiter-3.0/scripts/create-staff-commands-channel.js
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Create #staff-commands channel with command documentation
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType, EmbedBuilder } = require('discord.js');
|
||||
|
||||
async function main() {
|
||||
console.log('📋 Creating #staff-commands channel');
|
||||
console.log('====================================');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds]
|
||||
});
|
||||
|
||||
try {
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
await guild.channels.fetch();
|
||||
console.log(`✅ Found guild: ${guild.name}`);
|
||||
|
||||
// Find Staff Area category
|
||||
const staffCategory = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory && ch.name.includes('Staff')
|
||||
);
|
||||
|
||||
if (!staffCategory) {
|
||||
console.log('❌ Staff Area category not found!');
|
||||
return;
|
||||
}
|
||||
console.log(`✅ Found category: ${staffCategory.name}`);
|
||||
|
||||
// Check if channel already exists
|
||||
const existingChannel = guild.channels.cache.find(
|
||||
ch => ch.name === 'staff-commands' && ch.parentId === staffCategory.id
|
||||
);
|
||||
|
||||
if (existingChannel) {
|
||||
console.log('⚠️ #staff-commands already exists, posting documentation...');
|
||||
await postDocumentation(existingChannel);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the channel
|
||||
const channel = await guild.channels.create({
|
||||
name: 'staff-commands',
|
||||
type: ChannelType.GuildText,
|
||||
parent: staffCategory.id,
|
||||
topic: 'Arbiter bot command reference for staff',
|
||||
reason: 'Staff command documentation - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created: #${channel.name}`);
|
||||
|
||||
// Post documentation
|
||||
await postDocumentation(channel);
|
||||
|
||||
console.log('');
|
||||
console.log('✅ Done! Check #staff-commands in Discord.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR:', error.message);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function postDocumentation(channel) {
|
||||
// Header message
|
||||
await channel.send({
|
||||
content: `# 🤖 Arbiter Bot Commands
|
||||
|
||||
This channel documents all slash commands available through The Arbiter. Commands are organized by who can use them.
|
||||
|
||||
---`
|
||||
});
|
||||
|
||||
// /link command embed
|
||||
const linkEmbed = new EmbedBuilder()
|
||||
.setColor(0x4ECDC4) // Frost color
|
||||
.setTitle('📎 /link')
|
||||
.setDescription('Links a Discord account to a Minecraft username for automatic whitelist management.')
|
||||
.addFields(
|
||||
{
|
||||
name: '👥 Who Can Use',
|
||||
value: 'Everyone (all server members)',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '📍 Where to Use',
|
||||
value: 'Any channel',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '📝 Usage',
|
||||
value: '```/link username:YourMinecraftName```',
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '⚙️ What It Does',
|
||||
value: `1. Validates the Minecraft username exists via Mojang API
|
||||
2. Stores the Discord ↔ Minecraft link in the database
|
||||
3. Triggers an immediate whitelist sync across all servers
|
||||
4. Player is automatically whitelisted on servers they have access to`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '💡 Notes',
|
||||
value: `• Username is case-sensitive (uses Mojang's official casing)
|
||||
• Each Discord account can only link one Minecraft account
|
||||
• Re-running the command updates the linked account
|
||||
• Response is ephemeral (only visible to the user)`,
|
||||
inline: false
|
||||
}
|
||||
)
|
||||
.setFooter({ text: 'The Arbiter • Firefrost Gaming' });
|
||||
|
||||
await channel.send({ embeds: [linkEmbed] });
|
||||
|
||||
// /createserver command embed
|
||||
const createServerEmbed = new EmbedBuilder()
|
||||
.setColor(0xFF6B35) // Fire color
|
||||
.setTitle('🎮 /createserver')
|
||||
.setDescription('Creates a complete Discord server setup for a new Minecraft server with one command.')
|
||||
.addFields(
|
||||
{
|
||||
name: '👥 Who Can Use',
|
||||
value: 'Staff, Moderators, Trinity only',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '📍 Where to Use',
|
||||
value: 'Any channel',
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: '📝 Usage',
|
||||
value: '```/createserver name:Server Name```',
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '⚙️ What It Creates',
|
||||
value: `**Role:** \`Server Name\`
|
||||
|
||||
**Category:** \`🎮 Server Name\`
|
||||
|
||||
**Channels:**
|
||||
• \`server-name-chat\` — General text chat
|
||||
• \`server-name-in-game\` — In-game chat bridge
|
||||
• \`server-name-forum\` — Discussion forum with tags
|
||||
• \`Server Name\` — Voice channel
|
||||
|
||||
**Forum Tags:** Builds, Help, Suggestion, Bug Report, Achievement, Guide
|
||||
|
||||
**Welcome Post:** Auto-generated and archived`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '🔐 Permissions Applied',
|
||||
value: `• **@everyone** — Cannot see
|
||||
• **Wanderer** — Can see, cannot interact (window shopping)
|
||||
• **Server Role** — Full access
|
||||
• **Staff/Mods/Trinity** — Full access`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '📋 After Running',
|
||||
value: `The bot will suggest an unused emoji. To complete setup:
|
||||
|
||||
1. Go to <#1403980899464384572>
|
||||
2. Add the suggested emoji as a reaction
|
||||
3. Configure Carl-bot to assign the new role when reacted`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: '💡 Notes',
|
||||
value: `• Server name max 50 characters
|
||||
• Cannot create if role or category already exists
|
||||
• Channels are auto-positioned in the new category
|
||||
• Welcome post is archived so forums collapse cleanly`,
|
||||
inline: false
|
||||
}
|
||||
)
|
||||
.setFooter({ text: 'The Arbiter • Firefrost Gaming' });
|
||||
|
||||
await channel.send({ embeds: [createServerEmbed] });
|
||||
|
||||
// Future commands placeholder
|
||||
await channel.send({
|
||||
content: `---
|
||||
|
||||
## 🔮 Future Commands
|
||||
|
||||
More commands coming soon! Check back here for updates.
|
||||
|
||||
*Last updated: April 8, 2026*`
|
||||
});
|
||||
|
||||
console.log('✅ Posted command documentation');
|
||||
}
|
||||
|
||||
main();
|
||||
851
services/arbiter-3.0/scripts/discord-channel-full-setup.js
Normal file
851
services/arbiter-3.0/scripts/discord-channel-full-setup.js
Normal file
@@ -0,0 +1,851 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Discord Channel Full Setup Script
|
||||
* Task #98: Create 46 channels for 15 Minecraft servers
|
||||
*
|
||||
* Creates:
|
||||
* - 10 new categories (for servers without channels)
|
||||
* - 15 forum channels (all servers)
|
||||
* - 10 text channels (chat)
|
||||
* - 10 text channels (in-game-chat)
|
||||
* - 10 voice channels
|
||||
* - 1 Archive category
|
||||
* - 15 welcome posts
|
||||
* - Permission overwrites on all categories
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
* Spec: docs/tasks/task-098-discord-channel-automation/forum-content-spec.md
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const DRY_RUN = true; // SET TO false TO EXECUTE
|
||||
|
||||
// Standard forum tags for all server forums
|
||||
const STANDARD_FORUM_TAGS = [
|
||||
{ name: 'Builds', emoji: '🏗️' },
|
||||
{ name: 'Help', emoji: '❓' },
|
||||
{ name: 'Suggestion', emoji: '💡' },
|
||||
{ name: 'Bug Report', emoji: '🐛' },
|
||||
{ name: 'Achievement', emoji: '🎉' },
|
||||
{ name: 'Guide', emoji: '📖' }
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// SERVER DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
// Servers that ALREADY have categories (just need forum added)
|
||||
const EXISTING_SERVERS = [
|
||||
{
|
||||
name: 'Stoneblock 4',
|
||||
categoryName: 'Stoneblock 4', // Will add 🎮 prefix
|
||||
roleName: 'Stoneblock 4',
|
||||
forumName: 'stoneblock-4-forum',
|
||||
welcomeTitle: 'Welcome to Stoneblock 4!',
|
||||
welcomeBody: `🪨 **The stone remembers.**
|
||||
|
||||
You've entered the void — an endless expanse of stone hiding ancient Vaults, mysterious Echoes, and the legendary World Engine. Dig deep, automate everything, and never forget: in Stoneblock, even chickens are sacred.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your underground empires
|
||||
- ❓ Ask for help with progression
|
||||
- 💡 Suggest server improvements
|
||||
- 🎉 Celebrate your victories
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Show Us Your Starting Cave!**
|
||||
|
||||
Post a screenshot of your first base setup. Did you pick the Lush Cave? The Darkness? Show us where your journey began!
|
||||
|
||||
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||
},
|
||||
{
|
||||
name: 'Society: Sunlit Valley',
|
||||
categoryName: 'Society: Sunlit Valley',
|
||||
roleName: 'Society: Sunlit Valley',
|
||||
forumName: 'sunlit-valley-forum',
|
||||
welcomeTitle: 'Welcome to Sunlit Valley!',
|
||||
welcomeBody: `🌻 **A Stardew Valley experience in Minecraft.**
|
||||
|
||||
Welcome to the valley, farmer! Grow seasonal crops, raise animals, dive into the Skull Cavern for Iridium, and build the coziest farm this side of the blocky world. Money makes the world go round — so get shipping!
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Show off your farm layouts
|
||||
- ❓ Ask about crop rotations and bundles
|
||||
- 💡 Suggest new farming features
|
||||
- 🎉 Share your biggest hauls
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Dream Farm Name!**
|
||||
|
||||
What did you name your farm? Post it along with a screenshot of your farmhouse! Bonus points for creative theming.
|
||||
|
||||
React with 🌾 for farming focus or ⛏️ for mining focus!`
|
||||
},
|
||||
{
|
||||
name: 'All the Mods 10: To the Sky',
|
||||
categoryName: 'All the Mods 10: To the Sky',
|
||||
roleName: 'All The Mods: To the Sky',
|
||||
forumName: 'atm10-sky-forum',
|
||||
welcomeTitle: 'Welcome to ATM10: To the Sky!',
|
||||
welcomeBody: `☁️ **Start with a tree. Build an empire in the void.**
|
||||
|
||||
You've got nothing but a single tree and infinite ambition. Sieve your way to resources, automate your way to power, and craft the legendary ATM Star. This is skyblock evolved — 500+ mods of pure vertical progression.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your sky islands
|
||||
- ❓ Ask about automation setups
|
||||
- 💡 Suggest efficiency improvements
|
||||
- 🎉 Celebrate progression milestones
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Day One Screenshot!**
|
||||
|
||||
Show us your island after your first play session. Tiny platform? Sprawling network? We want to see your start!
|
||||
|
||||
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||
},
|
||||
{
|
||||
name: 'All the Mons',
|
||||
categoryName: 'All the Mons',
|
||||
roleName: 'All The Mons',
|
||||
forumName: 'all-the-mons-forum',
|
||||
welcomeTitle: 'Welcome to All the Mons!',
|
||||
welcomeBody: `🐾 **All the Mods meets Cobblemon. Gotta catch AND automate 'em all.**
|
||||
|
||||
The ultimate crossover — 500+ tech and magic mods collide with Pokémon. Build factories, cast spells, AND catch 'em all. Custom Pokéball recipes use modded materials, so your automation skills directly power your trainer journey.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Show off your bases and Pokémon pastures
|
||||
- ❓ Ask about spawn locations and evolution
|
||||
- 💡 Suggest Pokémon-related improvements
|
||||
- 🎉 Share shiny catches and team builds
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Starter Trio!**
|
||||
|
||||
Post your first three Pokémon! Did you go classic starters or catch wild ones? Show us your team!
|
||||
|
||||
React with 🔥 for Fire types or ❄️ for Ice/Water types!`
|
||||
},
|
||||
{
|
||||
name: 'Mythcraft 5',
|
||||
categoryName: 'Mythcraft 5',
|
||||
roleName: 'Mythcraft 5',
|
||||
forumName: 'mythcraft-5-forum',
|
||||
welcomeTitle: 'Welcome to Mythcraft 5!',
|
||||
welcomeBody: `⚔️ **Magic. Alchemy. Technology. Adventure.**
|
||||
|
||||
Over 1,000 structures await exploration. A custom questline guides your progression through dungeons, fortresses, and strange dimensions. Master weapons AND spells. Unlock skills. Become legend.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your bases and discoveries
|
||||
- ❓ Ask for help with progression and bosses
|
||||
- 💡 Suggest adventure improvements
|
||||
- 🎉 Celebrate boss kills and rare loot
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your First Boss Kill!**
|
||||
|
||||
What was the first boss you took down? Share the screenshot and the story!
|
||||
|
||||
React with ⚔️ for combat focus or 🔮 for magic focus!`
|
||||
}
|
||||
];
|
||||
|
||||
// Servers that need FULL setup (category + all channels)
|
||||
const NEW_SERVERS = [
|
||||
{
|
||||
name: 'Beyond Depth',
|
||||
categoryName: '🎮 Beyond Depth',
|
||||
roleName: 'Beyond Depth',
|
||||
chatName: 'beyond-depth-chat',
|
||||
inGameName: 'beyond-depth-in-game',
|
||||
voiceName: 'Beyond Depth',
|
||||
forumName: 'beyond-depth-forum',
|
||||
welcomeTitle: 'Welcome to Beyond!',
|
||||
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||
|
||||
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your progress
|
||||
- ❓ Ask for help with challenges
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate breakthroughs
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||
|
||||
What's been the hardest part? Share your struggles and triumphs!
|
||||
|
||||
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||
},
|
||||
{
|
||||
name: 'Beyond Ascension',
|
||||
categoryName: '🎮 Beyond Ascension',
|
||||
roleName: 'Beyond Ascension',
|
||||
chatName: 'beyond-ascension-chat',
|
||||
inGameName: 'beyond-ascension-in-game',
|
||||
voiceName: 'Beyond Ascension',
|
||||
forumName: 'beyond-ascension-forum',
|
||||
welcomeTitle: 'Welcome to Beyond Ascension!',
|
||||
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||
|
||||
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your progress
|
||||
- ❓ Ask for help with challenges
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate breakthroughs
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||
|
||||
What's been the hardest part? Share your struggles and triumphs!
|
||||
|
||||
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||
},
|
||||
{
|
||||
name: "Wold's Vaults",
|
||||
categoryName: "🎮 Wold's Vaults",
|
||||
roleName: "Wold's Vaults",
|
||||
chatName: 'wolds-vaults-chat',
|
||||
inGameName: 'wolds-vaults-in-game',
|
||||
voiceName: "Wold's Vaults",
|
||||
forumName: 'wolds-vaults-forum',
|
||||
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||
|
||||
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your vault hauls
|
||||
- ❓ Ask about vault strategies
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate legendary finds
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Best Vault Haul!**
|
||||
|
||||
What's the best thing you've pulled from a vault? Show us!`
|
||||
},
|
||||
{
|
||||
name: 'Otherworld [D&D]',
|
||||
categoryName: '🎮 Otherworld [D&D]',
|
||||
roleName: 'Otherworld [Dungeons & Dragons]',
|
||||
chatName: 'otherworld-chat',
|
||||
inGameName: 'otherworld-in-game',
|
||||
voiceName: 'Otherworld',
|
||||
forumName: 'otherworld-forum',
|
||||
welcomeTitle: 'Welcome to Otherworld!',
|
||||
welcomeBody: `🎲 **Roll for initiative in Minecraft.**
|
||||
|
||||
D&D-inspired adventures await. Character classes, dungeon crawling, and tabletop vibes brought to life in block form.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your characters and builds
|
||||
- ❓ Ask about classes and progression
|
||||
- 💡 Suggest adventure improvements
|
||||
- 🎉 Share epic moments
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Introduce Your Character!**
|
||||
|
||||
Name, class, backstory. Let's hear it!`
|
||||
},
|
||||
{
|
||||
name: 'DeceasedCraft',
|
||||
categoryName: '🎮 DeceasedCraft',
|
||||
roleName: 'DeceasedCraft',
|
||||
chatName: 'deceasedcraft-chat',
|
||||
inGameName: 'deceasedcraft-in-game',
|
||||
voiceName: 'DeceasedCraft',
|
||||
forumName: 'deceasedcraft-forum',
|
||||
welcomeTitle: 'Welcome to DeceasedCraft!',
|
||||
welcomeBody: `☠️ **Survive the apocalypse.**
|
||||
|
||||
The world has ended, but you haven't. Scavenge, survive, and maybe even thrive in a hostile world where death lurks around every corner.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your survival setups
|
||||
- ❓ Ask about survival strategies
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate survival milestones
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Day 7 Screenshot!**
|
||||
|
||||
If you survived a week, show us your base!`
|
||||
},
|
||||
{
|
||||
name: 'Submerged 2',
|
||||
categoryName: '🎮 Submerged 2',
|
||||
roleName: 'Submerged 2',
|
||||
chatName: 'submerged-2-chat',
|
||||
inGameName: 'submerged-2-in-game',
|
||||
voiceName: 'Submerged 2',
|
||||
forumName: 'submerged-2-forum',
|
||||
welcomeTitle: 'Welcome to Submerged 2!',
|
||||
welcomeBody: `🌊 **The depths await.**
|
||||
|
||||
Dive into an underwater adventure where the ocean is your home. Build aquatic bases, explore sunken ruins, and survive the pressure of the deep.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your underwater bases
|
||||
- ❓ Ask about aquatic survival
|
||||
- 💡 Suggest ocean improvements
|
||||
- 🎉 Celebrate deep sea discoveries
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your First Underwater Base!**
|
||||
|
||||
Show us where you set up shop beneath the waves!
|
||||
|
||||
React with 🐠 for ocean life or 🏗️ for engineering focus!`
|
||||
},
|
||||
{
|
||||
name: "Sneak's Pirate Pack",
|
||||
categoryName: "🎮 Sneak's Pirate Pack",
|
||||
roleName: "Sneak's Pirate Pack",
|
||||
chatName: 'sneaks-pirate-pack-chat',
|
||||
inGameName: 'sneaks-pirate-pack-in-game',
|
||||
voiceName: "Sneak's Pirate Pack",
|
||||
forumName: 'sneaks-pirate-pack-forum',
|
||||
welcomeTitle: "Ahoy, Welcome to Sneak's Pirate Pack!",
|
||||
welcomeBody: `🏴☠️ **Set sail for adventure!**
|
||||
|
||||
A pirate's life for thee! Build ships, explore the seas, find treasure, and live the swashbuckling dream. Just watch out for sea monsters...
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your ships and ports
|
||||
- ❓ Ask about naval adventures
|
||||
- 💡 Suggest pirate improvements
|
||||
- 🎉 Show off your treasure hoards
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Ship!**
|
||||
|
||||
Every pirate needs a vessel. Show us your pride and joy!
|
||||
|
||||
React with ⚓ for sailors or 💀 for scallywags!`
|
||||
},
|
||||
{
|
||||
name: 'Cottage Witch',
|
||||
categoryName: '🎮 Cottage Witch',
|
||||
roleName: 'Cottage Witch',
|
||||
chatName: 'cottage-witch-chat',
|
||||
inGameName: 'cottage-witch-in-game',
|
||||
voiceName: 'Cottage Witch',
|
||||
forumName: 'cottage-witch-forum',
|
||||
welcomeTitle: 'Welcome to Cottage Witch!',
|
||||
welcomeBody: `🧙 **Cozy vibes. Domestic magic. Witchy aesthetics.**
|
||||
|
||||
Cottage Witch emphasizes the magic in everyday things — cooking, crafting, decorating. Build your perfect witch's cabin, brew potions, cast spells with Ars Nouveau and Hexerei, and let Create automate your cozy life.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your witchy builds
|
||||
- ❓ Ask about magic systems
|
||||
- 💡 Suggest cozy improvements
|
||||
- 🎉 Show off your collections
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Witch's Corner!**
|
||||
|
||||
Every witch needs a cozy corner. Show us your cauldron setup, potion station, or spell crafting area!
|
||||
|
||||
React with 🌙 for dark witch or 🌻 for cottage witch!`
|
||||
},
|
||||
{
|
||||
name: 'Farm Crossing 5',
|
||||
categoryName: '🎮 Farm Crossing 5',
|
||||
roleName: 'Farm Crossing 5',
|
||||
chatName: 'farm-crossing-5-chat',
|
||||
inGameName: 'farm-crossing-5-in-game',
|
||||
voiceName: 'Farm Crossing 5',
|
||||
forumName: 'farm-crossing-5-forum',
|
||||
welcomeTitle: 'Welcome to Farm Crossing 5!',
|
||||
welcomeBody: `🌾 **The coziest crossover.**
|
||||
|
||||
Animal Crossing vibes meet Minecraft farming. Relax, decorate, farm, and make friends with your animal neighbors.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your island/farm layouts
|
||||
- ❓ Ask about villagers and farming
|
||||
- 💡 Suggest cozy additions
|
||||
- 🎉 Show off your collections
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Favorite Corner!**
|
||||
|
||||
Show us your coziest spot!`
|
||||
},
|
||||
{
|
||||
name: 'Homestead',
|
||||
categoryName: '🎮 Homestead',
|
||||
roleName: 'Homestead',
|
||||
chatName: 'homestead-chat',
|
||||
inGameName: 'homestead-in-game',
|
||||
voiceName: 'Homestead',
|
||||
forumName: 'homestead-forum',
|
||||
welcomeTitle: 'Welcome to Homestead!',
|
||||
welcomeBody: `🏠 **Build your dream. Live your peace.**
|
||||
|
||||
Homestead is all about cozy survival — building, farming, and creating your perfect world without the pressure. Take your time, make it beautiful, and enjoy the journey.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your homestead builds
|
||||
- ❓ Ask about building techniques
|
||||
- 💡 Suggest cozy additions
|
||||
- 🎉 Show off your finished projects
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Front Door!**
|
||||
|
||||
Post a screenshot standing at your front door looking out. What does home look like?
|
||||
|
||||
React with 🏡 for cottage vibes or 🏰 for grand builds!`
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find a role by name (case-insensitive)
|
||||
*/
|
||||
function findRole(guild, roleName) {
|
||||
return guild.roles.cache.find(r => r.name.toLowerCase() === roleName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a category by name (case-insensitive, ignores emoji prefix)
|
||||
*/
|
||||
function findCategory(guild, categoryName) {
|
||||
const searchName = categoryName.replace(/^🎮\s*/, '').toLowerCase();
|
||||
return guild.channels.cache.find(ch =>
|
||||
ch.type === ChannelType.GuildCategory &&
|
||||
ch.name.replace(/^🎮\s*/, '').toLowerCase() === searchName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build permission overwrites for a server category
|
||||
*/
|
||||
function buildPermissionOverwrites(guild, serverRole) {
|
||||
const everyone = guild.roles.everyone;
|
||||
const wanderer = findRole(guild, 'Wanderer');
|
||||
const staff = findRole(guild, 'Staff');
|
||||
const moderator = findRole(guild, '🛡️ Moderator');
|
||||
const wizard = findRole(guild, '👑 The Wizard');
|
||||
const emissary = findRole(guild, '💎 The Emissary');
|
||||
const catalyst = findRole(guild, '✨ The Catalyst');
|
||||
|
||||
const overwrites = [
|
||||
// @everyone - deny all
|
||||
{
|
||||
id: everyone.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||
},
|
||||
// Server role - allow all
|
||||
{
|
||||
id: serverRole.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
}
|
||||
];
|
||||
|
||||
// Wanderer - view only (window shopping)
|
||||
if (wanderer) {
|
||||
overwrites.push({
|
||||
id: wanderer.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.ReadMessageHistory],
|
||||
deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||
});
|
||||
}
|
||||
|
||||
// Staff roles - allow all
|
||||
const staffRoles = [staff, moderator, wizard, emissary, catalyst].filter(Boolean);
|
||||
for (const role of staffRoles) {
|
||||
overwrites.push({
|
||||
id: role.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
});
|
||||
}
|
||||
|
||||
return overwrites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep helper for rate limiting
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN SCRIPT
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log('🔧 Discord Channel Full Setup');
|
||||
console.log('=============================');
|
||||
console.log(`Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will create channels)'}`);
|
||||
console.log('');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||
});
|
||||
|
||||
const stats = {
|
||||
categoriesCreated: 0,
|
||||
categoriesRenamed: 0,
|
||||
forumsCreated: 0,
|
||||
textChannelsCreated: 0,
|
||||
voiceChannelsCreated: 0,
|
||||
welcomePostsCreated: 0,
|
||||
permissionsApplied: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Login
|
||||
console.log('📡 Connecting to Discord...');
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
if (!guild) throw new Error('Guild not found');
|
||||
console.log(`✅ Found guild: ${guild.name}`);
|
||||
|
||||
// Fetch all data
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
console.log(`📊 Current: ${guild.channels.cache.size} channels, ${guild.roles.cache.size} roles`);
|
||||
console.log('');
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 1: Add 🎮 prefix to existing 5 server categories
|
||||
// ========================================================================
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('PHASE 1: Update existing server categories (add 🎮 prefix)');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
for (const server of EXISTING_SERVERS) {
|
||||
const category = findCategory(guild, server.categoryName);
|
||||
if (!category) {
|
||||
console.log(` ⚠️ Category not found: ${server.categoryName}`);
|
||||
stats.errors.push(`Category not found: ${server.categoryName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add 🎮 prefix if not present
|
||||
if (!category.name.startsWith('🎮')) {
|
||||
const newName = `🎮 ${category.name}`;
|
||||
console.log(` 📝 Renaming: "${category.name}" → "${newName}"`);
|
||||
if (!DRY_RUN) {
|
||||
await category.setName(newName, 'Task #98 - Add emoji prefix');
|
||||
await sleep(500);
|
||||
}
|
||||
stats.categoriesRenamed++;
|
||||
} else {
|
||||
console.log(` ✓ Already has prefix: ${category.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 2: Add forums to existing 5 servers
|
||||
// ========================================================================
|
||||
console.log('');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('PHASE 2: Add forum channels to existing 5 servers');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
for (const server of EXISTING_SERVERS) {
|
||||
console.log(`\n 📦 ${server.name}`);
|
||||
|
||||
const category = findCategory(guild, server.categoryName);
|
||||
if (!category) {
|
||||
console.log(` ⚠️ Category not found, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverRole = findRole(guild, server.roleName);
|
||||
if (!serverRole) {
|
||||
console.log(` ⚠️ Role not found: ${server.roleName}`);
|
||||
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if forum already exists
|
||||
const existingForum = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildForum && ch.parentId === category.id
|
||||
);
|
||||
if (existingForum) {
|
||||
console.log(` ✓ Forum already exists: ${existingForum.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create forum
|
||||
console.log(` Creating forum: ${server.forumName}`);
|
||||
if (!DRY_RUN) {
|
||||
const forum = await guild.channels.create({
|
||||
name: server.forumName,
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
topic: `Discussion forum for ${server.name}`,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||
name: tag.name,
|
||||
emoji: tag.emoji ? { name: tag.emoji } : null
|
||||
})),
|
||||
permissionOverwrites: buildPermissionOverwrites(guild, serverRole),
|
||||
reason: 'Task #98 - Discord channel automation'
|
||||
});
|
||||
stats.forumsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
// Create welcome post
|
||||
console.log(` Creating welcome post...`);
|
||||
await forum.threads.create({
|
||||
name: server.welcomeTitle,
|
||||
message: { content: server.welcomeBody },
|
||||
reason: 'Task #98 - Server welcome post'
|
||||
});
|
||||
stats.welcomePostsCreated++;
|
||||
await sleep(500);
|
||||
} else {
|
||||
stats.forumsCreated++;
|
||||
stats.welcomePostsCreated++;
|
||||
}
|
||||
console.log(` ✅ Done`);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 3: Create full setup for 10 new servers
|
||||
// ========================================================================
|
||||
console.log('');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('PHASE 3: Create categories + channels for 10 new servers');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
for (const server of NEW_SERVERS) {
|
||||
console.log(`\n 📦 ${server.name}`);
|
||||
|
||||
const serverRole = findRole(guild, server.roleName);
|
||||
if (!serverRole) {
|
||||
console.log(` ⚠️ Role not found: ${server.roleName}, skipping`);
|
||||
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if category already exists
|
||||
let category = findCategory(guild, server.categoryName);
|
||||
if (category) {
|
||||
console.log(` ✓ Category already exists: ${category.name}`);
|
||||
} else {
|
||||
console.log(` Creating category: ${server.categoryName}`);
|
||||
if (!DRY_RUN) {
|
||||
category = await guild.channels.create({
|
||||
name: server.categoryName,
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites: buildPermissionOverwrites(guild, serverRole),
|
||||
reason: 'Task #98 - Discord channel automation'
|
||||
});
|
||||
await sleep(500);
|
||||
}
|
||||
stats.categoriesCreated++;
|
||||
}
|
||||
|
||||
if (!DRY_RUN && category) {
|
||||
// Create chat channel
|
||||
console.log(` Creating: ${server.chatName}`);
|
||||
await guild.channels.create({
|
||||
name: server.chatName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: `General chat for ${server.name}`,
|
||||
reason: 'Task #98 - Discord channel automation'
|
||||
});
|
||||
stats.textChannelsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
// Create in-game channel
|
||||
console.log(` Creating: ${server.inGameName}`);
|
||||
await guild.channels.create({
|
||||
name: server.inGameName,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: `In-game chat bridge for ${server.name}`,
|
||||
reason: 'Task #98 - Discord channel automation'
|
||||
});
|
||||
stats.textChannelsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
// Create voice channel
|
||||
console.log(` Creating: ${server.voiceName}`);
|
||||
await guild.channels.create({
|
||||
name: server.voiceName,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 - Discord channel automation'
|
||||
});
|
||||
stats.voiceChannelsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
// Create forum
|
||||
console.log(` Creating: ${server.forumName}`);
|
||||
const forum = await guild.channels.create({
|
||||
name: server.forumName,
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
topic: `Discussion forum for ${server.name}`,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||
name: tag.name,
|
||||
emoji: tag.emoji ? { name: tag.emoji } : null
|
||||
})),
|
||||
reason: 'Task #98 - Discord channel automation'
|
||||
});
|
||||
stats.forumsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
// Create welcome post
|
||||
console.log(` Creating welcome post...`);
|
||||
await forum.threads.create({
|
||||
name: server.welcomeTitle,
|
||||
message: { content: server.welcomeBody },
|
||||
reason: 'Task #98 - Server welcome post'
|
||||
});
|
||||
stats.welcomePostsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
stats.permissionsApplied++;
|
||||
} else if (DRY_RUN) {
|
||||
stats.textChannelsCreated += 2;
|
||||
stats.voiceChannelsCreated++;
|
||||
stats.forumsCreated++;
|
||||
stats.welcomePostsCreated++;
|
||||
stats.permissionsApplied++;
|
||||
}
|
||||
|
||||
console.log(` ✅ Done`);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 4: Create Archive category
|
||||
// ========================================================================
|
||||
console.log('');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('PHASE 4: Create Archive category');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
const existingArchive = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory && ch.name.includes('Archive')
|
||||
);
|
||||
if (existingArchive) {
|
||||
console.log(` ✓ Archive category already exists: ${existingArchive.name}`);
|
||||
} else {
|
||||
console.log(' Creating: 📦 Archive');
|
||||
if (!DRY_RUN) {
|
||||
const staff = findRole(guild, 'Staff');
|
||||
const moderator = findRole(guild, '🛡️ Moderator');
|
||||
const wizard = findRole(guild, '👑 The Wizard');
|
||||
const emissary = findRole(guild, '💎 The Emissary');
|
||||
const catalyst = findRole(guild, '✨ The Catalyst');
|
||||
|
||||
const archiveOverwrites = [
|
||||
{ id: guild.roles.everyone.id, deny: [PermissionFlagsBits.ViewChannel] }
|
||||
];
|
||||
|
||||
[staff, moderator, wizard, emissary, catalyst].filter(Boolean).forEach(role => {
|
||||
archiveOverwrites.push({
|
||||
id: role.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages]
|
||||
});
|
||||
});
|
||||
|
||||
await guild.channels.create({
|
||||
name: '📦 Archive',
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites: archiveOverwrites,
|
||||
position: 999, // Put at bottom
|
||||
reason: 'Task #98 - Archive category for retired servers'
|
||||
});
|
||||
stats.categoriesCreated++;
|
||||
} else {
|
||||
stats.categoriesCreated++;
|
||||
}
|
||||
console.log(' ✅ Done');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SUMMARY
|
||||
// ========================================================================
|
||||
console.log('');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('SUMMARY');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(` Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
|
||||
console.log(` Categories created: ${stats.categoriesCreated}`);
|
||||
console.log(` Categories renamed: ${stats.categoriesRenamed}`);
|
||||
console.log(` Forums created: ${stats.forumsCreated}`);
|
||||
console.log(` Text channels created: ${stats.textChannelsCreated}`);
|
||||
console.log(` Voice channels created: ${stats.voiceChannelsCreated}`);
|
||||
console.log(` Welcome posts created: ${stats.welcomePostsCreated}`);
|
||||
console.log(` Permission sets applied: ${stats.permissionsApplied}`);
|
||||
|
||||
if (stats.errors.length > 0) {
|
||||
console.log('');
|
||||
console.log(' ⚠️ Errors:');
|
||||
stats.errors.forEach(e => console.log(` - ${e}`));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
if (DRY_RUN) {
|
||||
console.log('🔍 This was a DRY RUN. No changes were made.');
|
||||
console.log(' Set DRY_RUN = false to execute for real.');
|
||||
} else {
|
||||
console.log('✅ All channels created successfully!');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ FATAL ERROR:', error.message);
|
||||
if (error.rawError) {
|
||||
console.error(' Raw:', JSON.stringify(error.rawError, null, 2));
|
||||
}
|
||||
} finally {
|
||||
client.destroy();
|
||||
console.log('');
|
||||
console.log('👋 Disconnected from Discord.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
338
services/arbiter-3.0/scripts/discord-channel-rename.js
Normal file
338
services/arbiter-3.0/scripts/discord-channel-rename.js
Normal file
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Discord Channel Rename Script
|
||||
*
|
||||
* Renames existing server channels to match consistent naming convention
|
||||
* based on Pterodactyl server names.
|
||||
*
|
||||
* Expected naming:
|
||||
* Category: 🎮 {Server Name}
|
||||
* Text: {base-name}-chat
|
||||
* Text: {base-name}-in-game
|
||||
* Forum: {base-name}-forum
|
||||
* Voice: {Server Name}
|
||||
*
|
||||
* Usage:
|
||||
* DRY_RUN=true node discord-channel-rename.js # Preview changes
|
||||
* DRY_RUN=false node discord-channel-rename.js # Execute changes
|
||||
*
|
||||
* Created: April 9, 2026
|
||||
* Chronicler: #74
|
||||
* Principle: "Make the plan. Execute the plan. Expect the plan to go off the rails."
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType } = require('discord.js');
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const DRY_RUN = process.env.DRY_RUN !== 'false'; // Default to dry run
|
||||
const RATE_LIMIT_DELAY = 1500; // 1.5 seconds between operations
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Convert server name to base channel name
|
||||
* "Homestead - A Cozy Survival Experience" → "homestead"
|
||||
* "All The Mons (Private) - TX" → "all-the-mons"
|
||||
* "Stoneblock 4" → "stoneblock-4"
|
||||
*/
|
||||
function toBaseName(serverName) {
|
||||
return serverName
|
||||
.split(' - ')[0] // Take part before " - " subtitle
|
||||
.replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals
|
||||
.replace(/\s*\[[^\]]*\]\s*/g, '') // Remove brackets like [Dungeons & Dragons]
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces
|
||||
.replace(/\s+/g, '-') // Spaces to hyphens
|
||||
.replace(/-+/g, '-') // Multiple hyphens to single
|
||||
.replace(/^-|-$/g, '') // Trim leading/trailing hyphens
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert server name to voice channel display name
|
||||
* Same as base but keeps proper case and removes subtitles/parentheticals
|
||||
*/
|
||||
function toVoiceName(serverName) {
|
||||
return serverName
|
||||
.split(' - ')[0]
|
||||
.replace(/\s*\([^)]*\)\s*/g, '')
|
||||
.replace(/\s*\[[^\]]*\]\s*/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Minecraft servers from Pterodactyl
|
||||
*/
|
||||
async function fetchPterodactylServers() {
|
||||
const PANEL_URL = process.env.PANEL_URL;
|
||||
const API_KEY = process.env.PANEL_APPLICATION_KEY;
|
||||
const NEST_IDS = (process.env.MINECRAFT_NEST_IDS || '1,5').split(',').map(n => parseInt(n.trim()));
|
||||
|
||||
const response = await fetch(`${PANEL_URL}/api/application/servers?per_page=100`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${API_KEY}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Pterodactyl API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Filter to Minecraft servers only
|
||||
return data.data
|
||||
.filter(s => NEST_IDS.includes(s.attributes.nest))
|
||||
.map(s => ({
|
||||
name: s.attributes.name,
|
||||
identifier: s.attributes.identifier,
|
||||
baseName: toBaseName(s.attributes.name),
|
||||
voiceName: toVoiceName(s.attributes.name)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching Discord category for a server
|
||||
*/
|
||||
function findCategoryForServer(server, categories) {
|
||||
const serverBase = server.baseName;
|
||||
const serverVoice = server.voiceName.toLowerCase();
|
||||
|
||||
for (const cat of categories) {
|
||||
const catName = cat.name.replace(/^🎮\s*/, '').toLowerCase();
|
||||
const catBase = toBaseName(cat.name);
|
||||
|
||||
// Exact match on base name
|
||||
if (catBase === serverBase) return cat;
|
||||
|
||||
// Category contains server voice name
|
||||
if (catName.includes(serverVoice)) return cat;
|
||||
|
||||
// Server voice name contains category name
|
||||
if (serverVoice.includes(catBase)) return cat;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what a channel should be renamed to based on its type
|
||||
*/
|
||||
function getExpectedChannelName(channel, server) {
|
||||
const baseName = server.baseName;
|
||||
const voiceName = server.voiceName;
|
||||
const currentName = channel.name.toLowerCase();
|
||||
|
||||
switch (channel.type) {
|
||||
case ChannelType.GuildVoice:
|
||||
return voiceName;
|
||||
|
||||
case ChannelType.GuildForum:
|
||||
return `${baseName}-forum`;
|
||||
|
||||
case ChannelType.GuildText:
|
||||
// Determine if this is chat or in-game based on current name
|
||||
if (currentName.includes('in-game') || currentName.includes('ingame')) {
|
||||
return `${baseName}-in-game`;
|
||||
} else if (currentName.includes('chat') || currentName.includes('general')) {
|
||||
return `${baseName}-chat`;
|
||||
} else {
|
||||
// Default text channel to chat
|
||||
return `${baseName}-chat`;
|
||||
}
|
||||
|
||||
default:
|
||||
return null; // Don't rename unknown types
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log('');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('🔧 Discord Channel Rename Script');
|
||||
console.log(` Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will rename channels)'}`);
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('');
|
||||
|
||||
// Initialize Discord client
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds]
|
||||
});
|
||||
|
||||
try {
|
||||
// Login
|
||||
console.log('📡 Connecting to Discord...');
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => client.once('ready', resolve));
|
||||
console.log(` Logged in as ${client.user.tag}`);
|
||||
|
||||
// Get guild
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
if (!guild) {
|
||||
throw new Error(`Guild not found: ${process.env.GUILD_ID}`);
|
||||
}
|
||||
console.log(` Guild: ${guild.name}`);
|
||||
console.log('');
|
||||
|
||||
// Fetch Pterodactyl servers
|
||||
console.log('📋 Fetching Pterodactyl servers...');
|
||||
const servers = await fetchPterodactylServers();
|
||||
console.log(` Found ${servers.length} Minecraft servers`);
|
||||
console.log('');
|
||||
|
||||
// Get all 🎮 categories
|
||||
const categories = guild.channels.cache
|
||||
.filter(ch => ch.type === ChannelType.GuildCategory && ch.name.includes('🎮'))
|
||||
.map(ch => ch);
|
||||
console.log(` Found ${categories.length} game categories in Discord`);
|
||||
console.log('');
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
matched: 0,
|
||||
unmatched: [],
|
||||
renames: [],
|
||||
alreadyCorrect: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Process each server
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('PROCESSING SERVERS');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
for (const server of servers) {
|
||||
console.log(`\n📦 ${server.name}`);
|
||||
console.log(` Base: ${server.baseName} | Voice: ${server.voiceName}`);
|
||||
|
||||
// Find matching category
|
||||
const category = findCategoryForServer(server, categories);
|
||||
|
||||
if (!category) {
|
||||
console.log(` ⚠️ No matching category found`);
|
||||
stats.unmatched.push(server.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` 📁 Matched to: ${category.name}`);
|
||||
stats.matched++;
|
||||
|
||||
// Get children of this category
|
||||
const children = guild.channels.cache.filter(ch => ch.parentId === category.id);
|
||||
|
||||
// Check each child channel
|
||||
for (const [, channel] of children) {
|
||||
const expectedName = getExpectedChannelName(channel, server);
|
||||
|
||||
if (!expectedName) {
|
||||
console.log(` ⏭️ Skipping ${channel.name} (unknown type: ${channel.type})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (channel.name === expectedName) {
|
||||
console.log(` ✓ ${channel.name} (already correct)`);
|
||||
stats.alreadyCorrect++;
|
||||
} else {
|
||||
console.log(` 🔄 ${channel.name} → ${expectedName}`);
|
||||
stats.renames.push({
|
||||
from: channel.name,
|
||||
to: expectedName,
|
||||
channelId: channel.id,
|
||||
type: ChannelType[channel.type]
|
||||
});
|
||||
|
||||
if (!DRY_RUN) {
|
||||
try {
|
||||
await channel.setName(expectedName, 'Discord channel rename script - consistent naming');
|
||||
console.log(` ✅ Renamed!`);
|
||||
await sleep(RATE_LIMIT_DELAY);
|
||||
} catch (err) {
|
||||
console.log(` ❌ Error: ${err.message}`);
|
||||
stats.errors.push({ channel: channel.name, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if category itself needs renaming
|
||||
const expectedCatName = `🎮 ${server.voiceName}`;
|
||||
if (category.name !== expectedCatName) {
|
||||
console.log(` 🔄 Category: ${category.name} → ${expectedCatName}`);
|
||||
stats.renames.push({
|
||||
from: category.name,
|
||||
to: expectedCatName,
|
||||
channelId: category.id,
|
||||
type: 'Category'
|
||||
});
|
||||
|
||||
if (!DRY_RUN) {
|
||||
try {
|
||||
await category.setName(expectedCatName, 'Discord channel rename script - consistent naming');
|
||||
console.log(` ✅ Renamed!`);
|
||||
await sleep(RATE_LIMIT_DELAY);
|
||||
} catch (err) {
|
||||
console.log(` ❌ Error: ${err.message}`);
|
||||
stats.errors.push({ channel: category.name, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('SUMMARY');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(` Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
|
||||
console.log(` Servers matched: ${stats.matched}`);
|
||||
console.log(` Already correct: ${stats.alreadyCorrect}`);
|
||||
console.log(` Renames ${DRY_RUN ? 'needed' : 'executed'}: ${stats.renames.length}`);
|
||||
|
||||
if (stats.unmatched.length > 0) {
|
||||
console.log('');
|
||||
console.log(' ⚠️ Unmatched servers (no Discord category found):');
|
||||
stats.unmatched.forEach(s => console.log(` - ${s}`));
|
||||
}
|
||||
|
||||
if (stats.errors.length > 0) {
|
||||
console.log('');
|
||||
console.log(' ❌ Errors:');
|
||||
stats.errors.forEach(e => console.log(` - ${e.channel}: ${e.error}`));
|
||||
}
|
||||
|
||||
if (DRY_RUN && stats.renames.length > 0) {
|
||||
console.log('');
|
||||
console.log(' 📝 Pending renames:');
|
||||
stats.renames.forEach(r => console.log(` ${r.type}: ${r.from} → ${r.to}`));
|
||||
console.log('');
|
||||
console.log(' Run with DRY_RUN=false to execute these changes.');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ FATAL ERROR:', error.message);
|
||||
if (error.stack) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
} finally {
|
||||
client.destroy();
|
||||
console.log('👋 Disconnected from Discord.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
812
services/arbiter-3.0/scripts/discord-channel-setup.js
Normal file
812
services/arbiter-3.0/scripts/discord-channel-setup.js
Normal file
@@ -0,0 +1,812 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Discord Channel Setup — Full Production Script
|
||||
* Task #98: Discord Channel Automation
|
||||
*
|
||||
* Creates 46 channels across 15 server categories:
|
||||
* - 5 existing servers: Add forum only
|
||||
* - 10 new servers: Create category + chat + in-game + forum + voice
|
||||
* - 1 archive category (staff only)
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
* Spec: docs/tasks/task-098-discord-channel-automation/forum-content-spec.md
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const DRY_RUN = false; // Set to true to preview without creating
|
||||
|
||||
// Standard forum tags for all server forums
|
||||
const STANDARD_FORUM_TAGS = [
|
||||
{ name: 'Builds', emoji: '🏗️' },
|
||||
{ name: 'Help', emoji: '❓' },
|
||||
{ name: 'Suggestion', emoji: '💡' },
|
||||
{ name: 'Bug Report', emoji: '🐛' },
|
||||
{ name: 'Achievement', emoji: '🎉' },
|
||||
{ name: 'Guide', emoji: '📖' }
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// SERVER DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
// Existing 5 servers — have categories, need forums only
|
||||
const EXISTING_SERVERS = [
|
||||
{
|
||||
name: 'Stoneblock 4',
|
||||
categoryName: 'Stoneblock 4', // Current name (will add 🎮 prefix)
|
||||
roleName: 'Stoneblock 4',
|
||||
welcomeTitle: 'Welcome to Stoneblock 4!',
|
||||
welcomeBody: `🪨 **The stone remembers.**
|
||||
|
||||
You've entered the void — an endless expanse of stone hiding ancient Vaults, mysterious Echoes, and the legendary World Engine. Dig deep, automate everything, and never forget: in Stoneblock, even chickens are sacred.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your underground empires
|
||||
- ❓ Ask for help with progression
|
||||
- 💡 Suggest server improvements
|
||||
- 🎉 Celebrate your victories
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Show Us Your Starting Cave!**
|
||||
|
||||
Post a screenshot of your first base setup. Did you pick the Lush Cave? The Darkness? Show us where your journey began!
|
||||
|
||||
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||
},
|
||||
{
|
||||
name: 'Society: Sunlit Valley',
|
||||
categoryName: 'Society: Sunlit Valley',
|
||||
roleName: 'Society: Sunlit Valley',
|
||||
welcomeTitle: 'Welcome to Sunlit Valley!',
|
||||
welcomeBody: `🌻 **A Stardew Valley experience in Minecraft.**
|
||||
|
||||
Welcome to the valley, farmer! Grow seasonal crops, raise animals, dive into the Skull Cavern for Iridium, and build the coziest farm this side of the blocky world. Money makes the world go round — so get shipping!
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Show off your farm layouts
|
||||
- ❓ Ask about crop rotations and bundles
|
||||
- 💡 Suggest new farming features
|
||||
- 🎉 Share your biggest hauls
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Dream Farm Name!**
|
||||
|
||||
What did you name your farm? Post it along with a screenshot of your farmhouse! Bonus points for creative theming.
|
||||
|
||||
React with 🌾 for farming focus or ⛏️ for mining focus!`
|
||||
},
|
||||
{
|
||||
name: 'All the Mods 10: To the Sky',
|
||||
categoryName: 'All the Mods 10: To the Sky',
|
||||
roleName: 'All The Mods: To the Sky',
|
||||
welcomeTitle: 'Welcome to ATM10: To the Sky!',
|
||||
welcomeBody: `☁️ **Start with a tree. Build an empire in the void.**
|
||||
|
||||
You've got nothing but a single tree and infinite ambition. Sieve your way to resources, automate your way to power, and craft the legendary ATM Star. This is skyblock evolved — 500+ mods of pure vertical progression.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your sky islands
|
||||
- ❓ Ask about automation setups
|
||||
- 💡 Suggest efficiency improvements
|
||||
- 🎉 Celebrate progression milestones
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Day One Screenshot!**
|
||||
|
||||
Show us your island after your first play session. Tiny platform? Sprawling network? We want to see your start!
|
||||
|
||||
React with 🔥 for Fire Path or ❄️ for Frost Path!`
|
||||
},
|
||||
{
|
||||
name: 'All the Mons',
|
||||
categoryName: 'All the Mons',
|
||||
roleName: 'All The Mons',
|
||||
welcomeTitle: 'Welcome to All the Mons!',
|
||||
welcomeBody: `🐾 **All the Mods meets Cobblemon. Gotta catch AND automate 'em all.**
|
||||
|
||||
The ultimate crossover — 500+ tech and magic mods collide with Pokémon. Build factories, cast spells, AND catch 'em all. Custom Pokéball recipes use modded materials, so your automation skills directly power your trainer journey.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Show off your bases and Pokémon pastures
|
||||
- ❓ Ask about spawn locations and evolution
|
||||
- 💡 Suggest Pokémon-related improvements
|
||||
- 🎉 Share shiny catches and team builds
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Starter Trio!**
|
||||
|
||||
Post your first three Pokémon! Did you go classic starters or catch wild ones? Show us your team!
|
||||
|
||||
React with 🔥 for Fire types or ❄️ for Ice/Water types!`
|
||||
},
|
||||
{
|
||||
name: 'Mythcraft 5',
|
||||
categoryName: 'Mythcraft 5',
|
||||
roleName: 'Mythcraft 5',
|
||||
welcomeTitle: 'Welcome to Mythcraft 5!',
|
||||
welcomeBody: `⚔️ **Magic. Alchemy. Technology. Adventure.**
|
||||
|
||||
Over 1,000 structures await exploration. A custom questline guides your progression through dungeons, fortresses, and strange dimensions. Master weapons AND spells. Unlock skills. Become legend.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your bases and discoveries
|
||||
- ❓ Ask for help with progression and bosses
|
||||
- 💡 Suggest adventure improvements
|
||||
- 🎉 Celebrate boss kills and rare loot
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your First Boss Kill!**
|
||||
|
||||
What was the first boss you took down? Share the screenshot and the story!
|
||||
|
||||
React with ⚔️ for combat focus or 🔮 for magic focus!`
|
||||
}
|
||||
];
|
||||
|
||||
// New 10 servers — need category + all channels
|
||||
const NEW_SERVERS = [
|
||||
{
|
||||
name: 'Beyond Depth',
|
||||
roleName: 'Beyond Depth',
|
||||
welcomeTitle: 'Welcome to Beyond!',
|
||||
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||
|
||||
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your progress
|
||||
- ❓ Ask for help with challenges
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate breakthroughs
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||
|
||||
What's been the hardest part? Share your struggles and triumphs!
|
||||
|
||||
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||
},
|
||||
{
|
||||
name: 'Beyond Ascension',
|
||||
roleName: 'Beyond Ascension',
|
||||
welcomeTitle: 'Welcome to Beyond!',
|
||||
welcomeBody: `🐉 **Push the limits. Go beyond.**
|
||||
|
||||
Whether you're diving into the depths or ascending to new heights, the Beyond series challenges you to master progression and conquer the unknown.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your progress
|
||||
- ❓ Ask for help with challenges
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate breakthroughs
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Biggest Challenge So Far!**
|
||||
|
||||
What's been the hardest part? Share your struggles and triumphs!
|
||||
|
||||
React with ⬇️ for Depth or ⬆️ for Ascension!`
|
||||
},
|
||||
{
|
||||
name: "Wold's Vaults",
|
||||
roleName: "Wold's Vaults",
|
||||
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||
|
||||
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your vault hauls
|
||||
- ❓ Ask about vault strategies
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate legendary finds
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Best Vault Haul!**
|
||||
|
||||
What's the best thing you've pulled from a vault? Show us!`
|
||||
},
|
||||
{
|
||||
name: 'Otherworld [D&D]',
|
||||
roleName: 'Otherworld [Dungeons & Dragons]',
|
||||
welcomeTitle: 'Welcome to Otherworld!',
|
||||
welcomeBody: `🎲 **Roll for initiative in Minecraft.**
|
||||
|
||||
D&D-inspired adventures await. Character classes, dungeon crawling, and tabletop vibes brought to life in block form.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your characters and builds
|
||||
- ❓ Ask about classes and progression
|
||||
- 💡 Suggest adventure improvements
|
||||
- 🎉 Share epic moments
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Introduce Your Character!**
|
||||
|
||||
Name, class, backstory. Let's hear it!`
|
||||
},
|
||||
{
|
||||
name: 'DeceasedCraft',
|
||||
roleName: 'DeceasedCraft',
|
||||
welcomeTitle: 'Welcome to DeceasedCraft!',
|
||||
welcomeBody: `☠️ **Survive the apocalypse.**
|
||||
|
||||
The world has ended, but you haven't. Scavenge, survive, and maybe even thrive in a hostile world where death lurks around every corner.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your survival setups
|
||||
- ❓ Ask about survival strategies
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate survival milestones
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Day 7 Screenshot!**
|
||||
|
||||
If you survived a week, show us your base!`
|
||||
},
|
||||
{
|
||||
name: 'Submerged 2',
|
||||
roleName: 'Submerged 2',
|
||||
welcomeTitle: 'Welcome to Submerged 2!',
|
||||
welcomeBody: `🌊 **The depths await.**
|
||||
|
||||
Dive into an underwater adventure where the ocean is your home. Build aquatic bases, explore sunken ruins, and survive the pressure of the deep.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your underwater bases
|
||||
- ❓ Ask about aquatic survival
|
||||
- 💡 Suggest ocean improvements
|
||||
- 🎉 Celebrate deep sea discoveries
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your First Underwater Base!**
|
||||
|
||||
Show us where you set up shop beneath the waves!
|
||||
|
||||
React with 🐠 for ocean life or 🏗️ for engineering focus!`
|
||||
},
|
||||
{
|
||||
name: "Sneak's Pirate Pack",
|
||||
roleName: "Sneak's Pirate Pack",
|
||||
welcomeTitle: "Ahoy, Welcome to Sneak's Pirate Pack!",
|
||||
welcomeBody: `🏴☠️ **Set sail for adventure!**
|
||||
|
||||
A pirate's life for thee! Build ships, explore the seas, find treasure, and live the swashbuckling dream. Just watch out for sea monsters...
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your ships and ports
|
||||
- ❓ Ask about naval adventures
|
||||
- 💡 Suggest pirate improvements
|
||||
- 🎉 Show off your treasure hoards
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Ship!**
|
||||
|
||||
Every pirate needs a vessel. Show us your pride and joy!
|
||||
|
||||
React with ⚓ for sailors or 💀 for scallywags!`
|
||||
},
|
||||
{
|
||||
name: 'Cottage Witch',
|
||||
roleName: 'Cottage Witch',
|
||||
welcomeTitle: 'Welcome to Cottage Witch!',
|
||||
welcomeBody: `🧙 **Cozy vibes. Domestic magic. Witchy aesthetics.**
|
||||
|
||||
Cottage Witch emphasizes the magic in everyday things — cooking, crafting, decorating. Build your perfect witch's cabin, brew potions, cast spells with Ars Nouveau and Hexerei, and let Create automate your cozy life.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your witchy builds
|
||||
- ❓ Ask about magic systems
|
||||
- 💡 Suggest cozy improvements
|
||||
- 🎉 Show off your collections
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Witch's Corner!**
|
||||
|
||||
Every witch needs a cozy corner. Show us your cauldron setup, potion station, or spell crafting area!
|
||||
|
||||
React with 🌙 for dark witch or 🌻 for cottage witch!`
|
||||
},
|
||||
{
|
||||
name: 'Farm Crossing 5',
|
||||
roleName: 'Farm Crossing 5',
|
||||
welcomeTitle: 'Welcome to Farm Crossing 5!',
|
||||
welcomeBody: `🌾 **The coziest crossover.**
|
||||
|
||||
Animal Crossing vibes meet Minecraft farming. Relax, decorate, farm, and make friends with your animal neighbors.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your island/farm layouts
|
||||
- ❓ Ask about villagers and farming
|
||||
- 💡 Suggest cozy additions
|
||||
- 🎉 Show off your collections
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Favorite Corner!**
|
||||
|
||||
Show us your coziest spot!`
|
||||
},
|
||||
{
|
||||
name: 'Homestead',
|
||||
roleName: 'Homestead',
|
||||
welcomeTitle: 'Welcome to Homestead!',
|
||||
welcomeBody: `🏠 **Build your dream. Live your peace.**
|
||||
|
||||
Homestead is all about cozy survival — building, farming, and creating your perfect world without the pressure. Take your time, make it beautiful, and enjoy the journey.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your homestead builds
|
||||
- ❓ Ask about building techniques
|
||||
- 💡 Suggest cozy additions
|
||||
- 🎉 Show off your finished projects
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Front Door!**
|
||||
|
||||
Post a screenshot standing at your front door looking out. What does home look like?
|
||||
|
||||
React with 🏡 for cottage vibes or 🏰 for grand builds!`
|
||||
}
|
||||
];
|
||||
|
||||
// Staff/Admin roles that get full access
|
||||
const ADMIN_ROLES = [
|
||||
'Staff',
|
||||
'🛡️ Moderator',
|
||||
'👑 The Wizard',
|
||||
'💎 The Emissary',
|
||||
'✨ The Catalyst'
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
function slugify(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.substring(0, 100);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN SCRIPT
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log('🎮 Discord Channel Setup — Full Production Script');
|
||||
console.log('=================================================');
|
||||
console.log(`Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will create channels)'}`);
|
||||
console.log('');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||
});
|
||||
|
||||
const stats = {
|
||||
categoriesCreated: 0,
|
||||
categoriesRenamed: 0,
|
||||
forumsCreated: 0,
|
||||
textChannelsCreated: 0,
|
||||
voiceChannelsCreated: 0,
|
||||
welcomePostsCreated: 0,
|
||||
permissionsApplied: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Login
|
||||
console.log('📡 Connecting to Discord...');
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
// Get guild
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
if (!guild) throw new Error('Guild not found');
|
||||
console.log(`✅ Found guild: ${guild.name}`);
|
||||
|
||||
// Fetch all data
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
await guild.members.fetch();
|
||||
console.log(`📊 Current: ${guild.channels.cache.size} channels, ${guild.roles.cache.size} roles`);
|
||||
console.log('');
|
||||
|
||||
// Build role lookup
|
||||
const rolesByName = new Map();
|
||||
guild.roles.cache.forEach(role => {
|
||||
rolesByName.set(role.name, role);
|
||||
});
|
||||
|
||||
// Get key roles
|
||||
const everyoneRole = guild.roles.everyone;
|
||||
const wandererRole = rolesByName.get('Wanderer');
|
||||
|
||||
if (!wandererRole) {
|
||||
throw new Error('Wanderer role not found!');
|
||||
}
|
||||
console.log(`✅ Found Wanderer role: ${wandererRole.id}`);
|
||||
|
||||
// Get admin roles
|
||||
const adminRoleIds = [];
|
||||
for (const roleName of ADMIN_ROLES) {
|
||||
const role = rolesByName.get(roleName);
|
||||
if (role) {
|
||||
adminRoleIds.push(role.id);
|
||||
console.log(`✅ Found admin role: ${roleName} (${role.id})`);
|
||||
} else {
|
||||
console.log(`⚠️ Admin role not found: ${roleName}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 1: Process existing 5 servers (add forum + rename category)
|
||||
// ========================================================================
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('PHASE 1: Existing 5 Servers — Add Forums + Rename Categories');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
|
||||
for (const server of EXISTING_SERVERS) {
|
||||
console.log(`📁 Processing: ${server.name}`);
|
||||
|
||||
// Find existing category
|
||||
let category = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory &&
|
||||
(ch.name === server.categoryName || ch.name === `🎮 ${server.categoryName}`)
|
||||
);
|
||||
|
||||
if (!category) {
|
||||
console.log(` ❌ Category not found: ${server.categoryName}`);
|
||||
stats.errors.push(`Category not found: ${server.categoryName}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ✅ Found category: ${category.name} (${category.id})`);
|
||||
|
||||
// Find server role
|
||||
const serverRole = rolesByName.get(server.roleName);
|
||||
if (!serverRole) {
|
||||
console.log(` ❌ Role not found: ${server.roleName}`);
|
||||
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [DRY RUN] Would rename category to: 🎮 ${server.categoryName}`);
|
||||
console.log(` [DRY RUN] Would create forum: ${slugify(server.name)}-forum`);
|
||||
console.log(` [DRY RUN] Would post welcome message`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rename category if needed
|
||||
if (!category.name.startsWith('🎮')) {
|
||||
await category.setName(`🎮 ${server.categoryName}`);
|
||||
console.log(` ✅ Renamed category to: 🎮 ${server.categoryName}`);
|
||||
stats.categoriesRenamed++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Check if forum already exists
|
||||
const existingForum = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildForum && ch.parentId === category.id
|
||||
);
|
||||
|
||||
if (existingForum) {
|
||||
console.log(` ⚠️ Forum already exists: ${existingForum.name}`);
|
||||
} else {
|
||||
// Create forum
|
||||
const forum = await guild.channels.create({
|
||||
name: `${slugify(server.name)}-forum`,
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
topic: `Discussion forum for ${server.name}`,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||
name: tag.name,
|
||||
emoji: { name: tag.emoji }
|
||||
})),
|
||||
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Created forum: ${forum.name} (${forum.id})`);
|
||||
stats.forumsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
// Post welcome message
|
||||
const welcomeThread = await forum.threads.create({
|
||||
name: server.welcomeTitle,
|
||||
message: { content: server.welcomeBody },
|
||||
reason: 'Task #98 Welcome Post - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Posted welcome: ${welcomeThread.name}`);
|
||||
stats.welcomePostsCreated++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Apply permissions to category
|
||||
const permissionOverwrites = [
|
||||
// @everyone: deny all
|
||||
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
// Wanderer: view only
|
||||
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
// Server role: full access
|
||||
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||
// Admin roles: full access
|
||||
...adminRoleIds.map(roleId => ({
|
||||
id: roleId,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
}))
|
||||
];
|
||||
|
||||
await category.permissionOverwrites.set(permissionOverwrites);
|
||||
console.log(` ✅ Applied permissions to category`);
|
||||
stats.permissionsApplied++;
|
||||
await sleep(500);
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 2: Create 10 new servers (category + all channels)
|
||||
// ========================================================================
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('PHASE 2: New 10 Servers — Create Categories + All Channels');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
|
||||
for (const server of NEW_SERVERS) {
|
||||
console.log(`📁 Creating: ${server.name}`);
|
||||
|
||||
// Find server role
|
||||
const serverRole = rolesByName.get(server.roleName);
|
||||
if (!serverRole) {
|
||||
console.log(` ❌ Role not found: ${server.roleName}`);
|
||||
stats.errors.push(`Role not found: ${server.roleName}`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||
|
||||
// Check if category already exists
|
||||
let category = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory &&
|
||||
(ch.name === server.name || ch.name === `🎮 ${server.name}`)
|
||||
);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log(` [DRY RUN] Would create category: 🎮 ${server.name}`);
|
||||
console.log(` [DRY RUN] Would create: ${slugify(server.name)}-chat`);
|
||||
console.log(` [DRY RUN] Would create: ${slugify(server.name)}-in-game`);
|
||||
console.log(` [DRY RUN] Would create: ${slugify(server.name)}-forum`);
|
||||
console.log(` [DRY RUN] Would create voice: ${server.name}`);
|
||||
console.log(` [DRY RUN] Would post welcome message`);
|
||||
console.log('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build permission overwrites
|
||||
const permissionOverwrites = [
|
||||
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||
...adminRoleIds.map(roleId => ({
|
||||
id: roleId,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
}))
|
||||
];
|
||||
|
||||
if (category) {
|
||||
console.log(` ⚠️ Category already exists: ${category.name}`);
|
||||
} else {
|
||||
// Create category
|
||||
category = await guild.channels.create({
|
||||
name: `🎮 ${server.name}`,
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites,
|
||||
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Created category: ${category.name} (${category.id})`);
|
||||
stats.categoriesCreated++;
|
||||
stats.permissionsApplied++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Create chat channel
|
||||
const chatExists = guild.channels.cache.find(
|
||||
ch => ch.parentId === category.id && ch.name.includes('chat')
|
||||
);
|
||||
if (!chatExists) {
|
||||
const chat = await guild.channels.create({
|
||||
name: `${slugify(server.name)}-chat`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: `General chat for ${server.name}`,
|
||||
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Created: ${chat.name}`);
|
||||
stats.textChannelsCreated++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Create in-game channel
|
||||
const ingameExists = guild.channels.cache.find(
|
||||
ch => ch.parentId === category.id && ch.name.includes('in-game')
|
||||
);
|
||||
if (!ingameExists) {
|
||||
const ingame = await guild.channels.create({
|
||||
name: `${slugify(server.name)}-in-game`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: `In-game chat bridge for ${server.name}`,
|
||||
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Created: ${ingame.name}`);
|
||||
stats.textChannelsCreated++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Create forum
|
||||
const forumExists = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildForum && ch.parentId === category.id
|
||||
);
|
||||
if (!forumExists) {
|
||||
const forum = await guild.channels.create({
|
||||
name: `${slugify(server.name)}-forum`,
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
topic: `Discussion forum for ${server.name}`,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||
name: tag.name,
|
||||
emoji: { name: tag.emoji }
|
||||
})),
|
||||
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Created forum: ${forum.name} (${forum.id})`);
|
||||
stats.forumsCreated++;
|
||||
await sleep(500);
|
||||
|
||||
// Post welcome message
|
||||
const welcomeThread = await forum.threads.create({
|
||||
name: server.welcomeTitle,
|
||||
message: { content: server.welcomeBody },
|
||||
reason: 'Task #98 Welcome Post - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Posted welcome: ${welcomeThread.name}`);
|
||||
stats.welcomePostsCreated++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Create voice channel
|
||||
const voiceExists = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildVoice && ch.parentId === category.id
|
||||
);
|
||||
if (!voiceExists) {
|
||||
const voice = await guild.channels.create({
|
||||
name: server.name,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Created voice: ${voice.name}`);
|
||||
stats.voiceChannelsCreated++;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// PHASE 3: Create Archive category
|
||||
// ========================================================================
|
||||
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('PHASE 3: Create Archive Category');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('');
|
||||
|
||||
const archiveExists = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory && ch.name.includes('Archive')
|
||||
);
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log('[DRY RUN] Would create: 📦 Archive (staff only)');
|
||||
} else if (archiveExists) {
|
||||
console.log(`⚠️ Archive category already exists: ${archiveExists.name}`);
|
||||
} else {
|
||||
const archivePerms = [
|
||||
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel] },
|
||||
...adminRoleIds.map(roleId => ({
|
||||
id: roleId,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory]
|
||||
}))
|
||||
];
|
||||
|
||||
const archive = await guild.channels.create({
|
||||
name: '📦 Archive',
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites: archivePerms,
|
||||
reason: 'Task #98 Discord Channel Automation - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created: ${archive.name} (${archive.id})`);
|
||||
stats.categoriesCreated++;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// SUMMARY
|
||||
// ========================================================================
|
||||
|
||||
console.log('');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('📊 SUMMARY');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log(`Categories created: ${stats.categoriesCreated}`);
|
||||
console.log(`Categories renamed: ${stats.categoriesRenamed}`);
|
||||
console.log(`Forums created: ${stats.forumsCreated}`);
|
||||
console.log(`Text channels created: ${stats.textChannelsCreated}`);
|
||||
console.log(`Voice channels created: ${stats.voiceChannelsCreated}`);
|
||||
console.log(`Welcome posts created: ${stats.welcomePostsCreated}`);
|
||||
console.log(`Permissions applied: ${stats.permissionsApplied}`);
|
||||
console.log('');
|
||||
|
||||
if (stats.errors.length > 0) {
|
||||
console.log('⚠️ ERRORS:');
|
||||
stats.errors.forEach(e => console.log(` - ${e}`));
|
||||
} else {
|
||||
console.log('✅ No errors!');
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('🎉 Task #98 Discord Channel Automation — COMPLETE');
|
||||
console.log('');
|
||||
console.log('👀 Next steps:');
|
||||
console.log(' 1. Check Discord to verify all channels');
|
||||
console.log(' 2. Test permissions with a Wanderer account');
|
||||
console.log(' 3. Test permissions with a subscriber account');
|
||||
console.log(' 4. Revoke Arbiter admin permissions');
|
||||
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ FATAL ERROR:', error.message);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
client.destroy();
|
||||
console.log('');
|
||||
console.log('👋 Disconnected from Discord.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
200
services/arbiter-3.0/scripts/discord-channel-test.js
Normal file
200
services/arbiter-3.0/scripts/discord-channel-test.js
Normal file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Discord Channel Creation Test Script
|
||||
* Phase 1: Create ONE test category with ONE forum channel
|
||||
*
|
||||
* Purpose: Verify our channel creation approach works before running full script
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION
|
||||
// ============================================================================
|
||||
|
||||
const DRY_RUN = false; // Set to false to actually create channels
|
||||
|
||||
const TEST_CATEGORY_NAME = '🧪 Test Category';
|
||||
const TEST_FORUM_NAME = 'test-forum';
|
||||
const TEST_FORUM_TOPIC = 'Testing forum creation - safe to delete';
|
||||
|
||||
// Forum tags we'll use on all server forums
|
||||
const STANDARD_FORUM_TAGS = [
|
||||
{ name: 'Builds', emoji: '🏗️' },
|
||||
{ name: 'Help', emoji: '❓' },
|
||||
{ name: 'Suggestion', emoji: '💡' },
|
||||
{ name: 'Bug Report', emoji: '🐛' },
|
||||
{ name: 'Achievement', emoji: '🎉' },
|
||||
{ name: 'Guide', emoji: '📖' }
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// MAIN SCRIPT
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.log('🔧 Discord Channel Creation Test');
|
||||
console.log('================================');
|
||||
console.log(`Mode: ${DRY_RUN ? '🔍 DRY RUN (no changes)' : '⚡ LIVE (will create channels)'}`);
|
||||
console.log('');
|
||||
|
||||
// Initialize client
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds]
|
||||
});
|
||||
|
||||
try {
|
||||
// Login
|
||||
console.log('📡 Connecting to Discord...');
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
|
||||
// Wait for ready
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
// Get guild
|
||||
const guildId = process.env.GUILD_ID;
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
|
||||
if (!guild) {
|
||||
throw new Error(`Guild ${guildId} not found. Is the bot in the server?`);
|
||||
}
|
||||
console.log(`✅ Found guild: ${guild.name}`);
|
||||
|
||||
// Check bot permissions
|
||||
const botMember = guild.members.cache.get(client.user.id);
|
||||
if (!botMember) {
|
||||
await guild.members.fetch(client.user.id);
|
||||
}
|
||||
|
||||
const permissions = guild.members.cache.get(client.user.id)?.permissions;
|
||||
console.log('');
|
||||
console.log('🔐 Bot Permissions Check:');
|
||||
console.log(` Manage Channels: ${permissions?.has(PermissionFlagsBits.ManageChannels) ? '✅' : '❌'}`);
|
||||
console.log(` Manage Roles: ${permissions?.has(PermissionFlagsBits.ManageRoles) ? '✅' : '❌'}`);
|
||||
console.log(` Send Messages: ${permissions?.has(PermissionFlagsBits.SendMessages) ? '✅' : '❌'}`);
|
||||
console.log(` Create Public Threads: ${permissions?.has(PermissionFlagsBits.CreatePublicThreads) ? '✅' : '❌'}`);
|
||||
|
||||
if (!permissions?.has(PermissionFlagsBits.ManageChannels)) {
|
||||
throw new Error('Bot lacks Manage Channels permission!');
|
||||
}
|
||||
|
||||
// Fetch existing channels
|
||||
await guild.channels.fetch();
|
||||
console.log('');
|
||||
console.log(`📊 Current channel count: ${guild.channels.cache.size}`);
|
||||
|
||||
// Check if test category already exists
|
||||
const existingCategory = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory && ch.name === TEST_CATEGORY_NAME
|
||||
);
|
||||
|
||||
if (existingCategory) {
|
||||
console.log(`⚠️ Test category "${TEST_CATEGORY_NAME}" already exists (ID: ${existingCategory.id})`);
|
||||
console.log(' Delete it manually if you want to re-run this test.');
|
||||
|
||||
// Check for forum in that category
|
||||
const existingForum = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildForum && ch.parentId === existingCategory.id
|
||||
);
|
||||
if (existingForum) {
|
||||
console.log(` Forum "${existingForum.name}" exists in category (ID: ${existingForum.id})`);
|
||||
}
|
||||
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log('');
|
||||
console.log('📋 DRY RUN - Would create:');
|
||||
console.log(` 1. Category: "${TEST_CATEGORY_NAME}"`);
|
||||
console.log(` 2. Forum: "${TEST_FORUM_NAME}" with ${STANDARD_FORUM_TAGS.length} tags`);
|
||||
console.log('');
|
||||
console.log('Set DRY_RUN = false to create these channels.');
|
||||
client.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// LIVE MODE - CREATE CHANNELS
|
||||
// ========================================================================
|
||||
|
||||
console.log('');
|
||||
console.log('🚀 Creating test channels...');
|
||||
|
||||
// Step 1: Create category
|
||||
console.log(` Creating category: ${TEST_CATEGORY_NAME}`);
|
||||
const category = await guild.channels.create({
|
||||
name: TEST_CATEGORY_NAME,
|
||||
type: ChannelType.GuildCategory,
|
||||
reason: 'Test by Chronicler #71 - Discord channel automation'
|
||||
});
|
||||
console.log(` ✅ Category created: ${category.id}`);
|
||||
|
||||
// Step 2: Create forum channel
|
||||
console.log(` Creating forum: ${TEST_FORUM_NAME}`);
|
||||
const forum = await guild.channels.create({
|
||||
name: TEST_FORUM_NAME,
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
topic: TEST_FORUM_TOPIC,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||
name: tag.name,
|
||||
emoji: tag.emoji ? { name: tag.emoji } : null
|
||||
})),
|
||||
reason: 'Test by Chronicler #71 - Discord channel automation'
|
||||
});
|
||||
console.log(` ✅ Forum created: ${forum.id}`);
|
||||
|
||||
// Step 3: Create a test welcome post in the forum
|
||||
console.log(' Creating welcome post in forum...');
|
||||
const welcomeThread = await forum.threads.create({
|
||||
name: '👋 Welcome to the Test Forum!',
|
||||
message: {
|
||||
content: `**This is a test welcome post.**\n\nIf you can see this, forum creation is working!\n\n🏗️ **Tags available:** ${STANDARD_FORUM_TAGS.map(t => t.name).join(', ')}\n\n*Created by Chronicler #71*`
|
||||
},
|
||||
reason: 'Test welcome post by Chronicler #71'
|
||||
});
|
||||
console.log(` ✅ Welcome post created: ${welcomeThread.id}`);
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
console.log('✅ TEST COMPLETE!');
|
||||
console.log('================');
|
||||
console.log(`Category: ${category.name} (${category.id})`);
|
||||
console.log(`Forum: ${forum.name} (${forum.id})`);
|
||||
console.log(`Welcome Post: ${welcomeThread.name} (${welcomeThread.id})`);
|
||||
console.log('');
|
||||
console.log('👀 Check Discord to verify:');
|
||||
console.log(' 1. Category appears with 🧪 emoji');
|
||||
console.log(' 2. Forum is inside the category');
|
||||
console.log(' 3. Forum has 6 tags (Builds, Help, Suggestion, Bug Report, Achievement, Guide)');
|
||||
console.log(' 4. Welcome post is visible in the forum');
|
||||
console.log('');
|
||||
console.log('🗑️ When done testing, delete the category from Discord.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ ERROR:', error.message);
|
||||
if (error.code) {
|
||||
console.error(' Discord Error Code:', error.code);
|
||||
}
|
||||
if (error.rawError) {
|
||||
console.error(' Raw Error:', JSON.stringify(error.rawError, null, 2));
|
||||
}
|
||||
} finally {
|
||||
client.destroy();
|
||||
console.log('');
|
||||
console.log('👋 Disconnected from Discord.');
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
169
services/arbiter-3.0/scripts/fix-wolds-vaults-v2.js
Normal file
169
services/arbiter-3.0/scripts/fix-wolds-vaults-v2.js
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Quick Fix: Wold's Vaults
|
||||
* Using role ID directly since the apostrophe character is weird
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
|
||||
const STANDARD_FORUM_TAGS = [
|
||||
{ name: 'Builds', emoji: '🏗️' },
|
||||
{ name: 'Help', emoji: '❓' },
|
||||
{ name: 'Suggestion', emoji: '💡' },
|
||||
{ name: 'Bug Report', emoji: '🐛' },
|
||||
{ name: 'Achievement', emoji: '🎉' },
|
||||
{ name: 'Guide', emoji: '📖' }
|
||||
];
|
||||
|
||||
// Using role ID directly!
|
||||
const WOLDS_VAULTS_ROLE_ID = '1491029373640376330';
|
||||
|
||||
const SERVER = {
|
||||
name: "Wold's Vaults",
|
||||
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||
|
||||
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your vault hauls
|
||||
- ❓ Ask about vault strategies
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate legendary finds
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Best Vault Haul!**
|
||||
|
||||
What's the best thing you've pulled from a vault? Show us!`
|
||||
};
|
||||
|
||||
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🔧 Quick Fix: Wold's Vaults (using role ID)");
|
||||
console.log('=============================================');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||
});
|
||||
|
||||
try {
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
|
||||
// Get role by ID
|
||||
const serverRole = guild.roles.cache.get(WOLDS_VAULTS_ROLE_ID);
|
||||
if (!serverRole) {
|
||||
console.log(`❌ Role ID not found: ${WOLDS_VAULTS_ROLE_ID}`);
|
||||
return;
|
||||
}
|
||||
console.log(`✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||
|
||||
const everyoneRole = guild.roles.everyone;
|
||||
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
|
||||
const adminRoleIds = ADMIN_ROLES.map(name => guild.roles.cache.find(r => r.name === name)?.id).filter(Boolean);
|
||||
|
||||
const permissionOverwrites = [
|
||||
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||
...adminRoleIds.map(roleId => ({
|
||||
id: roleId,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
}))
|
||||
];
|
||||
|
||||
// Create category
|
||||
console.log(`Creating category: 🎮 ${SERVER.name}`);
|
||||
const category = await guild.channels.create({
|
||||
name: `🎮 ${SERVER.name}`,
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created category: ${category.id}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create chat
|
||||
const chat = await guild.channels.create({
|
||||
name: 'wolds-vaults-chat',
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created: ${chat.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create in-game
|
||||
const ingame = await guild.channels.create({
|
||||
name: 'wolds-vaults-in-game',
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created: ${ingame.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create forum
|
||||
const forum = await guild.channels.create({
|
||||
name: 'wolds-vaults-forum',
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({ name: tag.name, emoji: { name: tag.emoji } })),
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created forum: ${forum.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Welcome post
|
||||
const welcomeThread = await forum.threads.create({
|
||||
name: SERVER.welcomeTitle,
|
||||
message: { content: SERVER.welcomeBody },
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Posted welcome: ${welcomeThread.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create voice
|
||||
const voice = await guild.channels.create({
|
||||
name: "Wold's Vaults",
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created voice: ${voice.name}`);
|
||||
|
||||
console.log('');
|
||||
console.log("🎉 Wold's Vaults — COMPLETE!");
|
||||
console.log('');
|
||||
console.log('📊 FINAL TOTALS:');
|
||||
console.log(' Categories: 11 (10 new + 1 archive)');
|
||||
console.log(' Forums: 15');
|
||||
console.log(' Text channels: 20');
|
||||
console.log(' Voice channels: 10');
|
||||
console.log(' Welcome posts: 15');
|
||||
console.log(' Total new channels: 46 ✅');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR:', error.message);
|
||||
console.error(error.stack);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
166
services/arbiter-3.0/scripts/fix-wolds-vaults.js
Normal file
166
services/arbiter-3.0/scripts/fix-wolds-vaults.js
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Quick Fix: Wold's Vaults
|
||||
* The role uses a curly apostrophe ('), not straight (')
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: '/opt/arbiter-3.0/.env' });
|
||||
const { Client, GatewayIntentBits, ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
|
||||
const STANDARD_FORUM_TAGS = [
|
||||
{ name: 'Builds', emoji: '🏗️' },
|
||||
{ name: 'Help', emoji: '❓' },
|
||||
{ name: 'Suggestion', emoji: '💡' },
|
||||
{ name: 'Bug Report', emoji: '🐛' },
|
||||
{ name: 'Achievement', emoji: '🎉' },
|
||||
{ name: 'Guide', emoji: '📖' }
|
||||
];
|
||||
|
||||
const SERVER = {
|
||||
name: "Wold's Vaults",
|
||||
roleName: "Wold's Vaults", // Curly apostrophe!
|
||||
welcomeTitle: "Welcome to Wold's Vaults!",
|
||||
welcomeBody: `🗄️ **Crack the vaults. Claim the treasure.**
|
||||
|
||||
A progression-focused pack centered around vault hunting. Gear up, dive in, and see what riches await those brave enough to face the challenges within.
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your vault hauls
|
||||
- ❓ Ask about vault strategies
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate legendary finds
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Your Best Vault Haul!**
|
||||
|
||||
What's the best thing you've pulled from a vault? Show us!`
|
||||
};
|
||||
|
||||
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||
|
||||
function slugify(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').substring(0, 100);
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🔧 Quick Fix: Wold's Vaults");
|
||||
console.log('============================');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||
});
|
||||
|
||||
try {
|
||||
await client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
await new Promise(resolve => {
|
||||
if (client.isReady()) resolve();
|
||||
else client.once('ready', resolve);
|
||||
});
|
||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
|
||||
// Find role with curly apostrophe
|
||||
const serverRole = guild.roles.cache.find(r => r.name === SERVER.roleName);
|
||||
if (!serverRole) {
|
||||
console.log(`❌ Role still not found: ${SERVER.roleName}`);
|
||||
console.log('Available roles with "Wold":');
|
||||
guild.roles.cache.filter(r => r.name.toLowerCase().includes('wold')).forEach(r => {
|
||||
console.log(` "${r.name}" (${r.id})`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log(`✅ Found role: ${serverRole.name} (${serverRole.id})`);
|
||||
|
||||
const everyoneRole = guild.roles.everyone;
|
||||
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
|
||||
const adminRoleIds = ADMIN_ROLES.map(name => guild.roles.cache.find(r => r.name === name)?.id).filter(Boolean);
|
||||
|
||||
const permissionOverwrites = [
|
||||
{ id: everyoneRole.id, deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
{ id: wandererRole.id, allow: [PermissionFlagsBits.ViewChannel], deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect] },
|
||||
{ id: serverRole.id, allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory] },
|
||||
...adminRoleIds.map(roleId => ({
|
||||
id: roleId,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
}))
|
||||
];
|
||||
|
||||
// Create category
|
||||
console.log(`Creating category: 🎮 ${SERVER.name}`);
|
||||
const category = await guild.channels.create({
|
||||
name: `🎮 ${SERVER.name}`,
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created category: ${category.id}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create chat
|
||||
const chat = await guild.channels.create({
|
||||
name: `${slugify(SERVER.name)}-chat`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created: ${chat.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create in-game
|
||||
const ingame = await guild.channels.create({
|
||||
name: `${slugify(SERVER.name)}-in-game`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created: ${ingame.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create forum
|
||||
const forum = await guild.channels.create({
|
||||
name: `${slugify(SERVER.name)}-forum`,
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({ name: tag.name, emoji: { name: tag.emoji } })),
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created forum: ${forum.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Welcome post
|
||||
const welcomeThread = await forum.threads.create({
|
||||
name: SERVER.welcomeTitle,
|
||||
message: { content: SERVER.welcomeBody },
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Posted welcome: ${welcomeThread.name}`);
|
||||
await sleep(500);
|
||||
|
||||
// Create voice
|
||||
const voice = await guild.channels.create({
|
||||
name: SERVER.name,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
reason: 'Task #98 Fix - Chronicler #71'
|
||||
});
|
||||
console.log(`✅ Created voice: ${voice.name}`);
|
||||
|
||||
console.log('');
|
||||
console.log("🎉 Wold's Vaults — COMPLETE!");
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ERROR:', error.message);
|
||||
} finally {
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
295
services/arbiter-3.0/src/discord/createserver.js
Normal file
295
services/arbiter-3.0/src/discord/createserver.js
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* /createserver Command
|
||||
* Creates a complete server setup with one command:
|
||||
* - Role
|
||||
* - Category with emoji prefix
|
||||
* - Chat, in-game, forum, voice channels
|
||||
* - Permission template
|
||||
* - Welcome post (archived)
|
||||
* - Suggests emoji for reaction roles
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
* Task: #98 Discord Channel Automation
|
||||
*/
|
||||
|
||||
const { SlashCommandBuilder, ChannelType, PermissionFlagsBits, PermissionsBitField } = require('discord.js');
|
||||
|
||||
// Channel ID for #get-roles
|
||||
const GET_ROLES_CHANNEL_ID = '1403980899464384572';
|
||||
|
||||
// Staff role names that can use this command
|
||||
const STAFF_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||
|
||||
// Admin roles that get full access to new server channels
|
||||
const ADMIN_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||
|
||||
// Standard forum tags
|
||||
const STANDARD_FORUM_TAGS = [
|
||||
{ name: 'Builds', emoji: '🏗️' },
|
||||
{ name: 'Help', emoji: '❓' },
|
||||
{ name: 'Suggestion', emoji: '💡' },
|
||||
{ name: 'Bug Report', emoji: '🐛' },
|
||||
{ name: 'Achievement', emoji: '🎉' },
|
||||
{ name: 'Guide', emoji: '📖' }
|
||||
];
|
||||
|
||||
// Emoji pool for reaction role suggestions (gaming/server themed)
|
||||
const EMOJI_POOL = [
|
||||
'🎮', '🕹️', '⚔️', '🛡️', '🏰', '🗡️', '🔮', '🧙', '🐉', '🌋',
|
||||
'🌊', '🏔️', '🌲', '🍄', '⚡', '💎', '🪨', '⛏️', '🧱', '🏠',
|
||||
'🌙', '☀️', '🌟', '✨', '🎯', '🎪', '🎭', '🎨', '🧪', '🔧',
|
||||
'⚙️', '🚀', '👾', '🤖', '🎲', '🃏', '🏴☠️', '⚓', '🧭', '🗺️',
|
||||
'🦊', '🐺', '🦁', '🐲', '🦅', '🐋', '🦈', '🐙', '🦑', '🕷️',
|
||||
'🌸', '🌺', '🌻', '🍀', '🌿', '🔥', '❄️', '💧', '🌪️', '⭐'
|
||||
];
|
||||
|
||||
// Generic welcome post template
|
||||
const WELCOME_TEMPLATE = (serverName) => `🎮 **Welcome to ${serverName}!**
|
||||
|
||||
This is your community space for all things ${serverName}. Share your adventures, ask questions, and connect with fellow players!
|
||||
|
||||
**This forum is your space to:**
|
||||
- 🏗️ Share your builds and creations
|
||||
- ❓ Ask for help and advice
|
||||
- 💡 Suggest improvements
|
||||
- 🎉 Celebrate your achievements
|
||||
|
||||
---
|
||||
|
||||
**🎮 First Challenge: Introduce Yourself!**
|
||||
|
||||
Tell us about your playstyle! What are you most excited to try on this server?
|
||||
|
||||
*Welcome to Firefrost Gaming!* 🔥❄️`;
|
||||
|
||||
// Build the slash command
|
||||
const createServerCommand = new SlashCommandBuilder()
|
||||
.setName('createserver')
|
||||
.setDescription('Create a complete server setup (Staff only)')
|
||||
.addStringOption(option =>
|
||||
option.setName('name')
|
||||
.setDescription('Server name (e.g., "Beyond Depth")')
|
||||
.setRequired(true)
|
||||
.setMaxLength(50)
|
||||
);
|
||||
|
||||
/**
|
||||
* Slugify a server name for channel names
|
||||
*/
|
||||
function slugify(name) {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.substring(0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has staff role
|
||||
*/
|
||||
function isStaff(member) {
|
||||
return member.roles.cache.some(role => STAFF_ROLES.includes(role.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unused emoji from pool
|
||||
*/
|
||||
async function getUnusedEmoji(channel) {
|
||||
try {
|
||||
const message = await channel.messages.fetch({ limit: 50 });
|
||||
const getRolesMsg = message.find(m => m.reactions.cache.size > 0);
|
||||
|
||||
if (!getRolesMsg) {
|
||||
// No message with reactions found, return first emoji
|
||||
return EMOJI_POOL[0];
|
||||
}
|
||||
|
||||
const usedEmojis = new Set();
|
||||
getRolesMsg.reactions.cache.forEach(reaction => {
|
||||
usedEmojis.add(reaction.emoji.name);
|
||||
});
|
||||
|
||||
// Find first unused emoji
|
||||
for (const emoji of EMOJI_POOL) {
|
||||
if (!usedEmojis.has(emoji)) {
|
||||
return emoji;
|
||||
}
|
||||
}
|
||||
|
||||
// All emojis used, return a random one with note
|
||||
return EMOJI_POOL[Math.floor(Math.random() * EMOJI_POOL.length)];
|
||||
} catch (error) {
|
||||
console.error('Error fetching emojis:', error);
|
||||
return EMOJI_POOL[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /createserver command
|
||||
*/
|
||||
async function handleCreateServerCommand(interaction) {
|
||||
// Check permissions
|
||||
if (!isStaff(interaction.member)) {
|
||||
return interaction.reply({
|
||||
content: '❌ This command is restricted to Staff members.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
await interaction.deferReply({ ephemeral: false });
|
||||
|
||||
const serverName = interaction.options.getString('name');
|
||||
const guild = interaction.guild;
|
||||
|
||||
try {
|
||||
// Fetch roles
|
||||
await guild.roles.fetch();
|
||||
|
||||
// Check if role already exists
|
||||
const existingRole = guild.roles.cache.find(r => r.name.toLowerCase() === serverName.toLowerCase());
|
||||
if (existingRole) {
|
||||
return interaction.editReply(`❌ Role **${serverName}** already exists!`);
|
||||
}
|
||||
|
||||
// Check if category already exists
|
||||
const existingCategory = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory &&
|
||||
(ch.name === serverName || ch.name === `🎮 ${serverName}`)
|
||||
);
|
||||
if (existingCategory) {
|
||||
return interaction.editReply(`❌ Category **${serverName}** already exists!`);
|
||||
}
|
||||
|
||||
// Get key roles
|
||||
const everyoneRole = guild.roles.everyone;
|
||||
const wandererRole = guild.roles.cache.find(r => r.name === 'Wanderer');
|
||||
|
||||
if (!wandererRole) {
|
||||
return interaction.editReply('❌ Wanderer role not found!');
|
||||
}
|
||||
|
||||
// Get admin role IDs
|
||||
const adminRoleIds = ADMIN_ROLES
|
||||
.map(name => guild.roles.cache.find(r => r.name === name)?.id)
|
||||
.filter(Boolean);
|
||||
|
||||
// Progress update
|
||||
await interaction.editReply(`⏳ Creating **${serverName}**...`);
|
||||
|
||||
// Step 1: Create role
|
||||
const serverRole = await guild.roles.create({
|
||||
name: serverName,
|
||||
reason: `/createserver by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
// Step 2: Build permission overwrites
|
||||
const permissionOverwrites = [
|
||||
{
|
||||
id: everyoneRole.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||
},
|
||||
{
|
||||
id: wandererRole.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel],
|
||||
deny: [PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect]
|
||||
},
|
||||
{
|
||||
id: serverRole.id,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
},
|
||||
...adminRoleIds.map(roleId => ({
|
||||
id: roleId,
|
||||
allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.Connect, PermissionFlagsBits.ReadMessageHistory]
|
||||
}))
|
||||
];
|
||||
|
||||
// Step 3: Create category
|
||||
const category = await guild.channels.create({
|
||||
name: `🎮 ${serverName}`,
|
||||
type: ChannelType.GuildCategory,
|
||||
permissionOverwrites,
|
||||
reason: `/createserver by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
// Step 4: Create chat channel
|
||||
const slug = slugify(serverName);
|
||||
await guild.channels.create({
|
||||
name: `${slug}-chat`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: `General chat for ${serverName}`,
|
||||
reason: `/createserver by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
// Step 5: Create in-game channel
|
||||
await guild.channels.create({
|
||||
name: `${slug}-in-game`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: category.id,
|
||||
topic: `In-game chat bridge for ${serverName}`,
|
||||
reason: `/createserver by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
// Step 6: Create forum
|
||||
const forum = await guild.channels.create({
|
||||
name: `${slug}-forum`,
|
||||
type: ChannelType.GuildForum,
|
||||
parent: category.id,
|
||||
topic: `Discussion forum for ${serverName}`,
|
||||
availableTags: STANDARD_FORUM_TAGS.map(tag => ({
|
||||
name: tag.name,
|
||||
emoji: { name: tag.emoji }
|
||||
})),
|
||||
reason: `/createserver by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
// Step 7: Create voice channel
|
||||
await guild.channels.create({
|
||||
name: serverName,
|
||||
type: ChannelType.GuildVoice,
|
||||
parent: category.id,
|
||||
reason: `/createserver by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
// Step 8: Post welcome message
|
||||
const welcomeThread = await forum.threads.create({
|
||||
name: `Welcome to ${serverName}!`,
|
||||
message: { content: WELCOME_TEMPLATE(serverName) },
|
||||
reason: `/createserver by ${interaction.user.tag}`
|
||||
});
|
||||
|
||||
// Step 9: Archive the welcome post
|
||||
await welcomeThread.setArchived(true, 'Auto-archive welcome post');
|
||||
|
||||
// Step 10: Get suggested emoji
|
||||
const getRolesChannel = await guild.channels.fetch(GET_ROLES_CHANNEL_ID);
|
||||
const suggestedEmoji = await getUnusedEmoji(getRolesChannel);
|
||||
|
||||
// Success message
|
||||
const successMessage = `✅ **${serverName}** created!
|
||||
|
||||
**Created:**
|
||||
• Role: ${serverRole}
|
||||
• Category: 🎮 ${serverName}
|
||||
• Channels: ${slug}-chat, ${slug}-in-game, ${slug}-forum, voice
|
||||
• Welcome post: Archived ✓
|
||||
|
||||
---
|
||||
|
||||
**Suggested emoji for #get-roles:** ${suggestedEmoji}
|
||||
|
||||
To complete setup, add ${suggestedEmoji} as a reaction to the #get-roles message, then configure Carl-bot to assign the "${serverName}" role.`;
|
||||
|
||||
await interaction.editReply(successMessage);
|
||||
|
||||
console.log(`✅ /createserver: ${serverName} created by ${interaction.user.tag}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('/createserver error:', error);
|
||||
await interaction.editReply(`❌ Error creating server: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createServerCommand, handleCreateServerCommand };
|
||||
190
services/arbiter-3.0/src/discord/delserver.js
Normal file
190
services/arbiter-3.0/src/discord/delserver.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* /delserver Command
|
||||
* Deletes a complete server setup:
|
||||
* - All channels in the category
|
||||
* - The category itself
|
||||
* - The server role
|
||||
*
|
||||
* Requires confirm:True to execute.
|
||||
* Without confirm, shows preview of what would be deleted.
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #71
|
||||
* Task: #98 Discord Channel Automation
|
||||
*/
|
||||
|
||||
const { SlashCommandBuilder, ChannelType } = require('discord.js');
|
||||
|
||||
// Staff role names that can use this command
|
||||
const STAFF_ROLES = ['Staff', '🛡️ Moderator', '👑 The Wizard', '💎 The Emissary', '✨ The Catalyst'];
|
||||
|
||||
// Build the slash command
|
||||
const delServerCommand = new SlashCommandBuilder()
|
||||
.setName('delserver')
|
||||
.setDescription('Delete a server setup completely (Staff only)')
|
||||
.addStringOption(option =>
|
||||
option.setName('name')
|
||||
.setDescription('Server name (e.g., "Beyond Depth")')
|
||||
.setRequired(true)
|
||||
.setMaxLength(50)
|
||||
)
|
||||
.addBooleanOption(option =>
|
||||
option.setName('confirm')
|
||||
.setDescription('Set to True to confirm deletion')
|
||||
.setRequired(false)
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if user has staff role
|
||||
*/
|
||||
function isStaff(member) {
|
||||
return member.roles.cache.some(role => STAFF_ROLES.includes(role.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /delserver command
|
||||
*/
|
||||
async function handleDelServerCommand(interaction) {
|
||||
// Check permissions
|
||||
if (!isStaff(interaction.member)) {
|
||||
return interaction.reply({
|
||||
content: '❌ This command is restricted to Staff members.',
|
||||
ephemeral: true
|
||||
});
|
||||
}
|
||||
|
||||
const serverName = interaction.options.getString('name');
|
||||
const confirmed = interaction.options.getBoolean('confirm') || false;
|
||||
const guild = interaction.guild;
|
||||
|
||||
await interaction.deferReply({ ephemeral: false });
|
||||
|
||||
try {
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
|
||||
// Find the category (with or without emoji)
|
||||
const category = guild.channels.cache.find(
|
||||
ch => ch.type === ChannelType.GuildCategory &&
|
||||
(ch.name === serverName || ch.name === `🎮 ${serverName}`)
|
||||
);
|
||||
|
||||
// Find the role
|
||||
const serverRole = guild.roles.cache.find(
|
||||
r => r.name.toLowerCase() === serverName.toLowerCase()
|
||||
);
|
||||
|
||||
// Build preview
|
||||
const channelsToDelete = category
|
||||
? guild.channels.cache.filter(ch => ch.parentId === category.id)
|
||||
: new Map();
|
||||
|
||||
const preview = [];
|
||||
|
||||
if (category) {
|
||||
preview.push(`**Category:** ${category.name}`);
|
||||
if (channelsToDelete.size > 0) {
|
||||
preview.push(`**Channels (${channelsToDelete.size}):**`);
|
||||
channelsToDelete.forEach(ch => {
|
||||
const typeLabel = ch.type === ChannelType.GuildVoice ? '🔊' :
|
||||
ch.type === ChannelType.GuildForum ? '💬' : '#';
|
||||
preview.push(`• ${typeLabel} ${ch.name}`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
preview.push(`**Category:** ⚠️ Not found`);
|
||||
}
|
||||
|
||||
if (serverRole) {
|
||||
preview.push(`**Role:** @${serverRole.name} (${serverRole.members.size} members)`);
|
||||
} else {
|
||||
preview.push(`**Role:** ⚠️ Not found`);
|
||||
}
|
||||
|
||||
// If nothing found
|
||||
if (!category && !serverRole) {
|
||||
return interaction.editReply(`❌ Server **${serverName}** not found.\n\nNo category or role matches that name.`);
|
||||
}
|
||||
|
||||
// If not confirmed, show preview
|
||||
if (!confirmed) {
|
||||
const previewMessage = `⚠️ **Delete Server: ${serverName}**
|
||||
|
||||
This will permanently delete:
|
||||
|
||||
${preview.join('\n')}
|
||||
|
||||
---
|
||||
|
||||
**To confirm, run:**
|
||||
\`\`\`
|
||||
/delserver name:${serverName} confirm:True
|
||||
\`\`\``;
|
||||
|
||||
return interaction.editReply(previewMessage);
|
||||
}
|
||||
|
||||
// CONFIRMED - Execute deletion
|
||||
await interaction.editReply(`🗑️ Deleting **${serverName}**...`);
|
||||
|
||||
const deleted = {
|
||||
channels: 0,
|
||||
category: false,
|
||||
role: false
|
||||
};
|
||||
|
||||
// Delete channels first
|
||||
if (category) {
|
||||
for (const [id, channel] of channelsToDelete) {
|
||||
try {
|
||||
await channel.delete(`/delserver by ${interaction.user.tag}`);
|
||||
deleted.channels++;
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete channel ${channel.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete category
|
||||
try {
|
||||
await category.delete(`/delserver by ${interaction.user.tag}`);
|
||||
deleted.category = true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete category:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete role
|
||||
if (serverRole) {
|
||||
try {
|
||||
await serverRole.delete(`/delserver by ${interaction.user.tag}`);
|
||||
deleted.role = true;
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete role:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Success message
|
||||
const successMessage = `🗑️ **${serverName}** deleted!
|
||||
|
||||
**Removed:**
|
||||
• ${deleted.channels} channels
|
||||
• ${deleted.category ? '1 category' : '0 categories'}
|
||||
• ${deleted.role ? '1 role' : '0 roles'}
|
||||
|
||||
---
|
||||
|
||||
**Don't forget to:**
|
||||
1. Remove the reaction emoji from <#1403980899464384572>
|
||||
2. Remove the role mapping from Carl-bot`;
|
||||
|
||||
await interaction.editReply(successMessage);
|
||||
|
||||
console.log(`🗑️ /delserver: ${serverName} deleted by ${interaction.user.tag}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('/delserver error:', error);
|
||||
await interaction.editReply(`❌ Error deleting server: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { delServerCommand, handleDelServerCommand };
|
||||
@@ -1,4 +1,7 @@
|
||||
const { handleLinkCommand } = require('./commands');
|
||||
const { handleCreateServerCommand } = require('./createserver');
|
||||
const { handleDelServerCommand } = require('./delserver');
|
||||
const discordRoleSync = require('../services/discordRoleSync');
|
||||
|
||||
function registerEvents(client) {
|
||||
client.on('interactionCreate', async interaction => {
|
||||
@@ -6,10 +9,18 @@ function registerEvents(client) {
|
||||
if (interaction.commandName === 'link') {
|
||||
await handleLinkCommand(interaction);
|
||||
}
|
||||
if (interaction.commandName === 'createserver') {
|
||||
await handleCreateServerCommand(interaction);
|
||||
}
|
||||
if (interaction.commandName === 'delserver') {
|
||||
await handleDelServerCommand(interaction);
|
||||
}
|
||||
});
|
||||
|
||||
client.on('ready', () => {
|
||||
console.log(`Discord bot logged in as ${client.user.tag}`);
|
||||
// Initialize role sync service with the ready client
|
||||
discordRoleSync.init(client);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ require('dotenv').config();
|
||||
const express = require('express');
|
||||
const expressLayouts = require('express-ejs-layouts');
|
||||
const session = require('express-session');
|
||||
const PgSession = require('connect-pg-simple')(session);
|
||||
const passport = require('passport');
|
||||
const DiscordStrategy = require('passport-discord').Strategy;
|
||||
const { Client, GatewayIntentBits, REST, Routes } = require('discord.js');
|
||||
const csrf = require('csurf');
|
||||
const cors = require('cors');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const adminRoutes = require('./routes/admin/index');
|
||||
@@ -14,7 +16,19 @@ const webhookRoutes = require('./routes/webhook');
|
||||
const stripeRoutes = require('./routes/stripe');
|
||||
const { registerEvents } = require('./discord/events');
|
||||
const { linkCommand } = require('./discord/commands');
|
||||
const { createServerCommand } = require('./discord/createserver');
|
||||
const { delServerCommand } = require('./discord/delserver');
|
||||
const { initCron } = require('./sync/cron');
|
||||
const discordRoleSync = require('./services/discordRoleSync');
|
||||
|
||||
// PostgreSQL connection pool for sessions
|
||||
const pgPool = new Pool({
|
||||
user: process.env.DB_USER,
|
||||
host: process.env.DB_HOST,
|
||||
database: process.env.DB_NAME,
|
||||
password: process.env.DB_PASSWORD,
|
||||
port: process.env.DB_PORT || 5432
|
||||
});
|
||||
|
||||
// Initialize Discord Client
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });
|
||||
@@ -64,6 +78,11 @@ app.use(express.urlencoded({ extended: true }));
|
||||
app.locals.client = client;
|
||||
|
||||
app.use(session({
|
||||
store: new PgSession({
|
||||
pool: pgPool,
|
||||
tableName: 'session',
|
||||
createTableIfMissing: true
|
||||
}),
|
||||
secret: process.env.SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
@@ -111,7 +130,7 @@ const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN)
|
||||
console.log('Refreshing application (/) commands.');
|
||||
await rest.put(
|
||||
Routes.applicationGuildCommands(process.env.DISCORD_CLIENT_ID, process.env.GUILD_ID),
|
||||
{ body: [linkCommand.toJSON()] },
|
||||
{ body: [linkCommand.toJSON(), createServerCommand.toJSON(), delServerCommand.toJSON()] },
|
||||
);
|
||||
console.log('✅ Successfully reloaded application (/) commands.');
|
||||
} catch (error) {
|
||||
|
||||
172
services/arbiter-3.0/src/lib/ptero-sync.js
Normal file
172
services/arbiter-3.0/src/lib/ptero-sync.js
Normal file
@@ -0,0 +1,172 @@
|
||||
const axios = require('axios');
|
||||
const db = require('../database');
|
||||
|
||||
const PTERO_URL = 'https://panel.firefrostgaming.com/api/client/servers';
|
||||
|
||||
function getHeaders() {
|
||||
return {
|
||||
'Authorization': `Bearer ${process.env.PTERO_CLIENT_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limit helper - 200ms between calls
|
||||
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Sync a single server's schedule to Pterodactyl
|
||||
*/
|
||||
async function syncToPterodactyl(serverId) {
|
||||
const result = await db.query('SELECT * FROM server_restart_schedules WHERE server_id = $1', [serverId]);
|
||||
const server = result.rows[0];
|
||||
|
||||
if (!server) {
|
||||
return { success: false, error: 'Server not found in database' };
|
||||
}
|
||||
|
||||
const [hour, minute] = server.effective_time.split(':');
|
||||
const pteroUrl = `${PTERO_URL}/${server.server_id}/schedules`;
|
||||
|
||||
const payload = {
|
||||
name: "[Trinity] Daily Restart",
|
||||
minute,
|
||||
hour,
|
||||
day_of_week: "*",
|
||||
day_of_month: "*",
|
||||
month: "*",
|
||||
is_active: !server.skip_restart
|
||||
};
|
||||
|
||||
try {
|
||||
let scheduleId = server.ptero_schedule_id;
|
||||
|
||||
if (!scheduleId) {
|
||||
// Create new schedule
|
||||
const res = await axios.post(pteroUrl, payload, { headers: getHeaders() });
|
||||
scheduleId = res.data.attributes.id;
|
||||
|
||||
// Attach the restart task
|
||||
await sleep(200);
|
||||
await axios.post(`${pteroUrl}/${scheduleId}/tasks`, {
|
||||
action: "power",
|
||||
payload: "restart",
|
||||
time_offset: 0
|
||||
}, { headers: getHeaders() });
|
||||
} else {
|
||||
// Update existing schedule
|
||||
await axios.post(`${pteroUrl}/${scheduleId}`, payload, { headers: getHeaders() });
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE server_restart_schedules
|
||||
SET ptero_schedule_id = $1, sync_status = $2, last_error = NULL, last_synced_at = NOW()
|
||||
WHERE server_id = $3`,
|
||||
[scheduleId, 'SUCCESS', server.server_id]
|
||||
);
|
||||
|
||||
// Log success
|
||||
await db.query(
|
||||
`INSERT INTO sync_logs (server_id, action, status) VALUES ($1, $2, $3)`,
|
||||
[server.server_id, 'Created/Updated Schedule', 'SUCCESS']
|
||||
);
|
||||
|
||||
return { success: true, scheduleId };
|
||||
} catch (err) {
|
||||
const errorMsg = err.response?.data?.errors?.[0]?.detail || err.message;
|
||||
await db.query(
|
||||
`UPDATE server_restart_schedules
|
||||
SET sync_status = $1, last_error = $2
|
||||
WHERE server_id = $3`,
|
||||
['FAILED', errorMsg, server.server_id]
|
||||
);
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO sync_logs (server_id, action, status, error_message) VALUES ($1, $2, $3, $4)`,
|
||||
[server.server_id, 'Sync Failed', 'FAILED', errorMsg]
|
||||
);
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing restart schedules NOT owned by Trinity
|
||||
*/
|
||||
async function auditServerSchedules(serverId, serverName) {
|
||||
const pteroUrl = `${PTERO_URL}/${serverId}/schedules`;
|
||||
|
||||
try {
|
||||
const res = await axios.get(pteroUrl, { headers: getHeaders() });
|
||||
const schedules = res.data.data || [];
|
||||
|
||||
// Find ANY schedule not created by Trinity
|
||||
const rogueSchedules = schedules
|
||||
.filter(s => !s.attributes.name.startsWith('[Trinity]'))
|
||||
.map(s => ({
|
||||
id: s.attributes.id,
|
||||
name: s.attributes.name,
|
||||
cron: `${s.attributes.minute} ${s.attributes.hour} * * *`,
|
||||
isActive: s.attributes.is_active
|
||||
}));
|
||||
|
||||
return { serverId, serverName, rogueSchedules };
|
||||
} catch (err) {
|
||||
console.error(`Audit failed for ${serverName}:`, err.message);
|
||||
return { serverId, serverName, rogueSchedules: [], error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific schedule from Pterodactyl
|
||||
*/
|
||||
async function deleteSchedule(serverId, scheduleId, scheduleName) {
|
||||
const pteroUrl = `${PTERO_URL}/${serverId}/schedules/${scheduleId}`;
|
||||
|
||||
try {
|
||||
await axios.delete(pteroUrl, { headers: getHeaders() });
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO sync_logs (server_id, action, status) VALUES ($1, $2, $3)`,
|
||||
[serverId, `Deleted Rogue Schedule: ${scheduleName}`, 'SUCCESS']
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const errorMsg = err.response?.data?.errors?.[0]?.detail || err.message;
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO sync_logs (server_id, action, status, error_message) VALUES ($1, $2, $3, $4)`,
|
||||
[serverId, `Failed to Delete: ${scheduleName}`, 'FAILED', errorMsg]
|
||||
);
|
||||
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all servers for a node
|
||||
*/
|
||||
async function syncAllForNode(node) {
|
||||
const result = await db.query(
|
||||
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||
[node]
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (const row of result.rows) {
|
||||
const syncResult = await syncToPterodactyl(row.server_id);
|
||||
results.push({ serverId: row.server_id, ...syncResult });
|
||||
await sleep(200); // Rate limiting
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncToPterodactyl,
|
||||
auditServerSchedules,
|
||||
deleteSchedule,
|
||||
syncAllForNode,
|
||||
sleep
|
||||
};
|
||||
@@ -18,13 +18,20 @@ async function getMinecraftServers() {
|
||||
|
||||
// Parse the allowed nest IDs from the environment variable
|
||||
const allowedNests = process.env.MINECRAFT_NEST_IDS.split(',').map(id => parseInt(id.trim(), 10));
|
||||
|
||||
|
||||
// Node ID to friendly name mapping
|
||||
const nodeMap = {
|
||||
2: 'NC1',
|
||||
3: 'TX1'
|
||||
};
|
||||
|
||||
return data.data.filter(server => {
|
||||
// The API returns the nest ID directly as an integer when relationships aren't included
|
||||
return allowedNests.includes(server.attributes.nest);
|
||||
}).map(server => ({
|
||||
identifier: server.attributes.identifier,
|
||||
name: server.attributes.name
|
||||
name: server.attributes.name,
|
||||
nodeId: server.attributes.node,
|
||||
node: nodeMap[server.attributes.node] || `Node ${server.attributes.node}`
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Discovery failed:", error);
|
||||
|
||||
300
services/arbiter-3.0/src/routes/admin/discord-audit.js
Normal file
300
services/arbiter-3.0/src/routes/admin/discord-audit.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Discord Audit Routes
|
||||
* Provides server structure auditing via Trinity Console
|
||||
*
|
||||
* Created: April 8, 2026
|
||||
* Chronicler: #70
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /admin/discord
|
||||
* Main Discord audit dashboard
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const client = req.app.locals.client;
|
||||
const guildId = process.env.GUILD_ID;
|
||||
|
||||
if (!client || !client.isReady()) {
|
||||
return res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: 'Discord client not ready',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
return res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: 'Guild not found',
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
|
||||
// Build channel structure
|
||||
const channels = guild.channels.cache.map(ch => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
typeName: getChannelTypeName(ch.type),
|
||||
parentId: ch.parentId,
|
||||
position: ch.position,
|
||||
nsfw: ch.nsfw || false,
|
||||
topic: ch.topic || null,
|
||||
permissionOverwrites: ch.permissionOverwrites?.cache.map(p => ({
|
||||
id: p.id,
|
||||
type: p.type,
|
||||
allow: p.allow.bitfield.toString(),
|
||||
deny: p.deny.bitfield.toString()
|
||||
})) || []
|
||||
})).sort((a, b) => a.position - b.position);
|
||||
|
||||
// Build role structure with permission overwrites lookup
|
||||
const roles = guild.roles.cache.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.hexColor,
|
||||
position: r.position,
|
||||
permissions: r.permissions.bitfield.toString(),
|
||||
mentionable: r.mentionable,
|
||||
managed: r.managed,
|
||||
memberCount: r.members.size
|
||||
})).sort((a, b) => b.position - a.position);
|
||||
|
||||
// Categories with their children
|
||||
const categories = channels
|
||||
.filter(ch => ch.type === 4)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
children: channels.filter(ch => ch.parentId === cat.id)
|
||||
}));
|
||||
|
||||
// Orphan channels
|
||||
const orphanChannels = channels.filter(ch => !ch.parentId && ch.type !== 4);
|
||||
|
||||
// Server info
|
||||
const serverInfo = {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
memberCount: guild.memberCount,
|
||||
ownerId: guild.ownerId,
|
||||
createdAt: guild.createdAt,
|
||||
icon: guild.iconURL(),
|
||||
features: guild.features
|
||||
};
|
||||
|
||||
// Health checks
|
||||
const healthChecks = {
|
||||
orphanChannels: orphanChannels.length,
|
||||
emptyRoles: roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone').length,
|
||||
botRoles: roles.filter(r => r.managed).length
|
||||
};
|
||||
|
||||
res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: null,
|
||||
data: {
|
||||
server: serverInfo,
|
||||
categories,
|
||||
orphanChannels,
|
||||
allChannels: channels,
|
||||
roles,
|
||||
healthChecks,
|
||||
summary: {
|
||||
totalChannels: channels.length,
|
||||
totalRoles: roles.length,
|
||||
categoryCount: categories.length,
|
||||
orphanCount: orphanChannels.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Discord dashboard error:', error);
|
||||
res.render('admin/discord/index', {
|
||||
title: 'Discord',
|
||||
error: error.message,
|
||||
data: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/discord/audit
|
||||
* Full Discord server audit - channels, roles, members
|
||||
*/
|
||||
router.get('/audit', async (req, res) => {
|
||||
try {
|
||||
const client = req.app.locals.client;
|
||||
const guildId = process.env.GUILD_ID;
|
||||
|
||||
if (!client || !client.isReady()) {
|
||||
return res.status(503).json({ error: 'Discord client not ready' });
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
return res.status(404).json({ error: 'Guild not found' });
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
await guild.channels.fetch();
|
||||
await guild.roles.fetch();
|
||||
|
||||
// Build channel structure
|
||||
const channels = guild.channels.cache.map(ch => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
typeName: getChannelTypeName(ch.type),
|
||||
parentId: ch.parentId,
|
||||
position: ch.position,
|
||||
nsfw: ch.nsfw || false,
|
||||
topic: ch.topic || null,
|
||||
permissionOverwrites: ch.permissionOverwrites?.cache.map(p => ({
|
||||
id: p.id,
|
||||
type: p.type, // 0 = role, 1 = member
|
||||
allow: p.allow.bitfield.toString(),
|
||||
deny: p.deny.bitfield.toString()
|
||||
})) || []
|
||||
})).sort((a, b) => a.position - b.position);
|
||||
|
||||
// Build role structure
|
||||
const roles = guild.roles.cache.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.hexColor,
|
||||
position: r.position,
|
||||
permissions: r.permissions.bitfield.toString(),
|
||||
mentionable: r.mentionable,
|
||||
managed: r.managed, // Bot roles
|
||||
memberCount: r.members.size
|
||||
})).sort((a, b) => b.position - a.position); // Higher position first
|
||||
|
||||
// Categories with their children
|
||||
const categories = channels
|
||||
.filter(ch => ch.type === 4)
|
||||
.map(cat => ({
|
||||
...cat,
|
||||
children: channels.filter(ch => ch.parentId === cat.id)
|
||||
}));
|
||||
|
||||
// Orphan channels (not in any category)
|
||||
const orphanChannels = channels.filter(ch => !ch.parentId && ch.type !== 4);
|
||||
|
||||
// Server info
|
||||
const serverInfo = {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
memberCount: guild.memberCount,
|
||||
ownerId: guild.ownerId,
|
||||
createdAt: guild.createdAt,
|
||||
icon: guild.iconURL(),
|
||||
features: guild.features
|
||||
};
|
||||
|
||||
res.json({
|
||||
server: serverInfo,
|
||||
categories,
|
||||
orphanChannels,
|
||||
allChannels: channels,
|
||||
roles,
|
||||
summary: {
|
||||
totalChannels: channels.length,
|
||||
totalRoles: roles.length,
|
||||
categoryCount: categories.length,
|
||||
orphanCount: orphanChannels.length
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Discord audit error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/discord/channels
|
||||
* Just channels
|
||||
*/
|
||||
router.get('/channels', async (req, res) => {
|
||||
try {
|
||||
const client = req.app.locals.client;
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
|
||||
if (!guild) return res.status(404).json({ error: 'Guild not found' });
|
||||
|
||||
await guild.channels.fetch();
|
||||
|
||||
const channels = guild.channels.cache.map(ch => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
typeName: getChannelTypeName(ch.type),
|
||||
parentId: ch.parentId,
|
||||
position: ch.position
|
||||
})).sort((a, b) => a.position - b.position);
|
||||
|
||||
res.json({ channels });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/discord/roles
|
||||
* Just roles
|
||||
*/
|
||||
router.get('/roles', async (req, res) => {
|
||||
try {
|
||||
const client = req.app.locals.client;
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
|
||||
if (!guild) return res.status(404).json({ error: 'Guild not found' });
|
||||
|
||||
await guild.roles.fetch();
|
||||
|
||||
const roles = guild.roles.cache.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.hexColor,
|
||||
position: r.position,
|
||||
memberCount: r.members.size,
|
||||
managed: r.managed
|
||||
})).sort((a, b) => b.position - a.position);
|
||||
|
||||
res.json({ roles });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: Channel type to human name
|
||||
*/
|
||||
function getChannelTypeName(type) {
|
||||
const types = {
|
||||
0: 'Text',
|
||||
2: 'Voice',
|
||||
4: 'Category',
|
||||
5: 'Announcement',
|
||||
10: 'Announcement Thread',
|
||||
11: 'Public Thread',
|
||||
12: 'Private Thread',
|
||||
13: 'Stage',
|
||||
14: 'Directory',
|
||||
15: 'Forum',
|
||||
16: 'Media'
|
||||
};
|
||||
return types[type] || `Unknown (${type})`;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireTrinityAccess } = require('./middleware');
|
||||
const { getMinecraftServers } = require('../../panel/discovery');
|
||||
const db = require('../../database');
|
||||
|
||||
// Sub-routers
|
||||
const playersRouter = require('./players');
|
||||
@@ -9,6 +11,9 @@ const financialsRouter = require('./financials');
|
||||
const graceRouter = require('./grace');
|
||||
const auditRouter = require('./audit');
|
||||
const rolesRouter = require('./roles');
|
||||
const schedulerRouter = require('./scheduler');
|
||||
const discordAuditRouter = require('./discord-audit');
|
||||
const systemRouter = require('./system');
|
||||
|
||||
router.use(requireTrinityAccess);
|
||||
|
||||
@@ -22,8 +27,49 @@ router.get('/', (req, res) => {
|
||||
res.redirect('/admin/dashboard');
|
||||
});
|
||||
|
||||
router.get('/dashboard', (req, res) => {
|
||||
res.render('admin/dashboard', { title: 'Command Bridge' });
|
||||
router.get('/dashboard', async (req, res) => {
|
||||
try {
|
||||
// Fetch server count from Pterodactyl
|
||||
const servers = await getMinecraftServers();
|
||||
const serversOnline = servers.length;
|
||||
|
||||
// Fetch subscriber stats from database
|
||||
const { rows: subStats } = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status IN ('active', 'grace_period') OR is_lifetime = true) as active_count,
|
||||
COALESCE(SUM(mrr_value) FILTER (WHERE status = 'active'), 0) as mrr
|
||||
FROM subscriptions
|
||||
`);
|
||||
|
||||
const activeSubscribers = parseInt(subStats[0]?.active_count || 0);
|
||||
const totalMRR = parseFloat(subStats[0]?.mrr || 0);
|
||||
|
||||
// Fetch most recent successful sync time
|
||||
const { rows: syncRows } = await db.query(`
|
||||
SELECT MAX(last_successful_sync) as last_sync
|
||||
FROM server_sync_log
|
||||
WHERE is_online = true
|
||||
`);
|
||||
const lastSyncTime = syncRows[0]?.last_sync || null;
|
||||
|
||||
res.render('admin/dashboard', {
|
||||
title: 'Command Bridge',
|
||||
serversOnline,
|
||||
activeSubscribers,
|
||||
totalMRR,
|
||||
lastSyncTime
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard data fetch error:', error);
|
||||
// Fallback to zeros on error
|
||||
res.render('admin/dashboard', {
|
||||
title: 'Command Bridge',
|
||||
serversOnline: 0,
|
||||
activeSubscribers: 0,
|
||||
totalMRR: 0,
|
||||
lastSyncTime: null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.use('/players', playersRouter);
|
||||
@@ -32,5 +78,8 @@ router.use('/financials', financialsRouter);
|
||||
router.use('/grace', graceRouter);
|
||||
router.use('/audit', auditRouter);
|
||||
router.use('/roles', rolesRouter);
|
||||
router.use('/scheduler', schedulerRouter);
|
||||
router.use('/discord', discordAuditRouter);
|
||||
router.use('/system', systemRouter);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
function requireTrinityAccess(req, res, next) {
|
||||
// Allow localhost requests (for curl debugging from Command Center)
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') {
|
||||
res.locals.adminUser = { username: 'localhost', id: 'localhost' };
|
||||
res.locals.currentPath = req.path;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.redirect('/auth/discord');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,64 @@ router.get('/', async (req, res) => {
|
||||
res.render('admin/players/index', { title: 'Player Management', tiers: TIER_INFO });
|
||||
});
|
||||
|
||||
// Export all players as CSV
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const { rows: players } = await db.query(`
|
||||
SELECT
|
||||
s.discord_id,
|
||||
u.minecraft_username,
|
||||
u.minecraft_uuid,
|
||||
COALESCE(u.is_staff, false) as is_staff,
|
||||
s.tier_level,
|
||||
s.status,
|
||||
s.mrr_value,
|
||||
s.is_lifetime,
|
||||
s.stripe_customer_id,
|
||||
s.created_at,
|
||||
s.updated_at
|
||||
FROM subscriptions s
|
||||
LEFT JOIN users u ON s.discord_id = u.discord_id
|
||||
ORDER BY s.updated_at DESC
|
||||
`);
|
||||
|
||||
// Build CSV
|
||||
const headers = ['discord_id', 'minecraft_username', 'minecraft_uuid', 'is_staff', 'tier_level', 'tier_name', 'status', 'mrr_value', 'is_lifetime', 'stripe_customer_id', 'created_at', 'updated_at'];
|
||||
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
for (const player of players) {
|
||||
const tierName = TIER_INFO[player.tier_level]?.name || 'Unknown';
|
||||
const row = [
|
||||
player.discord_id || '',
|
||||
player.minecraft_username || '',
|
||||
player.minecraft_uuid || '',
|
||||
player.is_staff ? 'true' : 'false',
|
||||
player.tier_level || '',
|
||||
tierName,
|
||||
player.status || '',
|
||||
player.mrr_value || '0',
|
||||
player.is_lifetime ? 'true' : 'false',
|
||||
player.stripe_customer_id || '',
|
||||
player.created_at ? new Date(player.created_at).toISOString() : '',
|
||||
player.updated_at ? new Date(player.updated_at).toISOString() : ''
|
||||
].map(val => `"${String(val).replace(/"/g, '""')}"`);
|
||||
csvRows.push(row.join(','));
|
||||
}
|
||||
|
||||
const csv = csvRows.join('\n');
|
||||
const filename = `firefrost-players-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(csv);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
res.status(500).send('Error exporting players');
|
||||
}
|
||||
});
|
||||
|
||||
// HTMX Endpoint for the table body (Handles pagination, sorting, searching)
|
||||
router.get('/table', async (req, res) => {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
|
||||
401
services/arbiter-3.0/src/routes/admin/scheduler.js
Normal file
401
services/arbiter-3.0/src/routes/admin/scheduler.js
Normal file
@@ -0,0 +1,401 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../../database');
|
||||
const { calculateStagger } = require('../../utils/scheduler');
|
||||
const { syncToPterodactyl, auditServerSchedules, deleteSchedule, syncAllForNode, sleep } = require('../../lib/ptero-sync');
|
||||
|
||||
// GET /admin/scheduler - Main page
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// Get config for both nodes
|
||||
const configResult = await db.query('SELECT * FROM global_restart_config ORDER BY node');
|
||||
const configs = configResult.rows;
|
||||
|
||||
// Get all servers ordered by node and sort_order
|
||||
const serversResult = await db.query(`
|
||||
SELECT s.*, c.base_time, c.interval_minutes
|
||||
FROM server_restart_schedules s
|
||||
JOIN global_restart_config c ON s.node = c.node
|
||||
ORDER BY s.node, s.sort_order
|
||||
`);
|
||||
|
||||
res.render('admin/scheduler', {
|
||||
title: 'Global Restart Scheduler',
|
||||
configs,
|
||||
servers: serversResult.rows
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Scheduler page error:', err);
|
||||
res.status(500).send('Error loading scheduler');
|
||||
}
|
||||
});
|
||||
|
||||
// GET /admin/scheduler/table-only - HTMX partial refresh
|
||||
router.get('/table-only', async (req, res) => {
|
||||
try {
|
||||
const serversResult = await db.query(`
|
||||
SELECT s.*, c.base_time, c.interval_minutes
|
||||
FROM server_restart_schedules s
|
||||
JOIN global_restart_config c ON s.node = c.node
|
||||
ORDER BY s.node, s.sort_order
|
||||
`);
|
||||
|
||||
const servers = serversResult.rows;
|
||||
|
||||
let html = `<table class="w-full text-left">
|
||||
<thead class="bg-gray-100 dark:bg-darkbg text-gray-600 dark:text-gray-300">
|
||||
<tr>
|
||||
<th class="p-3 w-10"></th>
|
||||
<th class="p-3">Server</th>
|
||||
<th class="p-3">Node</th>
|
||||
<th class="p-3">Restart Time (Central)</th>
|
||||
<th class="p-3">Status</th>
|
||||
<th class="p-3">Skip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sortable-servers">`;
|
||||
|
||||
if (servers.length === 0) {
|
||||
html += `<tr><td colspan="6" class="p-6 text-center text-gray-500">No servers imported yet. Click "Import Servers" to populate from Pterodactyl.</td></tr>`;
|
||||
} else {
|
||||
servers.forEach(server => {
|
||||
const nodeClass = server.node === 'TX1' ? 'bg-fire/20 text-fire' : 'bg-frost/20 text-frost';
|
||||
let statusHtml;
|
||||
if (server.sync_status === 'SUCCESS') {
|
||||
statusHtml = `<span class="text-green-500">● Synced</span>`;
|
||||
} else if (server.sync_status === 'FAILED') {
|
||||
statusHtml = `<span class="text-red-500" title="${server.last_error || ''}">✕ Error</span>`;
|
||||
} else {
|
||||
statusHtml = `<span class="text-yellow-500">○ Pending</span>`;
|
||||
}
|
||||
const skipClass = server.skip_restart ? 'bg-red-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300';
|
||||
const skipText = server.skip_restart ? 'Skipped' : 'Active';
|
||||
|
||||
html += `<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition" data-id="${server.server_id}">
|
||||
<td class="p-3 cursor-grab text-gray-400 hover:text-gray-900 dark:hover:text-white"><span class="drag-handle text-lg">☰</span></td>
|
||||
<td class="p-3 font-medium">${server.server_name}</td>
|
||||
<td class="p-3"><span class="px-2 py-1 rounded text-xs font-bold ${nodeClass}">${server.node}</span></td>
|
||||
<td class="p-3 font-mono text-sm">${server.effective_time || 'Not set'}</td>
|
||||
<td class="p-3 text-sm">${statusHtml}</td>
|
||||
<td class="p-3">
|
||||
<button hx-post="/admin/scheduler/toggle-skip/${server.server_id}" hx-swap="none" hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')" class="px-2 py-1 rounded text-xs ${skipClass}">${skipText}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `</tbody></table>`;
|
||||
res.send(html);
|
||||
} catch (err) {
|
||||
res.status(500).send('Error loading table');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/scheduler/reorder-servers - Handle drag-and-drop reorder
|
||||
router.post('/reorder-servers', async (req, res) => {
|
||||
try {
|
||||
const { orderedIds } = req.body;
|
||||
|
||||
// Update sort_order for each server
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await db.query(
|
||||
'UPDATE server_restart_schedules SET sort_order = $1 WHERE server_id = $2',
|
||||
[i, orderedIds[i]]
|
||||
);
|
||||
}
|
||||
|
||||
// Recalculate effective times for each node
|
||||
for (const node of ['TX1', 'NC1']) {
|
||||
const configResult = await db.query(
|
||||
'SELECT base_time, interval_minutes FROM global_restart_config WHERE node = $1',
|
||||
[node]
|
||||
);
|
||||
|
||||
if (configResult.rows.length === 0) continue;
|
||||
|
||||
const { base_time, interval_minutes } = configResult.rows[0];
|
||||
|
||||
const serversResult = await db.query(
|
||||
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||
[node]
|
||||
);
|
||||
|
||||
const servers = serversResult.rows;
|
||||
const staggered = calculateStagger(base_time, interval_minutes, servers);
|
||||
|
||||
for (const server of staggered) {
|
||||
await db.query(
|
||||
'UPDATE server_restart_schedules SET effective_time = $1 WHERE server_id = $2',
|
||||
[server.effective_time, server.server_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Reorder error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/scheduler/update-config - Update node config
|
||||
router.post('/update-config', async (req, res) => {
|
||||
try {
|
||||
let { node, base_time, interval_minutes } = req.body;
|
||||
const updatedBy = req.session?.user?.username || 'Unknown';
|
||||
|
||||
// Normalize time to HH:mm:ss format
|
||||
if (base_time && !base_time.includes(':00', 3)) {
|
||||
base_time = base_time + ':00';
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`UPDATE global_restart_config
|
||||
SET base_time = $1, interval_minutes = $2, updated_at = NOW(), updated_by = $3
|
||||
WHERE node = $4`,
|
||||
[base_time, interval_minutes, updatedBy, node]
|
||||
);
|
||||
|
||||
// Recalculate effective times for this node
|
||||
const serversResult = await db.query(
|
||||
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||
[node]
|
||||
);
|
||||
|
||||
const servers = serversResult.rows;
|
||||
const staggered = calculateStagger(base_time, interval_minutes, servers);
|
||||
|
||||
for (const server of staggered) {
|
||||
await db.query(
|
||||
'UPDATE server_restart_schedules SET effective_time = $1, sync_status = $2 WHERE server_id = $3',
|
||||
[server.effective_time, 'PENDING', server.server_id]
|
||||
);
|
||||
}
|
||||
|
||||
res.redirect('/admin/scheduler');
|
||||
} catch (err) {
|
||||
console.error('Update config error:', err);
|
||||
res.status(500).send('Error updating config');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/scheduler/sync/:node - Sync all servers for a node
|
||||
router.post('/sync/:node', async (req, res) => {
|
||||
try {
|
||||
const { node } = req.params;
|
||||
const results = await syncAllForNode(node);
|
||||
|
||||
const success = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
|
||||
res.json({ success: true, synced: success, failed });
|
||||
} catch (err) {
|
||||
console.error('Sync error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /admin/scheduler/audit/:node - Audit a node for rogue schedules
|
||||
router.get('/audit/:node', async (req, res) => {
|
||||
try {
|
||||
const { node } = req.params;
|
||||
|
||||
const serversResult = await db.query(
|
||||
'SELECT server_id, server_name FROM server_restart_schedules WHERE node = $1',
|
||||
[node]
|
||||
);
|
||||
|
||||
const results = [];
|
||||
let totalRogue = 0;
|
||||
|
||||
for (const server of serversResult.rows) {
|
||||
const auditResult = await auditServerSchedules(server.server_id, server.server_name);
|
||||
if (auditResult.rogueSchedules.length > 0) {
|
||||
results.push(auditResult);
|
||||
totalRogue += auditResult.rogueSchedules.length;
|
||||
}
|
||||
await sleep(200); // Rate limiting
|
||||
}
|
||||
|
||||
let html = `<div id="audit-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-darkcard border ${totalRogue > 0 ? 'border-red-500' : 'border-green-500'} rounded-lg shadow-2xl w-full max-w-2xl p-6 relative">`;
|
||||
|
||||
if (totalRogue > 0) {
|
||||
const nukePayload = [];
|
||||
results.forEach(r => r.rogueSchedules.forEach(s => nukePayload.push({
|
||||
serverId: r.serverId, scheduleId: s.id, scheduleName: s.name
|
||||
})));
|
||||
|
||||
html += `<h2 class="text-2xl font-bold text-red-500 mb-2">⚠ Conflicts Detected</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Found <strong class="text-gray-900 dark:text-white">${totalRogue}</strong> rogue restart schedule(s) across
|
||||
<strong class="text-gray-900 dark:text-white">${results.length}</strong> server(s) on ${node}.
|
||||
These must be removed before Trinity can take control.
|
||||
</p>
|
||||
<div class="bg-gray-100 dark:bg-darkbg rounded p-4 mb-6 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700">
|
||||
<ul class="space-y-3">`;
|
||||
|
||||
results.forEach(result => {
|
||||
html += `<li class="border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
|
||||
<span class="text-fire font-semibold">${result.serverName}</span>
|
||||
<ul class="ml-4 mt-1 text-sm text-gray-500 dark:text-gray-400">`;
|
||||
result.rogueSchedules.forEach(sched => {
|
||||
html += `<li>- "${sched.name}" (Cron: ${sched.cron})</li>`;
|
||||
});
|
||||
html += `</ul></li>`;
|
||||
});
|
||||
|
||||
html += `</ul></div>
|
||||
<form hx-post="/admin/scheduler/audit/nuke/${node}" hx-target="#audit-modal" hx-swap="outerHTML">
|
||||
<input type="hidden" name="nukeData" value='${JSON.stringify(nukePayload)}'>
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||
class="px-4 py-2 text-gray-500 hover:text-gray-900 dark:hover:text-white transition">Cancel</button>
|
||||
<button type="submit" class="bg-red-600 hover:bg-red-500 text-white px-6 py-2 rounded font-bold transition">
|
||||
🔥 Nuke ${totalRogue} Schedules
|
||||
</button>
|
||||
</div>
|
||||
</form>`;
|
||||
} else {
|
||||
html += `<h2 class="text-2xl font-bold text-green-500 mb-2">✓ All Clear</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">No conflicts found on ${node}. Trinity is ready to take control.</p>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||
class="bg-green-600 hover:bg-green-500 text-white px-6 py-2 rounded transition">Close</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
res.send(html);
|
||||
} catch (err) {
|
||||
console.error('Audit error:', err);
|
||||
res.status(500).send('Error running audit');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/scheduler/audit/nuke/:node - Delete all rogue schedules
|
||||
router.post('/audit/nuke/:node', async (req, res) => {
|
||||
try {
|
||||
const { node } = req.params;
|
||||
const nukeData = JSON.parse(req.body.nukeData);
|
||||
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const item of nukeData) {
|
||||
const result = await deleteSchedule(item.serverId, item.scheduleId, item.scheduleName);
|
||||
if (result.success) {
|
||||
deleted++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
await sleep(200); // Rate limiting
|
||||
}
|
||||
|
||||
// Return success message as modal replacement
|
||||
res.send(`
|
||||
<div id="audit-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
||||
<div class="bg-slate-900 border border-green-500 rounded-lg shadow-2xl w-full max-w-md p-6">
|
||||
<h2 class="text-2xl font-bold text-green-400 mb-4">✓ Cleanup Complete</h2>
|
||||
<p class="text-slate-300 mb-6">
|
||||
Deleted <strong class="text-white">${deleted}</strong> rogue schedule(s) on ${node}.
|
||||
${failed > 0 ? `<br><span class="text-red-400">${failed} failed.</span>` : ''}
|
||||
</p>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" onclick="document.getElementById('audit-modal').remove(); htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table');"
|
||||
class="bg-green-600 hover:bg-green-500 text-white px-6 py-2 rounded transition">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
} catch (err) {
|
||||
console.error('Nuke error:', err);
|
||||
res.status(500).send('Error deleting schedules');
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/scheduler/toggle-skip/:serverId - Toggle skip_restart
|
||||
router.post('/toggle-skip/:serverId', async (req, res) => {
|
||||
try {
|
||||
const { serverId } = req.params;
|
||||
|
||||
await db.query(
|
||||
'UPDATE server_restart_schedules SET skip_restart = NOT skip_restart, sync_status = $1 WHERE server_id = $2',
|
||||
['PENDING', serverId]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /admin/scheduler/import-servers - Import servers from Pterodactyl
|
||||
router.post('/import-servers', async (req, res) => {
|
||||
try {
|
||||
// Use discovery to get servers
|
||||
const { getMinecraftServers } = require('../../panel/discovery');
|
||||
const servers = await getMinecraftServers();
|
||||
|
||||
let imported = 0;
|
||||
|
||||
for (const server of servers) {
|
||||
const node = server.node || 'TX1'; // Default to TX1 if unknown
|
||||
|
||||
// Check if server already exists
|
||||
const existing = await db.query(
|
||||
'SELECT id FROM server_restart_schedules WHERE server_id = $1',
|
||||
[server.identifier]
|
||||
);
|
||||
|
||||
if (existing.rows.length === 0) {
|
||||
// Get current count for this node for sort_order
|
||||
const countResult = await db.query(
|
||||
'SELECT COUNT(*) as count FROM server_restart_schedules WHERE node = $1',
|
||||
[node]
|
||||
);
|
||||
const sortOrder = parseInt(countResult.rows[0].count);
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO server_restart_schedules (server_id, server_name, node, sort_order)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[server.identifier, server.name, node, sortOrder]
|
||||
);
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate effective times
|
||||
for (const node of ['TX1', 'NC1']) {
|
||||
const configResult = await db.query(
|
||||
'SELECT base_time, interval_minutes FROM global_restart_config WHERE node = $1',
|
||||
[node]
|
||||
);
|
||||
|
||||
if (configResult.rows.length === 0) continue;
|
||||
|
||||
const { base_time, interval_minutes } = configResult.rows[0];
|
||||
|
||||
const serversResult = await db.query(
|
||||
'SELECT server_id FROM server_restart_schedules WHERE node = $1 ORDER BY sort_order',
|
||||
[node]
|
||||
);
|
||||
|
||||
const staggered = calculateStagger(base_time, interval_minutes, serversResult.rows);
|
||||
|
||||
for (const server of staggered) {
|
||||
await db.query(
|
||||
'UPDATE server_restart_schedules SET effective_time = $1 WHERE server_id = $2',
|
||||
[server.effective_time, server.server_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, imported });
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,11 +4,105 @@ const db = require('../../database');
|
||||
const { getMinecraftServers } = require('../../panel/discovery');
|
||||
const { readServerProperties, writeWhitelistFile } = require('../../panel/files');
|
||||
const { reloadWhitelistCommand } = require('../../panel/commands');
|
||||
const { ChannelType } = require('discord.js');
|
||||
|
||||
// In-memory cache for RV low-bandwidth operations
|
||||
let serverCache = { data: null, lastFetch: 0 };
|
||||
const CACHE_TTL = 60000; // 60 seconds
|
||||
|
||||
// Cache for Discord channels (refresh less frequently)
|
||||
let discordChannelCache = { channels: null, lastFetch: 0 };
|
||||
const DISCORD_CACHE_TTL = 300000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get Discord channels from cache or fetch fresh
|
||||
*/
|
||||
async function getDiscordChannels(client) {
|
||||
const now = Date.now();
|
||||
if (discordChannelCache.channels && (now - discordChannelCache.lastFetch < DISCORD_CACHE_TTL)) {
|
||||
return discordChannelCache.channels;
|
||||
}
|
||||
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
if (!guild) return [];
|
||||
|
||||
const channels = guild.channels.cache.map(ch => ({
|
||||
id: ch.id,
|
||||
name: ch.name,
|
||||
type: ch.type,
|
||||
parentId: ch.parentId
|
||||
}));
|
||||
|
||||
discordChannelCache = { channels, lastFetch: now };
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check which Discord channels exist for a server
|
||||
* Returns object with missing channels array
|
||||
*/
|
||||
function checkServerChannels(serverName, allChannels) {
|
||||
// Extract the base name (before any " - " subtitle or parenthetical)
|
||||
// "Homestead - A Cozy Survival Experience" -> "homestead"
|
||||
// "All The Mons (Private) - TX" -> "all-the-mons"
|
||||
// "Stoneblock 4" -> "stoneblock-4"
|
||||
let baseName = serverName
|
||||
.split(' - ')[0] // Take part before " - " subtitle
|
||||
.replace(/\s*\([^)]*\)\s*/g, '') // Remove parentheticals like (Private)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, '') // Remove special chars except spaces
|
||||
.replace(/\s+/g, '-') // Spaces to hyphens
|
||||
.replace(/-+/g, '-') // Multiple hyphens to single
|
||||
.trim();
|
||||
|
||||
// Also create a display name for voice channel matching
|
||||
const voiceDisplayName = serverName
|
||||
.split(' - ')[0]
|
||||
.replace(/\s*\([^)]*\)\s*/g, '')
|
||||
.trim();
|
||||
|
||||
const expectedChannels = [
|
||||
{ name: `${baseName}-chat`, type: 'text', label: 'Chat' },
|
||||
{ name: `${baseName}-in-game`, type: 'text', label: 'In-Game' },
|
||||
{ name: `${baseName}-forum`, type: 'forum', label: 'Forum' },
|
||||
{ name: voiceDisplayName, type: 'voice', label: 'Voice' }
|
||||
];
|
||||
|
||||
const missing = [];
|
||||
const found = [];
|
||||
|
||||
for (const expected of expectedChannels) {
|
||||
let exists = false;
|
||||
|
||||
if (expected.type === 'voice') {
|
||||
// Voice channels match by exact name (case-insensitive)
|
||||
exists = allChannels.some(ch =>
|
||||
ch.type === ChannelType.GuildVoice &&
|
||||
ch.name.toLowerCase() === expected.name.toLowerCase()
|
||||
);
|
||||
} else if (expected.type === 'forum') {
|
||||
exists = allChannels.some(ch =>
|
||||
ch.type === ChannelType.GuildForum &&
|
||||
ch.name === expected.name
|
||||
);
|
||||
} else {
|
||||
// Text channels
|
||||
exists = allChannels.some(ch =>
|
||||
ch.type === ChannelType.GuildText &&
|
||||
ch.name === expected.name
|
||||
);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
found.push(expected.label);
|
||||
} else {
|
||||
missing.push(expected.label);
|
||||
}
|
||||
}
|
||||
|
||||
return { missing, found, complete: missing.length === 0 };
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('admin/servers/index', { title: 'Server Matrix' });
|
||||
});
|
||||
@@ -38,14 +132,22 @@ router.get('/matrix', async (req, res) => {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const enrichedServers = serversData.map(srv => ({
|
||||
...srv,
|
||||
log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' }
|
||||
}));
|
||||
// Get Discord channels
|
||||
const client = req.app.locals.client;
|
||||
const discordChannels = await getDiscordChannels(client);
|
||||
|
||||
const enrichedServers = serversData.map(srv => {
|
||||
const channelStatus = checkServerChannels(srv.name, discordChannels);
|
||||
return {
|
||||
...srv,
|
||||
log: logMap[srv.identifier] || { is_online: false, last_error: 'Never synced' },
|
||||
discord: channelStatus
|
||||
};
|
||||
});
|
||||
|
||||
// Group by Node Location
|
||||
const txServers = enrichedServers.filter(s => s.node === 'TX1' || s.node === 'Node 3' || s.name.includes('TX'));
|
||||
const ncServers = enrichedServers.filter(s => s.node === 'NC1' || s.node === 'Node 2' || s.name.includes('NC'));
|
||||
const txServers = enrichedServers.filter(s => s.node === 'TX1');
|
||||
const ncServers = enrichedServers.filter(s => s.node === 'NC1');
|
||||
|
||||
res.render('admin/servers/_matrix_body', { txServers, ncServers, layout: false });
|
||||
});
|
||||
@@ -108,4 +210,50 @@ router.post('/:identifier/toggle-whitelist', async (req, res) => {
|
||||
res.send(`<span class="text-yellow-500 font-bold text-sm">⚠️ Requires Restart</span>`);
|
||||
});
|
||||
|
||||
// Sync all servers on a specific node
|
||||
router.post('/sync-all/:node', async (req, res) => {
|
||||
const { node } = req.params;
|
||||
const nodeId = node === 'tx1' ? 3 : node === 'nc1' ? 2 : null;
|
||||
|
||||
if (!nodeId) {
|
||||
return res.send(`<span class="text-red-500">Invalid node</span>`);
|
||||
}
|
||||
|
||||
try {
|
||||
const discovered = await getMinecraftServers();
|
||||
const nodeServers = discovered.filter(s => s.nodeId === nodeId);
|
||||
|
||||
const { rows: players } = await db.query(
|
||||
`SELECT minecraft_username as name, minecraft_uuid as uuid FROM users
|
||||
JOIN subscriptions ON users.discord_id = subscriptions.discord_id
|
||||
WHERE subscriptions.status IN ('active', 'grace_period', 'lifetime')`
|
||||
);
|
||||
|
||||
let synced = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const srv of nodeServers) {
|
||||
try {
|
||||
await writeWhitelistFile(srv.identifier, players);
|
||||
await reloadWhitelistCommand(srv.identifier);
|
||||
await db.query(
|
||||
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL",
|
||||
[srv.identifier]
|
||||
);
|
||||
synced++;
|
||||
} catch (err) {
|
||||
await db.query(
|
||||
"INSERT INTO server_sync_log (server_identifier, last_error, is_online) VALUES ($1, $2, false) ON CONFLICT (server_identifier) DO UPDATE SET last_error = $2, is_online = false",
|
||||
[srv.identifier, err.message]
|
||||
);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
res.send(`<span class="text-green-500 font-bold">✅ ${synced} synced</span>${errors > 0 ? ` <span class="text-red-500">(${errors} errors)</span>` : ''}`);
|
||||
} catch (error) {
|
||||
res.send(`<span class="text-red-500">❌ ${error.message}</span>`);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
40
services/arbiter-3.0/src/routes/admin/system.js
Normal file
40
services/arbiter-3.0/src/routes/admin/system.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { exec } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
/**
|
||||
* System Routes - Maintenance & Deployment
|
||||
*
|
||||
* POST /admin/system/deploy - Deploy latest Arbiter code from Gitea
|
||||
*/
|
||||
|
||||
// POST /admin/system/deploy - Pull latest code and restart Arbiter
|
||||
router.post('/deploy', (req, res) => {
|
||||
const username = req.user?.username || 'unknown';
|
||||
|
||||
console.log(`[DEPLOY] Deployment initiated by ${username}`);
|
||||
|
||||
// Use nohup to detach the process so the response returns before Arbiter restarts
|
||||
// Output goes to /tmp/deploy.log for debugging if needed
|
||||
exec(`nohup sudo /opt/scripts/deploy-arbiter.sh "${username}" > /tmp/deploy.log 2>&1 &`);
|
||||
|
||||
// Return immediately - deploy happens in background
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Deploy started. Arbiter will restart momentarily.'
|
||||
});
|
||||
});
|
||||
|
||||
// GET /admin/system/status - Check if deploy script exists and arbiter is running
|
||||
router.get('/status', (req, res) => {
|
||||
exec('systemctl is-active arbiter-3', (error, stdout) => {
|
||||
const isRunning = stdout.trim() === 'active';
|
||||
res.json({
|
||||
arbiter: isRunning ? 'running' : 'stopped',
|
||||
deployAvailable: fs.existsSync('/opt/scripts/deploy-arbiter.sh')
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,6 +2,7 @@
|
||||
* Stripe Integration Routes
|
||||
* Handles checkout sessions, webhooks, and customer portal
|
||||
* Date: April 3, 2026
|
||||
* Updated: April 6, 2026 - Added Discord role sync (Task #87)
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
@@ -9,6 +10,7 @@ const router = express.Router();
|
||||
const cors = require('cors');
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
const db = require('../database');
|
||||
const { syncRole, removeAllRoles } = require('../services/discordRoleSync');
|
||||
|
||||
// CORS configuration for checkout endpoint
|
||||
const corsOptions = {
|
||||
@@ -123,6 +125,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
||||
const session = event.data.object;
|
||||
const discordId = session.client_reference_id;
|
||||
const customerId = session.customer;
|
||||
let tierLevel = null;
|
||||
|
||||
if (session.mode === 'subscription') {
|
||||
// RECURRING SUBSCRIPTION (Tiers 2-9)
|
||||
@@ -140,6 +143,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
||||
}
|
||||
|
||||
const tierData = productRes.rows[0];
|
||||
tierLevel = tierData.tier_level;
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_subscription_id, stripe_customer_id, mrr_value, is_lifetime)
|
||||
@@ -163,6 +167,7 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
||||
}
|
||||
|
||||
const tierData = productRes.rows[0];
|
||||
tierLevel = tierData.tier_level;
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO subscriptions (discord_id, tier_level, status, stripe_payment_intent_id, stripe_customer_id, mrr_value, is_lifetime)
|
||||
@@ -177,8 +182,11 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
||||
VALUES ('CHECKOUT_COMPLETED', $1, $2)
|
||||
`, [discordId, JSON.stringify({ mode: session.mode, customer: customerId })]);
|
||||
|
||||
// TODO: Trigger Discord role sync
|
||||
// TODO: Trigger Pterodactyl whitelist sync
|
||||
// Sync Discord role
|
||||
if (discordId && tierLevel) {
|
||||
const roleResult = await syncRole(discordId, tierLevel);
|
||||
console.log(`🎭 Role sync for ${discordId}: ${roleResult.message}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -257,23 +265,55 @@ router.post('/webhook', express.raw({ type: 'application/json' }), async (req, r
|
||||
case 'charge.dispute.created': {
|
||||
const dispute = event.data.object;
|
||||
const paymentIntentId = dispute.payment_intent;
|
||||
const customerId = dispute.customer;
|
||||
|
||||
// Immediately ban on chargeback
|
||||
// Find the subscription by customer ID (more reliable)
|
||||
const subResult = await client.query(`
|
||||
SELECT discord_id FROM subscriptions
|
||||
WHERE stripe_customer_id = $1
|
||||
LIMIT 1
|
||||
`, [customerId]);
|
||||
|
||||
let discordId = null;
|
||||
if (subResult.rows.length > 0) {
|
||||
discordId = subResult.rows[0].discord_id;
|
||||
}
|
||||
|
||||
// Mark as banned
|
||||
await client.query(`
|
||||
UPDATE subscriptions
|
||||
SET status = 'chargeback_ban',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE stripe_payment_intent_id = $1 OR stripe_subscription_id IN (
|
||||
SELECT id FROM stripe_subscriptions WHERE latest_invoice IN (
|
||||
SELECT id FROM stripe_invoices WHERE payment_intent = $1
|
||||
)
|
||||
)
|
||||
`, [paymentIntentId]);
|
||||
WHERE stripe_customer_id = $1
|
||||
`, [customerId]);
|
||||
|
||||
// Add to banned_users table
|
||||
if (discordId) {
|
||||
await client.query(`
|
||||
INSERT INTO banned_users (discord_id, ban_reason, notes)
|
||||
VALUES ($1, 'chargeback', $2)
|
||||
ON CONFLICT (discord_id) DO UPDATE SET
|
||||
ban_reason = 'chargeback',
|
||||
notes = $2,
|
||||
banned_at = CURRENT_TIMESTAMP
|
||||
`, [discordId, JSON.stringify({
|
||||
payment_intent: paymentIntentId,
|
||||
dispute_id: dispute.id
|
||||
})]);
|
||||
|
||||
// Remove all Discord roles
|
||||
const roleResult = await removeAllRoles(discordId);
|
||||
console.log(`🚫 Chargeback role removal for ${discordId}: ${roleResult.message}`);
|
||||
}
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO admin_audit_log (action_type, target_identifier, details)
|
||||
VALUES ('CHARGEBACK_BAN', $1, $2)
|
||||
`, ['system', JSON.stringify({ payment_intent: paymentIntentId, reason: 'Chargeback dispute created' })]);
|
||||
`, [discordId || 'unknown', JSON.stringify({
|
||||
payment_intent: paymentIntentId,
|
||||
customer_id: customerId,
|
||||
reason: 'Chargeback dispute created'
|
||||
})]);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
144
services/arbiter-3.0/src/services/discordRoleSync.js
Normal file
144
services/arbiter-3.0/src/services/discordRoleSync.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Discord Role Sync Service
|
||||
* Handles adding/removing Discord roles based on subscription tier
|
||||
*
|
||||
* Task #87: Arbiter Lifecycle Handlers
|
||||
* Date: April 6, 2026
|
||||
*/
|
||||
|
||||
const { getRoleMappings } = require('../utils/roleMappings');
|
||||
|
||||
// Tier level to role key mapping
|
||||
const TIER_TO_ROLE_KEY = {
|
||||
1: 'the-awakened',
|
||||
2: 'fire-elemental',
|
||||
3: 'frost-elemental',
|
||||
4: 'fire-knight',
|
||||
5: 'frost-knight',
|
||||
6: 'fire-master',
|
||||
7: 'frost-master',
|
||||
8: 'fire-legend',
|
||||
9: 'frost-legend',
|
||||
10: 'the-sovereign'
|
||||
};
|
||||
|
||||
// All subscriber role keys (for removal)
|
||||
const ALL_SUBSCRIBER_ROLE_KEYS = Object.values(TIER_TO_ROLE_KEY);
|
||||
|
||||
// Store Discord client reference
|
||||
let discordClient = null;
|
||||
|
||||
/**
|
||||
* Initialize the service with Discord client
|
||||
* Called from index.js after client is ready
|
||||
*/
|
||||
function init(client) {
|
||||
discordClient = client;
|
||||
console.log('✅ Discord Role Sync service initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Discord client
|
||||
*/
|
||||
function getClient() {
|
||||
return discordClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Discord role for a user based on their tier
|
||||
* Removes old tier roles and adds the new one
|
||||
*
|
||||
* @param {string} discordId - User's Discord ID
|
||||
* @param {number} newTierLevel - New tier level (1-10), or null for complete removal
|
||||
* @returns {Promise<{success: boolean, message: string}>}
|
||||
*/
|
||||
async function syncRole(discordId, newTierLevel) {
|
||||
if (!discordClient) {
|
||||
return { success: false, message: 'Discord client not initialized' };
|
||||
}
|
||||
|
||||
const guildId = process.env.GUILD_ID;
|
||||
if (!guildId) {
|
||||
return { success: false, message: 'GUILD_ID not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const guild = discordClient.guilds.cache.get(guildId);
|
||||
if (!guild) {
|
||||
return { success: false, message: 'Guild not found in cache' };
|
||||
}
|
||||
|
||||
const member = await guild.members.fetch(discordId).catch(() => null);
|
||||
if (!member) {
|
||||
return { success: false, message: 'Member not found in guild (may have left)' };
|
||||
}
|
||||
|
||||
const roleMappings = getRoleMappings();
|
||||
|
||||
// Get all role IDs to remove
|
||||
const rolesToRemove = ALL_SUBSCRIBER_ROLE_KEYS
|
||||
.map(key => roleMappings[key])
|
||||
.filter(id => id && member.roles.cache.has(id));
|
||||
|
||||
// Remove old roles
|
||||
if (rolesToRemove.length > 0) {
|
||||
await member.roles.remove(rolesToRemove);
|
||||
}
|
||||
|
||||
// Add new role if tier specified
|
||||
if (newTierLevel !== null) {
|
||||
const newRoleKey = TIER_TO_ROLE_KEY[newTierLevel];
|
||||
const newRoleId = roleMappings[newRoleKey];
|
||||
|
||||
if (newRoleId) {
|
||||
await member.roles.add(newRoleId);
|
||||
return {
|
||||
success: true,
|
||||
message: `Synced to tier ${newTierLevel} (${newRoleKey})`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `No role mapping found for tier ${newTierLevel}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: 'All subscriber roles removed' };
|
||||
|
||||
} catch (error) {
|
||||
console.error('Discord role sync error:', error);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all subscriber roles from a user (for bans/chargebacks)
|
||||
*
|
||||
* @param {string} discordId - User's Discord ID
|
||||
* @returns {Promise<{success: boolean, message: string}>}
|
||||
*/
|
||||
async function removeAllRoles(discordId) {
|
||||
return syncRole(discordId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade user to Awakened tier
|
||||
* Used when grace period expires
|
||||
*
|
||||
* @param {string} discordId - User's Discord ID
|
||||
* @returns {Promise<{success: boolean, message: string}>}
|
||||
*/
|
||||
async function downgradeToAwakened(discordId) {
|
||||
return syncRole(discordId, 1); // Tier 1 = Awakened
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
getClient,
|
||||
syncRole,
|
||||
removeAllRoles,
|
||||
downgradeToAwakened,
|
||||
TIER_TO_ROLE_KEY,
|
||||
ALL_SUBSCRIBER_ROLE_KEYS
|
||||
};
|
||||
@@ -1,10 +1,44 @@
|
||||
const cron = require('node-cron');
|
||||
const { triggerImmediateSync } = require('./immediate');
|
||||
const { processExpiredGracePeriods } = require('./graceExpiration');
|
||||
|
||||
let retryTimeout = null;
|
||||
|
||||
function initCron() {
|
||||
// Hourly whitelist reconciliation
|
||||
cron.schedule('0 * * * *', async () => {
|
||||
console.log("Starting hourly whitelist reconciliation...");
|
||||
await triggerImmediateSync();
|
||||
console.log("⏰ Starting hourly sync jobs...");
|
||||
|
||||
// 1. Process expired grace periods
|
||||
await processExpiredGracePeriods();
|
||||
|
||||
// 2. Whitelist reconciliation
|
||||
console.log("Starting whitelist reconciliation...");
|
||||
const { failCount } = await triggerImmediateSync();
|
||||
|
||||
// 3. Schedule retry if there were failures
|
||||
if (failCount > 0) {
|
||||
console.log(`⏳ ${failCount} servers failed. Scheduling retry in 10 minutes...`);
|
||||
|
||||
// Clear any existing retry timeout
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
|
||||
// Retry failed servers after 10 minutes
|
||||
retryTimeout = setTimeout(async () => {
|
||||
console.log("🔄 Running retry sync for failed servers...");
|
||||
const { failCount: retryFailCount } = await triggerImmediateSync(true);
|
||||
|
||||
if (retryFailCount > 0) {
|
||||
console.log(`⚠️ ${retryFailCount} servers still failing after retry.`);
|
||||
} else {
|
||||
console.log("✅ All previously failed servers now synced successfully.");
|
||||
}
|
||||
}, 10 * 60 * 1000); // 10 minutes
|
||||
}
|
||||
|
||||
console.log("✅ Hourly sync jobs complete");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
111
services/arbiter-3.0/src/sync/graceExpiration.js
Normal file
111
services/arbiter-3.0/src/sync/graceExpiration.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Grace Period Expiration Job
|
||||
* Checks for expired grace periods and downgrades users to Awakened
|
||||
*
|
||||
* Philosophy: "We Don't Kick People Out"
|
||||
* - Expired grace periods downgrade to permanent Awakened tier
|
||||
* - Users keep community access, just lose premium perks
|
||||
*
|
||||
* Task #87: Arbiter Lifecycle Handlers
|
||||
* Date: April 6, 2026
|
||||
*/
|
||||
|
||||
const db = require('../database');
|
||||
const { downgradeToAwakened } = require('../services/discordRoleSync');
|
||||
|
||||
/**
|
||||
* Process all expired grace periods
|
||||
* Called hourly from cron.js
|
||||
*
|
||||
* @returns {Promise<{processed: number, errors: number}>}
|
||||
*/
|
||||
async function processExpiredGracePeriods() {
|
||||
console.log('🔍 Checking for expired grace periods...');
|
||||
|
||||
const client = await db.pool.connect();
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
try {
|
||||
// Find all expired grace periods
|
||||
const { rows: expired } = await client.query(`
|
||||
SELECT s.discord_id, s.tier_level, u.minecraft_username
|
||||
FROM subscriptions s
|
||||
LEFT JOIN users u ON s.discord_id = u.discord_id
|
||||
WHERE s.status = 'grace_period'
|
||||
AND s.grace_period_ends_at < NOW()
|
||||
AND s.is_lifetime = FALSE
|
||||
`);
|
||||
|
||||
if (expired.length === 0) {
|
||||
console.log('✅ No expired grace periods found');
|
||||
return { processed: 0, errors: 0 };
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${expired.length} expired grace period(s)`);
|
||||
|
||||
for (const sub of expired) {
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Record the tier change in history
|
||||
await client.query(`
|
||||
INSERT INTO player_history
|
||||
(discord_id, previous_tier, new_tier, change_reason)
|
||||
VALUES ($1, $2, 1, 'grace_period_expired')
|
||||
`, [sub.discord_id, sub.tier_level]);
|
||||
|
||||
// Downgrade to Awakened (tier 1, lifetime)
|
||||
await client.query(`
|
||||
UPDATE subscriptions
|
||||
SET tier_level = 1,
|
||||
status = 'lifetime',
|
||||
is_lifetime = TRUE,
|
||||
mrr_value = 0,
|
||||
grace_period_started_at = NULL,
|
||||
grace_period_ends_at = NULL,
|
||||
payment_failure_reason = NULL,
|
||||
stripe_subscription_id = NULL,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE discord_id = $1
|
||||
`, [sub.discord_id]);
|
||||
|
||||
// Log in audit
|
||||
await client.query(`
|
||||
INSERT INTO admin_audit_log
|
||||
(action_type, target_identifier, details)
|
||||
VALUES ('GRACE_PERIOD_EXPIRED', $1, $2)
|
||||
`, [sub.discord_id, JSON.stringify({
|
||||
previous_tier: sub.tier_level,
|
||||
new_tier: 1,
|
||||
minecraft_username: sub.minecraft_username,
|
||||
reason: 'Automatic downgrade after grace period expiration'
|
||||
})]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Sync Discord role to Awakened
|
||||
const syncResult = await downgradeToAwakened(sub.discord_id);
|
||||
if (!syncResult.success) {
|
||||
console.warn(`⚠️ Role sync failed for ${sub.discord_id}: ${syncResult.message}`);
|
||||
}
|
||||
|
||||
console.log(`✅ Downgraded ${sub.minecraft_username || sub.discord_id} to Awakened`);
|
||||
processed++;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(`❌ Error processing ${sub.discord_id}:`, error.message);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
console.log(`📊 Grace period processing complete: ${processed} processed, ${errors} errors`);
|
||||
return { processed, errors };
|
||||
}
|
||||
|
||||
module.exports = { processExpiredGracePeriods };
|
||||
@@ -3,8 +3,8 @@ const { getMinecraftServers } = require('../panel/discovery');
|
||||
const { writeWhitelistFile } = require('../panel/files');
|
||||
const { reloadWhitelistCommand } = require('../panel/commands');
|
||||
|
||||
async function triggerImmediateSync() {
|
||||
console.log("--- Starting Whitelist Sync ---");
|
||||
async function triggerImmediateSync(retryOnly = false) {
|
||||
console.log(retryOnly ? "--- Starting Retry Sync (failed servers only) ---" : "--- Starting Whitelist Sync ---");
|
||||
try {
|
||||
// 1. Fetch Players (Now includes 'lifetime' for the Trinity)
|
||||
const { rows: players, rowCount: playerCount } = await db.query(
|
||||
@@ -16,12 +16,27 @@ async function triggerImmediateSync() {
|
||||
console.log(`[Sync] Retrieved ${playerCount} active players from database.`);
|
||||
|
||||
// 2. Fetch Servers
|
||||
const servers = await getMinecraftServers();
|
||||
console.log(`[Sync] Discovered ${servers.length} target servers.`);
|
||||
let servers = await getMinecraftServers();
|
||||
|
||||
// If retry mode, only sync servers that previously failed
|
||||
if (retryOnly) {
|
||||
const { rows: failedServers } = await db.query(
|
||||
`SELECT server_identifier FROM server_sync_log WHERE is_online = false`
|
||||
);
|
||||
const failedIds = failedServers.map(s => s.server_identifier);
|
||||
servers = servers.filter(s => failedIds.includes(s.identifier));
|
||||
console.log(`[Sync] Retrying ${servers.length} previously failed servers.`);
|
||||
} else {
|
||||
console.log(`[Sync] Discovered ${servers.length} target servers.`);
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
console.warn("[Sync] WARN: 0 servers discovered. Check MINECRAFT_NEST_IDS in .env.");
|
||||
return;
|
||||
if (retryOnly) {
|
||||
console.log("[Sync] No failed servers to retry.");
|
||||
} else {
|
||||
console.warn("[Sync] WARN: 0 servers discovered. Check MINECRAFT_NEST_IDS in .env.");
|
||||
}
|
||||
return { successCount: 0, failCount: 0 };
|
||||
}
|
||||
|
||||
// 3. Process Servers Sequentially
|
||||
@@ -34,10 +49,13 @@ async function triggerImmediateSync() {
|
||||
await reloadWhitelistCommand(server.identifier);
|
||||
|
||||
await db.query(
|
||||
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online) VALUES ($1, NOW(), true) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true",
|
||||
"INSERT INTO server_sync_log (server_identifier, last_successful_sync, is_online, last_error) VALUES ($1, NOW(), true, NULL) ON CONFLICT (server_identifier) DO UPDATE SET last_successful_sync = NOW(), is_online = true, last_error = NULL",
|
||||
[server.identifier]
|
||||
);
|
||||
successCount++;
|
||||
if (retryOnly) {
|
||||
console.log(`[Sync] ✅ Retry succeeded for ${server.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Sync] ❌ Failed for server ${server.name} (${server.identifier}):`, err.message);
|
||||
await db.query(
|
||||
@@ -50,8 +68,10 @@ async function triggerImmediateSync() {
|
||||
|
||||
console.log(`[Sync] Complete. Success: ${successCount}, Failed: ${failCount}`);
|
||||
console.log("-------------------------------");
|
||||
return { successCount, failCount };
|
||||
} catch (error) {
|
||||
console.error("[Sync] Critical failure during execution:", error);
|
||||
return { successCount: 0, failCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
services/arbiter-3.0/src/utils/scheduler.js
Normal file
12
services/arbiter-3.0/src/utils/scheduler.js
Normal file
@@ -0,0 +1,12 @@
|
||||
const { addMinutes, format, parse } = require('date-fns');
|
||||
|
||||
function calculateStagger(baseTime, interval, servers) {
|
||||
const start = parse(baseTime, 'HH:mm:ss', new Date());
|
||||
|
||||
return servers.map((server, index) => ({
|
||||
...server,
|
||||
effective_time: format(addMinutes(start, index * interval), 'HH:mm:ss')
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = { calculateStagger };
|
||||
@@ -1,19 +1,57 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Active Subscribers</div>
|
||||
<div class="text-3xl font-bold mt-2">0</div>
|
||||
<div class="text-3xl font-bold mt-2"><%= activeSubscribers %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Total MRR</div>
|
||||
<div class="text-3xl font-bold mt-2">$0</div>
|
||||
<div class="text-3xl font-bold mt-2">$<%= totalMRR.toFixed(0) %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Servers Online</div>
|
||||
<div class="text-3xl font-bold mt-2">12</div>
|
||||
<div class="text-3xl font-bold mt-2"><%= serversOnline %></div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Last Sync</div>
|
||||
<div class="text-3xl font-bold mt-2 text-green-500">✓</div>
|
||||
<% if (lastSyncTime) { %>
|
||||
<div class="text-xl font-bold mt-2 text-green-500">✓</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
<%= new Date(lastSyncTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) %>
|
||||
<%= new Date(lastSyncTime).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }) %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="text-xl font-bold mt-2 text-yellow-500">—</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Never</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Features Card -->
|
||||
<div class="bg-gradient-to-r from-green-500/10 to-emerald-500/10 rounded-lg border border-green-500/30 p-6 mb-6">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-green-500 text-xl">✨</span>
|
||||
<h3 class="text-lg font-semibold text-green-400">New Features</h3>
|
||||
<span class="text-xs bg-green-500/20 text-green-400 px-2 py-0.5 rounded-full">Just Added</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/admin/discord" class="group flex items-start gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition border border-transparent hover:border-green-500/30">
|
||||
<span class="text-2xl">💬</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-200 group-hover:text-green-400 transition">Discord Dashboard</div>
|
||||
<div class="text-sm text-gray-400 mt-1">
|
||||
Full server structure visualization with channel tree, role hierarchy, permission matrix, and health checks. Click any channel or role to see detailed access info.
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/admin/financials" class="group flex items-start gap-3 p-3 rounded-lg bg-white/5 hover:bg-white/10 transition border border-transparent hover:border-green-500/30">
|
||||
<span class="text-2xl">💰</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-200 group-hover:text-green-400 transition">Financials Module</div>
|
||||
<div class="text-sm text-gray-400 mt-1">
|
||||
Real-time Stripe integration showing MRR, revenue breakdown by tier, recent transactions, and subscription analytics.
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,3 +64,57 @@
|
||||
<strong>Fire + Frost + Foundation = Where Love Builds Legacy</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- v2 Teaser -->
|
||||
<div class="bg-gradient-to-r from-fire/10 via-universal/10 to-frost/10 rounded-lg border border-universal/30 p-6 mt-6">
|
||||
<h3 class="text-lg font-semibold mb-3 bg-gradient-to-r from-fire via-universal to-frost text-transparent bg-clip-text">
|
||||
🚀 Coming in v2.0 — Trinity Core
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm mb-4">
|
||||
A complete platform rebuild with plugin architecture and AI-powered operations.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-universal">🤖</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">Trinity Codex AI</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">Ask questions about Firefrost in natural language</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-universal">🔔</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">Smart Notifications</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">Real-time alerts via Discord</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-universal">🔐</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">Approval Workflows</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">Discord button approvals for sensitive actions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-universal">🧩</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">Plugin Architecture</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">12 self-registering modules: Dashboard, Players, Servers, Infrastructure, Financials, Tasks, Docs, Team, Marketing, Chroniclers, System, Health</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-universal">👥</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">Granular Permissions</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">Role-based access control for staff</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-universal">🌐</span>
|
||||
<div>
|
||||
<div class="font-medium text-gray-700 dark:text-gray-200">Distributed Mesh</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-xs">Manage all servers from anywhere</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal file
467
services/arbiter-3.0/src/views/admin/discord/index.ejs
Normal file
@@ -0,0 +1,467 @@
|
||||
<% if (error) { %>
|
||||
<div class="bg-red-500/10 border border-red-500/50 rounded-lg p-6 text-center">
|
||||
<div class="text-4xl mb-2">⚠️</div>
|
||||
<div class="text-red-400 font-medium"><%= error %></div>
|
||||
</div>
|
||||
<% } else if (data) { %>
|
||||
|
||||
<!-- Search & Filter Bar -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<div class="flex-1 min-w-64">
|
||||
<input type="text" id="search-input" placeholder="Search channels or roles..."
|
||||
class="w-full px-4 py-2 rounded-lg bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-frost">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="setView('channels')" id="btn-channels" class="px-4 py-2 rounded-lg bg-frost text-white font-medium transition">
|
||||
💬 Channels
|
||||
</button>
|
||||
<button onclick="setView('roles')" id="btn-roles" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
|
||||
🎭 Roles
|
||||
</button>
|
||||
<button onclick="setView('health')" id="btn-health" class="px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition">
|
||||
🩺 Health
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-frost"><%= data.summary.totalChannels %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Channels</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-fire"><%= data.summary.totalRoles %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Roles</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold text-universal"><%= data.summary.categoryCount %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Categories</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<div class="text-2xl font-bold"><%= data.server.memberCount %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||
<% if (data.healthChecks.orphanChannels === 0) { %>
|
||||
<div class="text-2xl font-bold text-green-500">✓</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Healthy</div>
|
||||
<% } else { %>
|
||||
<div class="text-2xl font-bold text-yellow-500"><%= data.healthChecks.orphanChannels %></div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Orphans</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Channels Panel (left 2/3) -->
|
||||
<div id="panel-channels" class="lg:col-span-2">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||
<img src="<%= data.server.icon %>" class="w-8 h-8 rounded-full" alt="">
|
||||
<h3 class="font-semibold"><%= data.server.name %></h3>
|
||||
</div>
|
||||
<div class="p-4 max-h-[600px] overflow-y-auto" id="channel-tree">
|
||||
<% data.categories.forEach((cat, catIndex) => { %>
|
||||
<div class="channel-item category-item mb-2" data-name="<%= cat.name.toLowerCase() %>">
|
||||
<div class="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer" onclick="toggleCategory('<%= cat.id %>')">
|
||||
<span class="text-gray-400 transition-transform" id="arrow-<%= cat.id %>">▶</span>
|
||||
<span class="text-gray-400">📁</span>
|
||||
<span class="font-medium text-sm uppercase text-gray-600 dark:text-gray-300"><%= cat.name %></span>
|
||||
<span class="text-xs text-gray-400 ml-auto"><%= cat.children.length %></span>
|
||||
</div>
|
||||
<div id="cat-<%= cat.id %>" class="hidden ml-6 mt-1 space-y-1">
|
||||
<% cat.children.forEach(ch => { %>
|
||||
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
data-name="<%= ch.name.toLowerCase() %>"
|
||||
onclick="showChannelDetails('<%= ch.id %>')">
|
||||
<% if (ch.type === 0) { %>
|
||||
<span class="text-gray-400">#</span>
|
||||
<% } else if (ch.type === 2) { %>
|
||||
<span class="text-gray-400">🔊</span>
|
||||
<% } else if (ch.type === 5) { %>
|
||||
<span class="text-gray-400">📢</span>
|
||||
<% } else if (ch.type === 13) { %>
|
||||
<span class="text-gray-400">🎭</span>
|
||||
<% } else if (ch.type === 15) { %>
|
||||
<span class="text-gray-400">💬</span>
|
||||
<% } else { %>
|
||||
<span class="text-gray-400">📄</span>
|
||||
<% } %>
|
||||
<span class="text-sm"><%= ch.name %></span>
|
||||
<% if (ch.nsfw) { %>
|
||||
<span class="text-xs bg-red-500/20 text-red-400 px-1 rounded">NSFW</span>
|
||||
<% } %>
|
||||
<% if (ch.permissionOverwrites.length > 0) { %>
|
||||
<span class="text-xs text-gray-400 ml-auto">🔒 <%= ch.permissionOverwrites.length %></span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
|
||||
<% if (data.orphanChannels.length > 0) { %>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="text-xs text-yellow-500 font-medium mb-2">⚠️ Orphan Channels (no category)</div>
|
||||
<% data.orphanChannels.forEach(ch => { %>
|
||||
<div class="channel-item flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
data-name="<%= ch.name.toLowerCase() %>"
|
||||
onclick="showChannelDetails('<%= ch.id %>')">
|
||||
<span class="text-yellow-500">#</span>
|
||||
<span class="text-sm"><%= ch.name %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Panel (right 1/3) -->
|
||||
<div id="panel-roles" class="hidden lg:block lg:col-span-1">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="font-semibold">🎭 Role Hierarchy</h3>
|
||||
</div>
|
||||
<div class="p-4 max-h-[600px] overflow-y-auto space-y-1" id="role-list">
|
||||
<% data.roles.forEach(role => { %>
|
||||
<div class="role-item flex items-center gap-2 px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
|
||||
data-name="<%= role.name.toLowerCase() %>"
|
||||
data-role-id="<%= role.id %>"
|
||||
onclick="showRoleDetails('<%= role.id %>')">
|
||||
<span class="w-3 h-3 rounded-full flex-shrink-0" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
|
||||
<span class="text-sm truncate flex-1"><%= role.name %></span>
|
||||
<% if (role.managed) { %>
|
||||
<span class="text-xs bg-blue-500/20 text-blue-400 px-1 rounded">BOT</span>
|
||||
<% } %>
|
||||
<span class="text-xs text-gray-400"><%= role.memberCount %></span>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Panel (hidden by default) -->
|
||||
<div id="panel-health" class="hidden lg:col-span-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- Orphan Channels -->
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||
<% if (data.healthChecks.orphanChannels === 0) { %>
|
||||
<span class="text-green-500">✓</span>
|
||||
<% } else { %>
|
||||
<span class="text-yellow-500">⚠️</span>
|
||||
<% } %>
|
||||
Orphan Channels
|
||||
</h4>
|
||||
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.orphanChannels === 0 ? 'text-green-500' : 'text-yellow-500' %>">
|
||||
<%= data.healthChecks.orphanChannels %>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Channels without a parent category
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty Roles -->
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||
<% if (data.healthChecks.emptyRoles <= 5) { %>
|
||||
<span class="text-green-500">✓</span>
|
||||
<% } else { %>
|
||||
<span class="text-yellow-500">⚠️</span>
|
||||
<% } %>
|
||||
Empty Roles
|
||||
</h4>
|
||||
<div class="text-3xl font-bold mb-2 <%= data.healthChecks.emptyRoles <= 5 ? 'text-green-500' : 'text-yellow-500' %>">
|
||||
<%= data.healthChecks.emptyRoles %>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Non-bot roles with no members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bot Roles -->
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h4 class="font-medium mb-3 flex items-center gap-2">
|
||||
<span class="text-blue-500">🤖</span>
|
||||
Bot Roles
|
||||
</h4>
|
||||
<div class="text-3xl font-bold mb-2 text-blue-500">
|
||||
<%= data.healthChecks.botRoles %>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Managed by Discord integrations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Roles List -->
|
||||
<% const emptyRoles = data.roles.filter(r => r.memberCount === 0 && !r.managed && r.name !== '@everyone'); %>
|
||||
<% if (emptyRoles.length > 0) { %>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mt-6">
|
||||
<h4 class="font-medium mb-3">Empty Roles (candidates for cleanup)</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% emptyRoles.forEach(role => { %>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
|
||||
style="background-color: <%= role.color === '#000000' ? '#374151' : role.color %>20; border: 1px solid <%= role.color === '#000000' ? '#374151' : role.color %>">
|
||||
<span class="w-2 h-2 rounded-full" style="background-color: <%= role.color === '#000000' ? '#6b7280' : role.color %>"></span>
|
||||
<%= role.name %>
|
||||
</span>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<div id="details-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50" onclick="closeModal(event)">
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[80vh] overflow-hidden" onclick="event.stopPropagation()">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 class="font-semibold" id="modal-title">Details</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 text-xl">×</button>
|
||||
</div>
|
||||
<div class="p-4 overflow-y-auto max-h-[60vh]" id="modal-content">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Store data for JS access
|
||||
const discordData = <%- JSON.stringify(data) %>;
|
||||
|
||||
// Role lookup by ID
|
||||
const rolesById = {};
|
||||
discordData.roles.forEach(r => rolesById[r.id] = r);
|
||||
|
||||
// Channel lookup by ID
|
||||
const channelsById = {};
|
||||
discordData.allChannels.forEach(ch => channelsById[ch.id] = ch);
|
||||
|
||||
// View switching
|
||||
function setView(view) {
|
||||
// Update buttons
|
||||
document.getElementById('btn-channels').className = view === 'channels'
|
||||
? 'px-4 py-2 rounded-lg bg-frost text-white font-medium transition'
|
||||
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||
document.getElementById('btn-roles').className = view === 'roles'
|
||||
? 'px-4 py-2 rounded-lg bg-fire text-white font-medium transition'
|
||||
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||
document.getElementById('btn-health').className = view === 'health'
|
||||
? 'px-4 py-2 rounded-lg bg-universal text-white font-medium transition'
|
||||
: 'px-4 py-2 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 font-medium transition';
|
||||
|
||||
// Show/hide panels
|
||||
document.getElementById('panel-channels').className = view === 'channels' ? 'lg:col-span-2' : 'hidden';
|
||||
document.getElementById('panel-roles').className = view === 'roles' || view === 'channels' ? 'lg:col-span-1' : 'hidden';
|
||||
document.getElementById('panel-health').className = view === 'health' ? 'lg:col-span-3' : 'hidden';
|
||||
|
||||
// Adjust for roles-only view
|
||||
if (view === 'roles') {
|
||||
document.getElementById('panel-roles').className = 'lg:col-span-3';
|
||||
}
|
||||
}
|
||||
|
||||
// Category toggle
|
||||
function toggleCategory(catId) {
|
||||
const content = document.getElementById('cat-' + catId);
|
||||
const arrow = document.getElementById('arrow-' + catId);
|
||||
if (content.classList.contains('hidden')) {
|
||||
content.classList.remove('hidden');
|
||||
arrow.style.transform = 'rotate(90deg)';
|
||||
} else {
|
||||
content.classList.add('hidden');
|
||||
arrow.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// Show channel details
|
||||
function showChannelDetails(channelId) {
|
||||
const channel = channelsById[channelId];
|
||||
if (!channel) return;
|
||||
|
||||
let html = `
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Channel</div>
|
||||
<div class="font-medium"># ${channel.name}</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
|
||||
<div>${channel.typeName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
|
||||
<div class="text-xs font-mono">${channel.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
${channel.topic ? `<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Topic</div>
|
||||
<div class="text-sm">${channel.topic}</div>
|
||||
</div>` : ''}
|
||||
`;
|
||||
|
||||
if (channel.permissionOverwrites.length > 0) {
|
||||
html += `<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Permission Overwrites</div>
|
||||
<div class="space-y-1">`;
|
||||
|
||||
channel.permissionOverwrites.forEach(p => {
|
||||
const role = rolesById[p.id];
|
||||
const name = role ? role.name : `User ${p.id.slice(-4)}`;
|
||||
const color = role ? (role.color === '#000000' ? '#6b7280' : role.color) : '#6b7280';
|
||||
const allow = p.allow !== '0' ? '✓ Allow' : '';
|
||||
const deny = p.deny !== '0' ? '✗ Deny' : '';
|
||||
|
||||
html += `<div class="flex items-center gap-2 text-sm px-2 py-1 rounded bg-gray-100 dark:bg-gray-800">
|
||||
<span class="w-2 h-2 rounded-full" style="background-color: ${color}"></span>
|
||||
<span class="flex-1">${name}</span>
|
||||
${allow ? `<span class="text-green-500 text-xs">${allow}</span>` : ''}
|
||||
${deny ? `<span class="text-red-500 text-xs">${deny}</span>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
|
||||
html += `</div></div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
document.getElementById('modal-title').textContent = '# ' + channel.name;
|
||||
document.getElementById('modal-content').innerHTML = html;
|
||||
document.getElementById('details-modal').classList.remove('hidden');
|
||||
document.getElementById('details-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
// Show role details
|
||||
function showRoleDetails(roleId) {
|
||||
const role = rolesById[roleId];
|
||||
if (!role) return;
|
||||
|
||||
// Find channels this role can access
|
||||
const accessibleChannels = discordData.allChannels.filter(ch => {
|
||||
// Check if role has explicit permission
|
||||
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
|
||||
if (overwrite) {
|
||||
return overwrite.allow !== '0' || overwrite.deny === '0';
|
||||
}
|
||||
// Check if @everyone is denied (and role doesn't have explicit access)
|
||||
const everyoneOverwrite = ch.permissionOverwrites.find(p => p.id === discordData.server.id);
|
||||
if (everyoneOverwrite && everyoneOverwrite.deny !== '0') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const color = role.color === '#000000' ? '#6b7280' : role.color;
|
||||
|
||||
let html = `
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="w-6 h-6 rounded-full" style="background-color: ${color}"></span>
|
||||
<div>
|
||||
<div class="font-medium">${role.name}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Position: ${role.position}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Members</div>
|
||||
<div class="text-xl font-bold">${role.memberCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Type</div>
|
||||
<div>${role.managed ? '🤖 Bot Managed' : '👥 Regular'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">ID</div>
|
||||
<div class="text-xs font-mono">${role.id}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mb-2">Explicit Channel Access (${accessibleChannels.filter(ch => ch.permissionOverwrites.some(p => p.id === roleId)).length})</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
`;
|
||||
|
||||
// Show channels with explicit overwrites for this role
|
||||
const explicitChannels = discordData.allChannels.filter(ch =>
|
||||
ch.permissionOverwrites.some(p => p.id === roleId)
|
||||
);
|
||||
|
||||
if (explicitChannels.length === 0) {
|
||||
html += '<span class="text-gray-500 text-sm">No explicit overwrites</span>';
|
||||
} else {
|
||||
explicitChannels.forEach(ch => {
|
||||
const overwrite = ch.permissionOverwrites.find(p => p.id === roleId);
|
||||
const isAllowed = overwrite && overwrite.allow !== '0';
|
||||
const isDenied = overwrite && overwrite.deny !== '0';
|
||||
|
||||
html += `<span class="text-xs px-2 py-1 rounded ${isAllowed ? 'bg-green-500/20 text-green-400' : isDenied ? 'bg-red-500/20 text-red-400' : 'bg-gray-500/20 text-gray-400'}">
|
||||
# ${ch.name}
|
||||
</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `</div></div></div>`;
|
||||
|
||||
document.getElementById('modal-title').innerHTML = `<span class="inline-block w-3 h-3 rounded-full mr-2" style="background-color: ${color}"></span>${role.name}`;
|
||||
document.getElementById('modal-content').innerHTML = html;
|
||||
document.getElementById('details-modal').classList.remove('hidden');
|
||||
document.getElementById('details-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
// Close modal
|
||||
function closeModal(event) {
|
||||
if (!event || event.target === document.getElementById('details-modal')) {
|
||||
document.getElementById('details-modal').classList.add('hidden');
|
||||
document.getElementById('details-modal').classList.remove('flex');
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||
const query = e.target.value.toLowerCase();
|
||||
|
||||
// Filter channels
|
||||
document.querySelectorAll('.channel-item').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
el.style.display = name.includes(query) ? '' : 'none';
|
||||
});
|
||||
|
||||
// Filter roles
|
||||
document.querySelectorAll('.role-item').forEach(el => {
|
||||
const name = el.dataset.name || '';
|
||||
el.style.display = name.includes(query) ? '' : 'none';
|
||||
});
|
||||
|
||||
// If searching, expand all categories
|
||||
if (query) {
|
||||
document.querySelectorAll('[id^="cat-"]').forEach(el => {
|
||||
el.classList.remove('hidden');
|
||||
});
|
||||
document.querySelectorAll('[id^="arrow-"]').forEach(el => {
|
||||
el.style.transform = 'rotate(90deg)';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcut to close modal
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
|
||||
// Expand all categories on load for better UX
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Keep categories collapsed by default - user can expand as needed
|
||||
});
|
||||
</script>
|
||||
|
||||
<% } %>
|
||||
@@ -9,6 +9,7 @@
|
||||
hx-target="#player-table-body">
|
||||
|
||||
<div class="space-x-2">
|
||||
<a href="/admin/players/export" class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-200 dark:hover:bg-gray-600 inline-block">📤 Export CSV</a>
|
||||
<button class="bg-gray-100 dark:bg-gray-700 px-4 py-2 rounded-md text-sm hover:bg-gray-200 dark:hover:bg-gray-600">📥 Import CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
150
services/arbiter-3.0/src/views/admin/scheduler.ejs
Normal file
150
services/arbiter-3.0/src/views/admin/scheduler.ejs
Normal file
@@ -0,0 +1,150 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-1">Manage staggered restart times for all servers</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button hx-post="/admin/scheduler/import-servers"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||
class="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded transition">
|
||||
↻ Import Servers
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Configuration Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<% configs.forEach(config => { %>
|
||||
<div class="bg-white dark:bg-darkcard rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold <%= config.node === 'TX1' ? 'text-fire' : 'text-frost' %>">
|
||||
<%= config.node %> Node
|
||||
</h2>
|
||||
<div class="flex gap-2">
|
||||
<button hx-get="/admin/scheduler/audit/<%= config.node %>"
|
||||
hx-target="#modal-container"
|
||||
class="bg-yellow-600 hover:bg-yellow-500 text-white px-3 py-1 rounded text-sm transition">
|
||||
Audit
|
||||
</button>
|
||||
<button hx-post="/admin/scheduler/sync/<%= config.node %>"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||
class="bg-green-600 hover:bg-green-500 text-white px-3 py-1 rounded text-sm transition">
|
||||
Sync All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/admin/scheduler/update-config" method="POST" class="flex gap-4 items-end">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<input type="hidden" name="node" value="<%= config.node %>">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Base Time (Central)</label>
|
||||
<input type="time" name="base_time" value="<%= config.base_time.substring(0,5) %>"
|
||||
class="w-full bg-gray-100 dark:bg-darkbg border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Interval</label>
|
||||
<input type="number" name="interval_minutes" value="<%= config.interval_minutes %>" min="1" max="30"
|
||||
class="w-full bg-gray-100 dark:bg-darkbg border border-gray-300 dark:border-gray-600 rounded px-3 py-2 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
<button type="submit" class="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded transition">
|
||||
Update
|
||||
</button>
|
||||
</form>
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Last updated: <%= config.updated_at ? new Date(config.updated_at).toLocaleString() : 'Never' %>
|
||||
by <%= config.updated_by || 'Unknown' %>
|
||||
</p>
|
||||
</div>
|
||||
<% }) %>
|
||||
</div>
|
||||
|
||||
<!-- Server Table -->
|
||||
<div id="scheduler-table" class="bg-white dark:bg-darkcard rounded overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-100 dark:bg-darkbg text-gray-600 dark:text-gray-300">
|
||||
<tr>
|
||||
<th class="p-3 w-10"></th>
|
||||
<th class="p-3">Server</th>
|
||||
<th class="p-3">Node</th>
|
||||
<th class="p-3">Restart Time (Central)</th>
|
||||
<th class="p-3">Status</th>
|
||||
<th class="p-3">Skip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sortable-servers">
|
||||
<% if (servers.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="6" class="p-6 text-center text-gray-500">
|
||||
No servers imported yet. Click "Import Servers" to populate from Pterodactyl.
|
||||
</td>
|
||||
</tr>
|
||||
<% } else { %>
|
||||
<% servers.forEach((server, i) => { %>
|
||||
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition" data-id="<%= server.server_id %>">
|
||||
<td class="p-3 cursor-grab text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
<span class="drag-handle text-lg">☰</span>
|
||||
</td>
|
||||
<td class="p-3 font-medium"><%= server.server_name %></td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-1 rounded text-xs font-bold <%= server.node === 'TX1' ? 'bg-fire/20 text-fire' : 'bg-frost/20 text-frost' %>">
|
||||
<%= server.node %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3 font-mono text-sm"><%= server.effective_time || 'Not set' %></td>
|
||||
<td class="p-3 text-sm">
|
||||
<% if (server.sync_status === 'SUCCESS') { %>
|
||||
<span class="text-green-500" title="Last synced: <%= server.last_synced_at %>">● Synced</span>
|
||||
<% } else if (server.sync_status === 'FAILED') { %>
|
||||
<span class="text-red-500" title="<%= server.last_error %>">✕ Error</span>
|
||||
<% } else { %>
|
||||
<span class="text-yellow-500">○ Pending</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<button hx-post="/admin/scheduler/toggle-skip/<%= server.server_id %>"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||
class="px-2 py-1 rounded text-xs <%= server.skip_restart ? 'bg-red-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300' %>">
|
||||
<%= server.skip_restart ? 'Skipped' : 'Active' %>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal Container -->
|
||||
<div id="modal-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- SortableJS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const tbody = document.getElementById('sortable-servers');
|
||||
|
||||
if(tbody) {
|
||||
new Sortable(tbody, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'bg-gray-600',
|
||||
|
||||
onEnd: function (evt) {
|
||||
const newOrder = Array.from(tbody.querySelectorAll('tr')).map(row => row.dataset.id);
|
||||
|
||||
fetch('/admin/scheduler/reorder-servers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orderedIds: newOrder })
|
||||
})
|
||||
.then(() => htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<div id="audit-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-70 backdrop-blur-sm">
|
||||
<div class="bg-white dark:bg-darkcard border <%= totalRogue > 0 ? 'border-red-500' : 'border-green-500' %> rounded-lg shadow-2xl w-full max-w-2xl p-6 relative">
|
||||
|
||||
<% if (totalRogue > 0) { %>
|
||||
<h2 class="text-2xl font-bold text-red-500 mb-2">⚠ Conflicts Detected</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
||||
Found <strong class="text-gray-900 dark:text-white"><%= totalRogue %></strong> rogue restart schedule(s) across
|
||||
<strong class="text-gray-900 dark:text-white"><%= serverCount %></strong> server(s) on <%= node %>.
|
||||
These must be removed before Trinity can take control.
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-100 dark:bg-darkbg rounded p-4 mb-6 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700">
|
||||
<ul class="space-y-3">
|
||||
<% results.forEach(result => { %>
|
||||
<li class="border-b border-gray-200 dark:border-gray-700 pb-2 last:border-0">
|
||||
<span class="text-fire font-semibold"><%= result.serverName %></span>
|
||||
<ul class="ml-4 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<% result.rogueSchedules.forEach(sched => { %>
|
||||
<li>- "<%= sched.name %>" (Cron: <%= sched.cron %>)</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<%
|
||||
const nukePayload = [];
|
||||
results.forEach(r => r.rogueSchedules.forEach(s => nukePayload.push({
|
||||
serverId: r.serverId, scheduleId: s.id, scheduleName: s.name
|
||||
})));
|
||||
%>
|
||||
|
||||
<form hx-post="/admin/scheduler/audit/nuke/<%= node %>" hx-target="#audit-modal" hx-swap="outerHTML">
|
||||
<input type="hidden" name="nukeData" value='<%- JSON.stringify(nukePayload) %>'>
|
||||
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||
class="px-4 py-2 text-gray-500 hover:text-gray-900 dark:hover:text-white transition">Cancel</button>
|
||||
<button type="submit"
|
||||
class="bg-red-600 hover:bg-red-500 text-white px-6 py-2 rounded font-bold transition">
|
||||
🔥 Nuke <%= totalRogue %> Schedules
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<% } else { %>
|
||||
<h2 class="text-2xl font-bold text-green-500 mb-2">✓ All Clear</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">No conflicts found on <%= node %>. Trinity is ready to take control.</p>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" onclick="document.getElementById('audit-modal').remove()"
|
||||
class="bg-green-600 hover:bg-green-500 text-white px-6 py-2 rounded transition">Close</button>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
53
services/arbiter-3.0/src/views/admin/scheduler/table.ejs
Normal file
53
services/arbiter-3.0/src/views/admin/scheduler/table.ejs
Normal file
@@ -0,0 +1,53 @@
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-100 dark:bg-darkbg text-gray-600 dark:text-gray-300">
|
||||
<tr>
|
||||
<th class="p-3 w-10"></th>
|
||||
<th class="p-3">Server</th>
|
||||
<th class="p-3">Node</th>
|
||||
<th class="p-3">Restart Time (UTC)</th>
|
||||
<th class="p-3">Status</th>
|
||||
<th class="p-3">Skip</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sortable-servers">
|
||||
<% if (servers.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="6" class="p-6 text-center text-gray-500">
|
||||
No servers imported yet. Click "Import Servers" to populate from Pterodactyl.
|
||||
</td>
|
||||
</tr>
|
||||
<% } else { %>
|
||||
<% servers.forEach((server, i) => { %>
|
||||
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition" data-id="<%= server.server_id %>">
|
||||
<td class="p-3 cursor-grab text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
<span class="drag-handle text-lg">☰</span>
|
||||
</td>
|
||||
<td class="p-3 font-medium"><%= server.server_name %></td>
|
||||
<td class="p-3">
|
||||
<span class="px-2 py-1 rounded text-xs font-bold <%= server.node === 'TX1' ? 'bg-fire/20 text-fire' : 'bg-frost/20 text-frost' %>">
|
||||
<%= server.node %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-3 font-mono text-sm"><%= server.effective_time || 'Not set' %></td>
|
||||
<td class="p-3 text-sm">
|
||||
<% if (server.sync_status === 'SUCCESS') { %>
|
||||
<span class="text-green-500" title="Last synced: <%= server.last_synced_at %>">● Synced</span>
|
||||
<% } else if (server.sync_status === 'FAILED') { %>
|
||||
<span class="text-red-500" title="<%= server.last_error %>">✕ Error</span>
|
||||
<% } else { %>
|
||||
<span class="text-yellow-500">○ Pending</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="p-3">
|
||||
<button hx-post="/admin/scheduler/toggle-skip/<%= server.server_id %>"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/admin/scheduler/table-only', '#scheduler-table')"
|
||||
class="px-2 py-1 rounded text-xs <%= server.skip_restart ? 'bg-red-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300' %>">
|
||||
<%= server.skip_restart ? 'Skipped' : 'Active' %>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -7,6 +7,7 @@
|
||||
<% txServers.forEach(server => {
|
||||
const isOnline = server.log.is_online;
|
||||
const hasError = !!server.log.last_error;
|
||||
const discordComplete = server.discord?.complete;
|
||||
let borderClass = 'border-gray-200 dark:border-gray-700';
|
||||
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
||||
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
|
||||
@@ -38,6 +39,19 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Discord Channel Status -->
|
||||
<div class="mb-4">
|
||||
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
|
||||
<% if (discordComplete) { %>
|
||||
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels</span>
|
||||
<% } else if (server.discord?.missing?.length > 0) { %>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
|
||||
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (hasError) { %>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
||||
<strong>Error:</strong> <%= server.log.last_error %>
|
||||
@@ -64,6 +78,7 @@
|
||||
<% ncServers.forEach(server => {
|
||||
const isOnline = server.log.is_online;
|
||||
const hasError = !!server.log.last_error;
|
||||
const discordComplete = server.discord?.complete;
|
||||
let borderClass = 'border-gray-200 dark:border-gray-700';
|
||||
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
||||
if (hasError) borderClass = 'border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.2)]';
|
||||
@@ -95,6 +110,19 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Discord Channel Status -->
|
||||
<div class="mb-4">
|
||||
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
|
||||
<% if (discordComplete) { %>
|
||||
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels</span>
|
||||
<% } else if (server.discord?.missing?.length > 0) { %>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
|
||||
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if (hasError) { %>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
||||
<strong>Error:</strong> <%= server.log.last_error %>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%
|
||||
const isOnline = server.log.is_online;
|
||||
const hasError = !!server.log.last_error;
|
||||
const discordComplete = server.discord?.complete;
|
||||
|
||||
let borderClass = 'border-gray-200 dark:border-gray-700'; // Default / Offline
|
||||
if (isOnline && !hasError) borderClass = 'border-green-500 shadow-[0_0_10px_rgba(34,197,94,0.2)]';
|
||||
@@ -37,6 +38,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discord Channel Status -->
|
||||
<div class="mb-4">
|
||||
<span class="text-gray-500 dark:text-gray-400 block text-xs mb-1">Discord Channels</span>
|
||||
<% if (discordComplete) { %>
|
||||
<span class="text-green-600 dark:text-green-400 text-sm font-medium">✅ All 4 channels configured</span>
|
||||
<% } else if (server.discord?.missing?.length > 0) { %>
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400 p-2 rounded text-xs">
|
||||
<strong>Missing:</strong> <%= server.discord.missing.join(', ') %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm">Unable to check</span>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (hasError) { %>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-2 rounded text-xs mb-4 break-words">
|
||||
<strong>Error:</strong> <%= server.log.last_error %>
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">Real-time status and whitelist controls</p>
|
||||
</div>
|
||||
<div class="space-x-3">
|
||||
<button class="bg-fire hover:bg-orange-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
||||
<button hx-post="/admin/servers/sync-all/tx1"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#sync-result"
|
||||
class="bg-fire hover:bg-orange-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
||||
🔥 Sync All Dallas
|
||||
</button>
|
||||
<button class="bg-frost hover:bg-cyan-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
||||
<button hx-post="/admin/servers/sync-all/nc1"
|
||||
hx-swap="innerHTML"
|
||||
hx-target="#sync-result"
|
||||
class="bg-frost hover:bg-cyan-600 text-white px-4 py-2 rounded-md text-sm font-medium shadow transition-colors">
|
||||
❄️ Sync All Charlotte
|
||||
</button>
|
||||
<span id="sync-result" class="text-sm ml-2"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -63,7 +63,10 @@
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<aside id="sidebar" class="w-64 bg-white dark:bg-darkcard border-r border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
<div class="p-6 flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-fire via-universal to-frost text-transparent bg-clip-text">Trinity Console</h1>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-fire via-universal to-frost text-transparent bg-clip-text">Trinity Console</h1>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">v1.0</span>
|
||||
</div>
|
||||
<!-- Mobile close button -->
|
||||
<button onclick="document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('open');" class="md:hidden text-2xl">✕</button>
|
||||
</div>
|
||||
@@ -89,13 +92,128 @@
|
||||
<a href="/admin/roles" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/roles') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
🔍 Role Audit
|
||||
</a>
|
||||
<a href="/admin/scheduler" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/scheduler') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
⏰ Restart Scheduler
|
||||
</a>
|
||||
<a href="/admin/discord" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/discord') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
|
||||
💬 Discord
|
||||
</a>
|
||||
</nav>
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="https://cdn.discordapp.com/avatars/<%= adminUser.id %>/<%= adminUser.avatar %>.png" class="w-10 h-10 rounded-full">
|
||||
<span class="font-medium"><%= adminUser.username %></span>
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<!-- Deploy Button -->
|
||||
<button
|
||||
id="deploy-btn"
|
||||
onclick="deployArbiter()"
|
||||
class="w-full px-4 py-2 bg-gradient-to-r from-fire to-frost text-white font-medium rounded-md hover:opacity-90 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<span id="deploy-icon">🚀</span>
|
||||
<span id="deploy-text">Deploy Arbiter</span>
|
||||
</button>
|
||||
<div id="deploy-result" class="text-xs text-center hidden"></div>
|
||||
|
||||
<!-- User Info -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="https://cdn.discordapp.com/avatars/<%= adminUser.id %>/<%= adminUser.avatar %>.png" class="w-10 h-10 rounded-full">
|
||||
<span class="font-medium"><%= adminUser.username %></span>
|
||||
</div>
|
||||
<a href="/auth/logout" class="text-gray-400 hover:text-red-500 transition" title="Logout">
|
||||
🚪
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function deployArbiter() {
|
||||
const btn = document.getElementById('deploy-btn');
|
||||
const icon = document.getElementById('deploy-icon');
|
||||
const text = document.getElementById('deploy-text');
|
||||
const result = document.getElementById('deploy-result');
|
||||
|
||||
// Disable button, show loading state
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
icon.textContent = '⏳';
|
||||
text.textContent = 'Deploying...';
|
||||
result.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/system/deploy', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'CSRF-Token': '<%= csrfToken %>'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Deploy triggered, now wait for restart and check health
|
||||
icon.textContent = '🔄';
|
||||
text.textContent = 'Restarting...';
|
||||
result.textContent = 'Waiting for Arbiter to come back online...';
|
||||
result.classList.remove('hidden', 'text-red-500');
|
||||
result.classList.add('text-yellow-500');
|
||||
|
||||
// Wait 4 seconds for restart, then check health
|
||||
await new Promise(resolve => setTimeout(resolve, 4000));
|
||||
|
||||
// Poll for health (up to 3 attempts)
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
const healthRes = await fetch('/admin/system/status', {
|
||||
headers: { 'CSRF-Token': '<%= csrfToken %>' }
|
||||
});
|
||||
const healthData = await healthRes.json();
|
||||
if (healthData.arbiter === 'running') {
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Server still restarting, wait and retry
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (healthy) {
|
||||
icon.textContent = '✅';
|
||||
text.textContent = 'Deployed!';
|
||||
result.textContent = 'Arbiter restarted successfully';
|
||||
result.classList.remove('text-yellow-500', 'text-red-500');
|
||||
result.classList.add('text-green-500');
|
||||
} else {
|
||||
icon.textContent = '⚠️';
|
||||
text.textContent = 'Check Status';
|
||||
result.textContent = 'Deploy triggered but could not confirm restart. Check logs.';
|
||||
result.classList.remove('text-yellow-500', 'text-green-500');
|
||||
result.classList.add('text-red-500');
|
||||
}
|
||||
} else {
|
||||
icon.textContent = '❌';
|
||||
text.textContent = 'Deploy Failed';
|
||||
result.textContent = data.log || data.message;
|
||||
result.classList.remove('hidden', 'text-green-500');
|
||||
result.classList.add('text-red-500');
|
||||
}
|
||||
} catch (error) {
|
||||
icon.textContent = '❌';
|
||||
text.textContent = 'Deploy Failed';
|
||||
result.textContent = error.message;
|
||||
result.classList.remove('hidden', 'text-green-500');
|
||||
result.classList.add('text-red-500');
|
||||
}
|
||||
|
||||
// Re-enable button after 3 seconds
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
icon.textContent = '🚀';
|
||||
text.textContent = 'Deploy Arbiter';
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
</aside>
|
||||
|
||||
<main class="flex-1 flex flex-col overflow-hidden">
|
||||
@@ -109,9 +227,6 @@
|
||||
<button onclick="document.documentElement.classList.toggle('dark')" class="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
🌙/☀️
|
||||
</button>
|
||||
<span class="relative">
|
||||
🔔 <span class="absolute -top-1 -right-1 bg-fire text-white text-xs rounded-full h-4 w-4 flex items-center justify-center">0</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to ModpackChecker will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.0] - 2026-04-06
|
||||
|
||||
### Added
|
||||
- Initial release
|
||||
- Dashboard badge showing update status (🟠 update available / 🟢 up to date)
|
||||
- Console widget with "Check for Updates" button
|
||||
- Support for 4 modpack platforms:
|
||||
- CurseForge (requires API key)
|
||||
- Modrinth (no key required)
|
||||
- FTB via modpacks.ch (no key required)
|
||||
- Technic (no key required, dynamic build detection)
|
||||
- Admin panel for CurseForge API key configuration
|
||||
- Cron command for automated background checks
|
||||
- Rate limiting: 2 manual checks per minute per server
|
||||
- 60-second TTL cache for dashboard badges
|
||||
- Foreign key cascade delete for data integrity
|
||||
|
||||
### Architecture
|
||||
- Centralized `ModpackApiService` for all platform API calls
|
||||
- Cached Technic launcher build number (12-hour TTL)
|
||||
- Database table `modpackchecker_servers` for status caching
|
||||
|
||||
---
|
||||
|
||||
*Fire + Frost + Foundation = Where Love Builds Legacy* 🔥❄️💙
|
||||
308
services/modpack-version-checker/blueprint-extension/README.md
Normal file
308
services/modpack-version-checker/blueprint-extension/README.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# ModpackChecker — Pterodactyl Blueprint Extension
|
||||
|
||||
**Version:** 1.0.0
|
||||
**Author:** Firefrost Gaming / Frostystyle
|
||||
**License:** Commercial License - Unauthorized redistribution, resale, or sharing of this source code is strictly prohibited.
|
||||
|
||||
A Pterodactyl Panel extension that checks modpack versions across CurseForge, Modrinth, FTB, and Technic platforms. Shows update status on the dashboard and provides manual version checks from the server console.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Features](#features)
|
||||
2. [Architecture Overview](#architecture-overview)
|
||||
3. [File Structure](#file-structure)
|
||||
4. [Installation](#installation)
|
||||
5. [Configuration](#configuration)
|
||||
6. [Usage](#usage)
|
||||
7. [Development](#development)
|
||||
8. [API Reference](#api-reference)
|
||||
9. [Troubleshooting](#troubleshooting)
|
||||
10. [Support](#support)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Dashboard Badge
|
||||
- Shows a colored dot next to each server name on the dashboard
|
||||
- **🟠 Orange (Fire #FF6B35):** Update available
|
||||
- **🟢 Teal (Frost #4ECDC4):** Up to date
|
||||
- Hover for version details tooltip
|
||||
- Single API call per page load (cached with 60s TTL)
|
||||
|
||||
### Console Widget
|
||||
- "Check for Updates" button on each server's console page
|
||||
- Real-time version check against platform API
|
||||
- Rate limited: 2 checks per minute per server
|
||||
- Shows modpack name and latest version
|
||||
|
||||
### Admin Panel
|
||||
- Configure CurseForge API key
|
||||
- View supported platforms
|
||||
- PRO features: Discord notifications, custom check intervals
|
||||
|
||||
### Supported Platforms
|
||||
| Platform | ID Type | API Key Required | Status |
|
||||
|----------|---------|------------------|--------|
|
||||
| CurseForge | Numeric project ID | ✅ Yes | ✅ Working |
|
||||
| Modrinth | Project ID or slug | ❌ No | ✅ Working |
|
||||
| FTB | Numeric modpack ID | ❌ No | ✅ Working |
|
||||
| Technic | URL slug | ❌ No | ✅ Working |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ MODPACK VERSION CHECKER │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────┐
|
||||
│ CRON JOB (runs every 4-6 hrs) │
|
||||
│ php artisan modpackchecker:check │
|
||||
└──────────────────┬──────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ DATABASE CACHE │
|
||||
│ modpackchecker_servers table │
|
||||
│ │
|
||||
│ • server_uuid │
|
||||
│ • platform, modpack_id │
|
||||
│ • current_version, latest_version │
|
||||
│ • status (string) │
|
||||
│ • last_checked timestamp │
|
||||
└──────────────────┬──────────────────┘
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────────────────────────┐ ┌───────────────────────────┐
|
||||
│ DASHBOARD BADGE │ │ CONSOLE WIDGET │
|
||||
│ (UpdateBadge.tsx) │ │ (wrapper.tsx) │
|
||||
│ │ │ │
|
||||
│ • Reads from cache ONLY │ │ • Manual "Check" button │
|
||||
│ • 60-second TTL │ │ • LIVE API call │
|
||||
│ • Shows 🟠 or 🟢 dot │ │ • Rate limited (2/min) │
|
||||
└───────────────────────────┘ └───────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
blueprint-extension/
|
||||
├── README.md
|
||||
├── CHANGELOG.md
|
||||
├── conf.yml
|
||||
├── build.sh
|
||||
├── icon.png
|
||||
│
|
||||
├── app/
|
||||
│ ├── Console/Commands/
|
||||
│ │ └── CheckModpackUpdates.php
|
||||
│ ├── Http/Controllers/
|
||||
│ │ └── ModpackAPIController.php
|
||||
│ └── Services/
|
||||
│ └── ModpackApiService.php
|
||||
│
|
||||
├── admin/
|
||||
│ ├── controller.php
|
||||
│ └── view.blade.php
|
||||
│
|
||||
├── database/migrations/
|
||||
│ └── 2026_04_06_000000_create_modpackchecker_servers_table.php
|
||||
│
|
||||
├── routes/
|
||||
│ └── client.php
|
||||
│
|
||||
└── views/
|
||||
├── server/wrapper.tsx
|
||||
└── dashboard/UpdateBadge.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Pterodactyl Panel v1.11+
|
||||
- Blueprint Framework (beta-2026-01 or newer)
|
||||
- PHP 8.1+
|
||||
- Node.js 18+
|
||||
- Yarn package manager
|
||||
|
||||
### Standard Installation (BuiltByBit)
|
||||
|
||||
1. Upload the downloaded `modpackchecker.blueprint` file to your Pterodactyl panel's root directory (usually `/var/www/pterodactyl`).
|
||||
|
||||
2. Run the Blueprint installation command:
|
||||
```bash
|
||||
blueprint -install modpackchecker
|
||||
```
|
||||
|
||||
3. The framework will automatically inject the frontend components and rebuild the panel assets.
|
||||
|
||||
4. Set up the cron job for automated checks:
|
||||
```bash
|
||||
# Add to crontab
|
||||
0 */6 * * * www-data cd /var/www/pterodactyl && php artisan modpackchecker:check >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
### Developer/Manual Installation
|
||||
|
||||
If installing from raw source:
|
||||
```bash
|
||||
cp -r blueprint-extension /var/www/pterodactyl/.blueprint/extensions/modpackchecker
|
||||
chown -R www-data:www-data /var/www/pterodactyl/.blueprint/extensions/modpackchecker
|
||||
blueprint -build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Server Egg Variables
|
||||
|
||||
For modpack detection, set these variables in your server's egg:
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `MODPACK_PLATFORM` | Platform name | `modrinth`, `curseforge`, `ftb`, `technic` |
|
||||
| `MODPACK_ID` | Platform-specific ID | `adrenaserver` (Modrinth slug) |
|
||||
| `MODPACK_CURRENT_VERSION` | Installed version | `1.7.0` |
|
||||
|
||||
### CurseForge API Key
|
||||
|
||||
CurseForge requires an API key:
|
||||
|
||||
1. Apply for API access at https://console.curseforge.com/
|
||||
2. Go to **Admin Panel → Extensions → ModpackChecker**
|
||||
3. Enter your API key and save
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Dashboard Badge
|
||||
Badges appear automatically for servers that:
|
||||
- Have `MODPACK_PLATFORM` egg variable set
|
||||
- Have been checked by the cron job at least once
|
||||
|
||||
### Manual Check
|
||||
1. Go to any server's console page
|
||||
2. Click "Check for Updates" button
|
||||
3. View results showing modpack name and version status
|
||||
|
||||
### Cron Command
|
||||
Run manually for testing:
|
||||
```bash
|
||||
php artisan modpackchecker:check
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Adding a New Platform
|
||||
|
||||
1. Open `app/Services/ModpackApiService.php`
|
||||
2. Add your new platform check method (e.g., `private function checkNewPlatform(string $id): array`)
|
||||
3. Add the platform key to the `match()` statement inside the `fetchLatestVersion()` method
|
||||
4. The Controller and Cron Command will automatically inherit the new logic
|
||||
5. Update this README to reflect the newly supported platform
|
||||
|
||||
### Testing API Endpoints
|
||||
```bash
|
||||
# Manual check (requires auth token)
|
||||
curl -X POST "https://panel.example.com/api/client/extensions/modpackchecker/servers/{uuid}/check" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
|
||||
# Get all statuses
|
||||
curl "https://panel.example.com/api/client/extensions/modpackchecker/status" \
|
||||
-H "Authorization: Bearer {token}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### POST `/api/client/extensions/modpackchecker/servers/{server}/check`
|
||||
|
||||
Manual version check for a specific server. Triggers a live API call to the modpack platform.
|
||||
|
||||
**Rate Limit:** 2 requests per minute per server
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"platform": "modrinth",
|
||||
"modpack_id": "adrenaserver",
|
||||
"modpack_name": "Adrenaserver",
|
||||
"latest_version": "1.7.0+1.21.1.fabric",
|
||||
"status": "checked"
|
||||
}
|
||||
```
|
||||
|
||||
### GET `/api/client/extensions/modpackchecker/status`
|
||||
|
||||
Get cached status for all servers accessible to the authenticated user.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"a1b2c3d4-...": {
|
||||
"update_available": true,
|
||||
"modpack_name": "All The Mods 9",
|
||||
"current_version": "0.2.51",
|
||||
"latest_version": "0.2.60"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Badge not showing
|
||||
1. Check server has `MODPACK_PLATFORM` variable set
|
||||
2. Run cron command manually: `php artisan modpackchecker:check`
|
||||
3. Check `modpackchecker_servers` table for entries
|
||||
|
||||
### "CurseForge API key not configured"
|
||||
1. Go to Admin → Extensions → ModpackChecker
|
||||
2. Enter your CurseForge API key
|
||||
3. Key must have mod read permissions
|
||||
|
||||
### 500 errors on check
|
||||
1. Check PHP error log: `tail -f /var/log/php8.3-fpm.log`
|
||||
2. Verify controller namespace: `Pterodactyl\Http\Controllers`
|
||||
3. Restart PHP-FPM: `systemctl restart php8.3-fpm`
|
||||
|
||||
### "Rate limit reached" message
|
||||
The manual check is limited to 2 requests per minute per server. Wait 60 seconds and try again.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
**Need help?** Join our Discord for support:
|
||||
- **Discord:** [discord.firefrostgaming.com](https://discord.firefrostgaming.com)
|
||||
- **Email:** dev@firefrostgaming.com
|
||||
- **Website:** [firefrostgaming.com](https://firefrostgaming.com)
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
|
||||
**Developed by:** Firefrost Gaming / Frostystyle
|
||||
**Architecture Review:** Gemini AI
|
||||
|
||||
**Part of Firefrost Gaming**
|
||||
*Fire + Frost + Foundation = Where Love Builds Legacy* 🔥❄️💙
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Controllers\Admin\Extensions\modpackchecker;
|
||||
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\View\Factory as ViewFactory;
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\BlueprintFramework\Libraries\ExtensionLibrary\Admin\BlueprintAdminLibrary as BlueprintExtensionLibrary;
|
||||
use Pterodactyl\Http\Requests\Admin\AdminFormRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class modpackcheckerExtensionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ViewFactory $view,
|
||||
private BlueprintExtensionLibrary $blueprint,
|
||||
) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
// Get current settings
|
||||
$curseforge_api_key = $this->blueprint->dbGet('modpackchecker', 'curseforge_api_key');
|
||||
$discord_webhook_url = $this->blueprint->dbGet('modpackchecker', 'discord_webhook_url');
|
||||
$check_interval = $this->blueprint->dbGet('modpackchecker', 'check_interval');
|
||||
$tier = $this->blueprint->dbGet('modpackchecker', 'tier');
|
||||
|
||||
// Set defaults if empty
|
||||
if ($check_interval == '') {
|
||||
$this->blueprint->dbSet('modpackchecker', 'check_interval', 'daily');
|
||||
$check_interval = 'daily';
|
||||
}
|
||||
if ($tier == '') {
|
||||
$this->blueprint->dbSet('modpackchecker', 'tier', 'standard');
|
||||
$tier = 'standard';
|
||||
}
|
||||
|
||||
return $this->view->make(
|
||||
'admin.extensions.modpackchecker.index', [
|
||||
'curseforge_api_key' => $curseforge_api_key,
|
||||
'discord_webhook_url' => $discord_webhook_url,
|
||||
'check_interval' => $check_interval,
|
||||
'tier' => $tier,
|
||||
'root' => '/admin/extensions/modpackchecker',
|
||||
'blueprint' => $this->blueprint,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
|
||||
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
|
||||
*/
|
||||
public function update(modpackcheckerSettingsFormRequest $request): RedirectResponse
|
||||
{
|
||||
$this->blueprint->dbSet('modpackchecker', 'curseforge_api_key', $request->input('curseforge_api_key') ?? '');
|
||||
$this->blueprint->dbSet('modpackchecker', 'discord_webhook_url', $request->input('discord_webhook_url') ?? '');
|
||||
$this->blueprint->dbSet('modpackchecker', 'check_interval', $request->input('check_interval') ?? 'daily');
|
||||
|
||||
return redirect()->route('admin.extensions.modpackchecker.index')->with('success', 'Settings saved successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
class modpackcheckerSettingsFormRequest extends AdminFormRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'curseforge_api_key' => 'nullable|string|max:500',
|
||||
'discord_webhook_url' => 'nullable|url|max:500',
|
||||
'check_interval' => 'nullable|in:daily,12h,6h',
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'curseforge_api_key' => 'CurseForge API Key',
|
||||
'discord_webhook_url' => 'Discord Webhook URL',
|
||||
'check_interval' => 'Check Interval',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<form id="config-form" action="" method="POST">
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
showSaveButton();
|
||||
});
|
||||
|
||||
function showSaveButton() {
|
||||
const configForm = document.getElementById("config-form");
|
||||
const saveOverlay = document.getElementById("save-overlay");
|
||||
|
||||
configForm.addEventListener("change", function() {
|
||||
saveOverlay.style.display = "inline";
|
||||
setTimeout(() => {
|
||||
saveOverlay.style.bottom = "10px";
|
||||
}, 100);
|
||||
});
|
||||
|
||||
configForm.addEventListener("input", function() {
|
||||
saveOverlay.style.display = "inline";
|
||||
setTimeout(() => {
|
||||
saveOverlay.style.bottom = "10px";
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Save button overlay -->
|
||||
<div id="save-overlay">
|
||||
{{ csrf_field() }}
|
||||
<button type="submit" name="_method" value="PATCH" class="btn btn-primary btn-sm">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
<style>
|
||||
#save-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
transition: bottom 0.3s;
|
||||
bottom: -200px;
|
||||
right: 20px;
|
||||
z-index: 500;
|
||||
padding: 15px;
|
||||
background: #1a1a2e;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="row" style="margin-bottom: 20px;">
|
||||
<div class="col-xs-12">
|
||||
<div style="display: flex; align-items: center; gap: 15px;">
|
||||
<div style="width: 50px; height: 50px; background: linear-gradient(135deg, #FF6B35, #4ECDC4); border-radius: 10px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fa fa-cube" style="font-size: 24px; color: white;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 style="margin: 0; color: #fff;">ModpackChecker</h2>
|
||||
<p style="margin: 0; color: #888;">4-Platform Modpack Version Monitoring</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- CurseForge API Key -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-primary">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-key"></i> CurseForge API Key
|
||||
</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">API Key (BYOK)</label>
|
||||
<input
|
||||
type="text"
|
||||
name="curseforge_api_key"
|
||||
id="curseforge_api_key"
|
||||
value="{{ $curseforge_api_key }}"
|
||||
placeholder="$2a$10$..."
|
||||
class="form-control"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<p class="text-muted small" style="margin-top: 8px;">
|
||||
Get your free API key from
|
||||
<a href="https://console.curseforge.com/" target="_blank">console.curseforge.com</a>.
|
||||
Required for CurseForge modpack detection.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check Interval (PRO TIER) -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-info">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-clock-o"></i> Check Interval
|
||||
<span class="label label-warning" style="margin-left: 10px;">PRO TIER</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Automatic Check Frequency</label>
|
||||
<select class="form-control" name="check_interval" id="check_interval" disabled>
|
||||
<option value="daily" selected>Daily (24 Hours)</option>
|
||||
<option value="12h">Every 12 Hours</option>
|
||||
<option value="6h">Every 6 Hours</option>
|
||||
</select>
|
||||
<p class="text-muted small" style="margin-top: 8px;">
|
||||
Standard tier is locked to daily cron checks.
|
||||
Upgrade to Professional for more frequent automated checks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Discord Webhook (PRO TIER) -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-success">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-bell"></i> Discord Notifications
|
||||
<span class="label label-warning" style="margin-left: 10px;">PRO TIER</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
name="discord_webhook_url"
|
||||
id="discord_webhook_url"
|
||||
value="{{ $discord_webhook_url }}"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
class="form-control"
|
||||
disabled
|
||||
/>
|
||||
<p class="text-muted small" style="margin-top: 8px;">
|
||||
Upgrade to Professional to receive automated update alerts in your Discord server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supported Platforms -->
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<div class="box box-default">
|
||||
<div class="box-header with-border">
|
||||
<h3 class="box-title">
|
||||
<i class="fa fa-check-circle"></i> Supported Platforms
|
||||
</h3>
|
||||
</div>
|
||||
<div class="box-body">
|
||||
<ul style="list-style: none; padding: 0; margin: 0;">
|
||||
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||
<i class="fa fa-fire" style="color: #f16436; width: 20px;"></i>
|
||||
<strong>CurseForge</strong>
|
||||
<span class="text-muted small">(Requires API Key)</span>
|
||||
</li>
|
||||
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||
<i class="fa fa-leaf" style="color: #1bd96a; width: 20px;"></i>
|
||||
<strong>Modrinth</strong>
|
||||
<span class="text-muted small">(No key required)</span>
|
||||
</li>
|
||||
<li style="padding: 8px 0; border-bottom: 1px solid #333;">
|
||||
<i class="fa fa-cogs" style="color: #4a90d9; width: 20px;"></i>
|
||||
<strong>Technic</strong>
|
||||
<span class="text-muted small">(No key required)</span>
|
||||
</li>
|
||||
<li style="padding: 8px 0;">
|
||||
<i class="fa fa-cube" style="color: #e04e39; width: 20px;"></i>
|
||||
<strong>FTB (modpacks.ch)</strong>
|
||||
<span class="text-muted small">(No key required)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div style="background: #1a1a2e; border-left: 4px solid #4ECDC4; border-radius: 4px; padding: 15px; margin-bottom: 15px;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #fff;"><i class="fa fa-info-circle" style="color: #4ECDC4;"></i> How It Works</h4>
|
||||
<p style="margin-bottom: 0; color: #ccc;">
|
||||
ModpackChecker automatically detects modpacks via Egg Variables or file fingerprinting.
|
||||
Set <code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">MODPACK_PLATFORM</code> and
|
||||
<code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">MODPACK_ID</code> in your server's startup variables
|
||||
for the most reliable detection, or let the extension scan for
|
||||
<code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">manifest.json</code> /
|
||||
<code style="background: #2a2a3e; padding: 2px 6px; border-radius: 3px;">modrinth.index.json</code> files.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support -->
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<div style="background: #1a1a2e; border-left: 4px solid #FF6B35; border-radius: 4px; padding: 15px;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #fff;"><i class="fa fa-life-ring" style="color: #FF6B35;"></i> Need Help?</h4>
|
||||
<p style="margin-bottom: 0; color: #ccc;">
|
||||
Join our Discord for support: <a href="https://firefrostgaming.com/discord" target="_blank" style="color: #4ECDC4;">firefrostgaming.com/discord</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* MODPACK VERSION CHECKER - CRON COMMAND
|
||||
* =============================================================================
|
||||
*
|
||||
* Laravel Artisan command that checks all servers for modpack updates.
|
||||
* This is the "brain" that populates the cache used by the dashboard badges.
|
||||
*
|
||||
* USAGE:
|
||||
* php artisan modpackchecker:check
|
||||
*
|
||||
* RECOMMENDED CRON SCHEDULE:
|
||||
* # Check for updates every 6 hours (adjust based on your server count)
|
||||
* 0 */6 * * * cd /var/www/pterodactyl && php artisan modpackchecker:check >> /dev/null 2>&1
|
||||
*
|
||||
* HOW IT WORKS:
|
||||
* 1. Finds all servers with MODPACK_PLATFORM egg variable set
|
||||
* 2. Loops through each server, checking via ModpackApiService
|
||||
* 3. Stores results in modpackchecker_servers database table
|
||||
* 4. Dashboard badges read from this table (never calling APIs directly)
|
||||
*
|
||||
* RATE LIMITING:
|
||||
* Each API call is followed by a 2-second sleep to avoid rate limits.
|
||||
* For 50 servers, a full check takes ~2 minutes.
|
||||
*
|
||||
* @package Pterodactyl\Console\Commands
|
||||
* @author Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>
|
||||
* @version 1.0.0
|
||||
* @see ModpackApiService.php (centralized API logic)
|
||||
* @see ModpackAPIController.php (provides getStatus endpoint for badges)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
namespace Pterodactyl\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Services\ModpackApiService;
|
||||
|
||||
class CheckModpackUpdates extends Command
|
||||
{
|
||||
protected $signature = 'modpackchecker:check';
|
||||
protected $description = 'Check all servers for modpack updates';
|
||||
|
||||
public function __construct(private ModpackApiService $apiService)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int Exit code (0 = success)
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting modpack update check...');
|
||||
|
||||
// Get all servers that have modpack variables set
|
||||
$servers = Server::whereHas('variables', function ($q) {
|
||||
$q->where('env_variable', 'MODPACK_PLATFORM');
|
||||
})->get();
|
||||
|
||||
$this->info("Found {$servers->count()} servers with modpack configuration");
|
||||
|
||||
foreach ($servers as $server) {
|
||||
$this->checkServer($server);
|
||||
// Rate limiting - sleep between checks
|
||||
sleep(2);
|
||||
}
|
||||
|
||||
$this->info('Modpack update check complete!');
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a single server for modpack updates.
|
||||
*
|
||||
* @param Server $server The server to check
|
||||
* @return void
|
||||
*/
|
||||
private function checkServer(Server $server): void
|
||||
{
|
||||
$this->line("Checking: {$server->name} ({$server->uuid})");
|
||||
|
||||
try {
|
||||
$platform = $this->getVariable($server, 'MODPACK_PLATFORM');
|
||||
$modpackId = $this->getVariable($server, 'MODPACK_ID');
|
||||
|
||||
if (!$platform || !$modpackId) {
|
||||
$this->warn(" Skipping - missing platform or modpack ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Centralized API Call via Service
|
||||
$latestData = $this->apiService->fetchLatestVersion($platform, $modpackId);
|
||||
$currentVersion = $this->getVariable($server, 'MODPACK_CURRENT_VERSION');
|
||||
$updateAvailable = $currentVersion && $currentVersion !== $latestData['version'];
|
||||
|
||||
$this->updateDatabase($server, [
|
||||
'platform' => $platform,
|
||||
'modpack_id' => $modpackId,
|
||||
'modpack_name' => $latestData['name'],
|
||||
'current_version' => $currentVersion,
|
||||
'latest_version' => $latestData['version'],
|
||||
'status' => $updateAvailable ? 'update_available' : 'up_to_date',
|
||||
'error_message' => null,
|
||||
'last_checked' => now(),
|
||||
]);
|
||||
|
||||
$statusIcon = $updateAvailable ? '🟠 UPDATE AVAILABLE' : '🟢 Up to date';
|
||||
$this->info(" {$statusIcon}: {$latestData['name']} - {$latestData['version']}");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" Error: {$e->getMessage()}");
|
||||
$this->updateDatabase($server, [
|
||||
'status' => 'error',
|
||||
'error_message' => $e->getMessage(),
|
||||
'last_checked' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an egg variable value from a server.
|
||||
*
|
||||
* @param Server $server The server to query
|
||||
* @param string $name The variable name
|
||||
* @return string|null The variable value, or null if not set
|
||||
*/
|
||||
private function getVariable(Server $server, string $name): ?string
|
||||
{
|
||||
$variable = $server->variables()
|
||||
->where('env_variable', $name)
|
||||
->first();
|
||||
return $variable?->server_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store or update the modpack check results in the database.
|
||||
*
|
||||
* Uses updateOrInsert for upsert behavior.
|
||||
* The server_uuid column is the unique key for matching.
|
||||
*
|
||||
* @param Server $server The server being checked
|
||||
* @param array $data The data to store
|
||||
* @return void
|
||||
*/
|
||||
private function updateDatabase(Server $server, array $data): void
|
||||
{
|
||||
DB::table('modpackchecker_servers')->updateOrInsert(
|
||||
['server_uuid' => $server->uuid],
|
||||
array_merge($data, ['updated_at' => now()])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* MODPACK VERSION CHECKER - API CONTROLLER
|
||||
* =============================================================================
|
||||
*
|
||||
* Part of the ModpackChecker Blueprint extension for Pterodactyl Panel.
|
||||
*
|
||||
* PURPOSE:
|
||||
* Provides API endpoints for checking modpack versions across multiple platforms
|
||||
* (CurseForge, Modrinth, FTB, Technic). Supports both on-demand manual checks
|
||||
* from the server console and cached status retrieval for the dashboard badge.
|
||||
*
|
||||
* ARCHITECTURE OVERVIEW:
|
||||
* This controller serves two distinct use cases:
|
||||
*
|
||||
* 1. MANUAL CHECK (manualCheck method)
|
||||
* - Called from: Server console "Check for Updates" button
|
||||
* - Behavior: Makes LIVE API calls via ModpackApiService
|
||||
* - Rate limited: 2 requests per minute per server
|
||||
*
|
||||
* 2. DASHBOARD STATUS (getStatus method)
|
||||
* - Called from: Dashboard badge component (UpdateBadge.tsx)
|
||||
* - Behavior: Reads from LOCAL DATABASE CACHE only - NO external API calls
|
||||
* - Why cached? Prevents rate limit hell on panels with many servers
|
||||
*
|
||||
* ROUTES (defined in routes/client.php):
|
||||
* - POST /api/client/extensions/modpackchecker/servers/{server}/check -> manualCheck()
|
||||
* - GET /api/client/extensions/modpackchecker/status -> getStatus()
|
||||
*
|
||||
* @package Pterodactyl\BlueprintFramework\Extensions\modpackchecker
|
||||
* @author Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>
|
||||
* @version 1.0.0
|
||||
* @see ModpackApiService.php (centralized API logic)
|
||||
* @see CheckModpackUpdates.php (cron command that populates the cache)
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
namespace Pterodactyl\Http\Controllers;
|
||||
|
||||
use Pterodactyl\Http\Controllers\Controller;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
|
||||
use Pterodactyl\Services\ModpackApiService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class ModpackAPIController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private DaemonFileRepository $fileRepository,
|
||||
private ModpackApiService $apiService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Manual version check triggered from the server console UI.
|
||||
*
|
||||
* Rate limited to 2 requests per minute per server to prevent API abuse.
|
||||
*
|
||||
* @param Request $request The incoming HTTP request
|
||||
* @param Server $server The server to check
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function manualCheck(Request $request, Server $server): JsonResponse
|
||||
{
|
||||
// Rate Limiting: Max 2 requests per minute per server
|
||||
$limitKey = 'modpack_check_' . $server->uuid;
|
||||
if (RateLimiter::tooManyAttempts($limitKey, 2)) {
|
||||
$seconds = RateLimiter::availableIn($limitKey);
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "Too many requests. Please wait {$seconds} seconds before checking again."
|
||||
], 429);
|
||||
}
|
||||
RateLimiter::hit($limitKey, 60);
|
||||
|
||||
// 1. Try Egg Variables first (most reliable)
|
||||
$platform = $this->getEggVariable($server, 'MODPACK_PLATFORM');
|
||||
$modpackId = $this->getEggVariable($server, 'MODPACK_ID');
|
||||
|
||||
// Also check platform-specific variables
|
||||
if (empty($modpackId)) {
|
||||
$modpackId = match($platform) {
|
||||
'curseforge' => $this->getEggVariable($server, 'CURSEFORGE_ID'),
|
||||
'modrinth' => $this->getEggVariable($server, 'MODRINTH_PROJECT_ID'),
|
||||
'ftb' => $this->getEggVariable($server, 'FTB_MODPACK_ID'),
|
||||
'technic' => $this->getEggVariable($server, 'TECHNIC_SLUG'),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
|
||||
// 2. If no egg variables, try file detection
|
||||
if (empty($platform) || empty($modpackId)) {
|
||||
$detected = $this->detectFromFiles($server);
|
||||
$platform = $platform ?: ($detected['platform'] ?? null);
|
||||
$modpackId = $modpackId ?: ($detected['modpack_id'] ?? null);
|
||||
}
|
||||
|
||||
// 3. If still nothing, return helpful error
|
||||
if (empty($platform) || empty($modpackId)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Could not detect modpack. Set MODPACK_PLATFORM and MODPACK_ID in startup variables.',
|
||||
]);
|
||||
}
|
||||
|
||||
// 4. Check the appropriate API using the unified Service
|
||||
try {
|
||||
$versionData = $this->apiService->fetchLatestVersion($platform, $modpackId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'platform' => $platform,
|
||||
'modpack_id' => $modpackId,
|
||||
'modpack_name' => $versionData['name'],
|
||||
'latest_version' => $versionData['version'],
|
||||
'status' => 'checked',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'platform' => $platform,
|
||||
'modpack_id' => $modpackId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve an egg variable value for a specific server.
|
||||
*
|
||||
* @param Server $server The server to query
|
||||
* @param string $name The environment variable name
|
||||
* @return string|null The variable's value, or null if not set
|
||||
*/
|
||||
private function getEggVariable(Server $server, string $name): ?string
|
||||
{
|
||||
$variable = $server->variables()
|
||||
->where('env_variable', $name)
|
||||
->first();
|
||||
return $variable?->server_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to detect modpack platform and ID by reading server files.
|
||||
*
|
||||
* Fallback method when egg variables aren't set.
|
||||
*
|
||||
* @param Server $server The server to scan
|
||||
* @return array Contains: platform, modpack_id (all nullable)
|
||||
*/
|
||||
private function detectFromFiles(Server $server): array
|
||||
{
|
||||
try {
|
||||
// Try CurseForge manifest.json
|
||||
$manifest = $this->readServerFile($server, 'manifest.json');
|
||||
if ($manifest) {
|
||||
$data = json_decode($manifest, true);
|
||||
if (isset($data['manifestType']) && $data['manifestType'] === 'minecraftModpack') {
|
||||
return [
|
||||
'platform' => 'curseforge',
|
||||
'modpack_id' => $data['projectID'] ?? null,
|
||||
'name' => $data['name'] ?? null,
|
||||
'version' => $data['version'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Try Modrinth modrinth.index.json
|
||||
$modrinthIndex = $this->readServerFile($server, 'modrinth.index.json');
|
||||
if ($modrinthIndex) {
|
||||
$data = json_decode($modrinthIndex, true);
|
||||
if (isset($data['formatVersion'])) {
|
||||
return [
|
||||
'platform' => 'modrinth',
|
||||
'modpack_id' => $data['dependencies']['minecraft'] ?? null,
|
||||
'name' => $data['name'] ?? null,
|
||||
'version' => $data['versionId'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// File detection failed, return empty
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file from the game server via the Wings daemon.
|
||||
*
|
||||
* @param Server $server The server whose files we're reading
|
||||
* @param string $path Relative path from server root
|
||||
* @return string|null File contents, or null if unreadable
|
||||
*/
|
||||
private function readServerFile(Server $server, string $path): ?string
|
||||
{
|
||||
try {
|
||||
$this->fileRepository->setServer($server);
|
||||
return $this->fileRepository->getContent($path);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached update status for all of a user's servers.
|
||||
*
|
||||
* THIS IS THE DASHBOARD BADGE ENDPOINT.
|
||||
*
|
||||
* CRITICAL: This method ONLY reads from the local database cache.
|
||||
* It NEVER makes external API calls.
|
||||
*
|
||||
* @param Request $request The incoming HTTP request
|
||||
* @return JsonResponse Keyed by server_uuid
|
||||
*/
|
||||
public function getStatus(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get all server UUIDs the user has access to
|
||||
$serverUuids = $user->accessibleServers()->pluck('uuid')->toArray();
|
||||
|
||||
// Query our cache table for these servers
|
||||
$statuses = DB::table('modpackchecker_servers')
|
||||
->whereIn('server_uuid', $serverUuids)
|
||||
->get()
|
||||
->keyBy('server_uuid');
|
||||
|
||||
$result = [];
|
||||
foreach ($statuses as $uuid => $status) {
|
||||
$result[$uuid] = [
|
||||
'update_available' => $status->status === 'update_available',
|
||||
'modpack_name' => $status->modpack_name,
|
||||
'current_version' => $status->current_version,
|
||||
'latest_version' => $status->latest_version,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* =============================================================================
|
||||
* MODPACK VERSION CHECKER - API SERVICE
|
||||
* =============================================================================
|
||||
*
|
||||
* Centralized service for all modpack platform API interactions.
|
||||
* Used by both the Controller (manual checks) and Command (cron checks).
|
||||
*
|
||||
* SUPPORTED PLATFORMS:
|
||||
* - Modrinth: Public API with User-Agent requirement
|
||||
* - CurseForge: Requires API key (configured in admin panel)
|
||||
* - FTB: Public API via modpacks.ch
|
||||
* - Technic: Public API with dynamic build number caching
|
||||
*
|
||||
* WHY A SERVICE?
|
||||
* DRY principle - both the manual check button and the cron job need
|
||||
* the same API logic. Centralizing it here means:
|
||||
* - One place to fix bugs
|
||||
* - One place to add new platforms
|
||||
* - Consistent error handling
|
||||
* - Shared caching (e.g., Technic build number)
|
||||
*
|
||||
* @package Pterodactyl\BlueprintFramework\Extensions\modpackchecker
|
||||
* @author Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>
|
||||
* @version 1.0.0
|
||||
* =============================================================================
|
||||
*/
|
||||
|
||||
namespace Pterodactyl\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Exception;
|
||||
|
||||
class ModpackApiService
|
||||
{
|
||||
/**
|
||||
* Fetch the latest version info for a modpack from its platform API.
|
||||
*
|
||||
* @param string $platform Platform name: modrinth, curseforge, ftb, technic
|
||||
* @param string $modpackId Platform-specific modpack identifier
|
||||
* @return array Contains: name, version
|
||||
* @throws Exception If platform unknown or API call fails
|
||||
*/
|
||||
public function fetchLatestVersion(string $platform, string $modpackId): array
|
||||
{
|
||||
return match($platform) {
|
||||
'modrinth' => $this->checkModrinth($modpackId),
|
||||
'curseforge' => $this->checkCurseForge($modpackId),
|
||||
'ftb' => $this->checkFTB($modpackId),
|
||||
'technic' => $this->checkTechnic($modpackId),
|
||||
default => throw new Exception("Unknown platform: {$platform}")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Modrinth API for latest modpack version.
|
||||
*
|
||||
* NO API KEY REQUIRED - uses User-Agent for identification.
|
||||
* Rate limit: 300 requests/minute (generous)
|
||||
*
|
||||
* @param string $projectId Modrinth project ID or slug
|
||||
* @return array Contains: name, version
|
||||
* @throws Exception If API request fails
|
||||
*/
|
||||
private function checkModrinth(string $projectId): array
|
||||
{
|
||||
$headers = ['User-Agent' => 'FirefrostGaming/ModpackChecker/1.0'];
|
||||
|
||||
$response = Http::withHeaders($headers)
|
||||
->get("https://api.modrinth.com/v2/project/{$projectId}");
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new Exception('Modrinth API request failed: ' . $response->status());
|
||||
}
|
||||
|
||||
$project = $response->json();
|
||||
|
||||
$versionResponse = Http::withHeaders($headers)
|
||||
->get("https://api.modrinth.com/v2/project/{$projectId}/version");
|
||||
|
||||
if (!$versionResponse->successful()) {
|
||||
throw new Exception('Modrinth versions API failed: ' . $versionResponse->status());
|
||||
}
|
||||
|
||||
$versions = $versionResponse->json();
|
||||
|
||||
return [
|
||||
'name' => $project['title'] ?? 'Unknown',
|
||||
'version' => $versions[0]['version_number'] ?? 'Unknown',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query CurseForge API for latest modpack version.
|
||||
*
|
||||
* REQUIRES API KEY - configured in admin panel.
|
||||
* Rate limit: ~1000 requests/day for personal keys.
|
||||
*
|
||||
* @param string $modpackId CurseForge project ID (numeric)
|
||||
* @return array Contains: name, version
|
||||
* @throws Exception If API key missing or request fails
|
||||
*/
|
||||
private function checkCurseForge(string $modpackId): array
|
||||
{
|
||||
$apiKey = DB::table('settings')
|
||||
->where('key', 'modpackchecker::curseforge_api_key')
|
||||
->value('value');
|
||||
|
||||
if (empty($apiKey)) {
|
||||
throw new Exception('CurseForge API key not configured');
|
||||
}
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'x-api-key' => $apiKey,
|
||||
'Accept' => 'application/json',
|
||||
])->get("https://api.curseforge.com/v1/mods/{$modpackId}");
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new Exception('CurseForge API request failed: ' . $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json()['data'] ?? [];
|
||||
|
||||
return [
|
||||
'name' => $data['name'] ?? 'Unknown',
|
||||
'version' => $data['latestFiles'][0]['displayName'] ?? 'Unknown',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Feed The Beast (FTB) API for latest modpack version.
|
||||
*
|
||||
* NO API KEY REQUIRED - modpacks.ch is public.
|
||||
*
|
||||
* @param string $modpackId FTB modpack ID (numeric)
|
||||
* @return array Contains: name, version
|
||||
* @throws Exception If API request fails
|
||||
*/
|
||||
private function checkFTB(string $modpackId): array
|
||||
{
|
||||
$response = Http::get("https://api.modpacks.ch/public/modpack/{$modpackId}");
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new Exception('FTB API request failed: ' . $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'name' => $data['name'] ?? 'Unknown',
|
||||
'version' => $data['versions'][0]['name'] ?? 'Unknown',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Technic Platform API for latest modpack version.
|
||||
*
|
||||
* NO API KEY REQUIRED - but requires dynamic build number.
|
||||
* The build number is cached for 12 hours to reduce API calls.
|
||||
*
|
||||
* "RV-Ready" approach: Technic blocks old build numbers, so we
|
||||
* dynamically fetch the current stable launcher build.
|
||||
*
|
||||
* @param string $slug Technic modpack slug (URL-friendly name)
|
||||
* @return array Contains: name, version
|
||||
* @throws Exception If API request fails
|
||||
*/
|
||||
private function checkTechnic(string $slug): array
|
||||
{
|
||||
// Cache the build number for 12 hours to prevent rate limits
|
||||
$latestBuild = Cache::remember('modpackchecker_technic_build', 43200, function () {
|
||||
$versionResponse = Http::get('https://api.technicpack.net/launcher/version/stable4');
|
||||
return $versionResponse->successful()
|
||||
? ($versionResponse->json('build') ?? 999)
|
||||
: 999;
|
||||
});
|
||||
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => 'FirefrostGaming/ModpackChecker/1.0',
|
||||
'Accept' => 'application/json',
|
||||
])->get("https://api.technicpack.net/modpack/{$slug}?build={$latestBuild}");
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new Exception('Technic API request failed: ' . $response->status());
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'name' => $data['displayName'] ?? $data['name'] ?? 'Unknown',
|
||||
'version' => $data['version'] ?? 'Unknown',
|
||||
];
|
||||
}
|
||||
}
|
||||
98
services/modpack-version-checker/blueprint-extension/build.sh
Executable file
98
services/modpack-version-checker/blueprint-extension/build.sh
Executable file
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# MODPACK VERSION CHECKER - BUILD SCRIPT
|
||||
# =============================================================================
|
||||
#
|
||||
# Executes automatically during `blueprint -build`
|
||||
# Injects React components into Pterodactyl's frontend
|
||||
#
|
||||
# @author Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>
|
||||
# @version 1.0.0
|
||||
# =============================================================================
|
||||
|
||||
echo "=========================================="
|
||||
echo "ModpackChecker Build Script v1.0.0"
|
||||
echo "=========================================="
|
||||
|
||||
# Determine the extension source directory
|
||||
# Blueprint may run from .blueprint/dev/ or .blueprint/extensions/modpackchecker/
|
||||
if [ -d ".blueprint/extensions/modpackchecker/views" ]; then
|
||||
EXT_DIR=".blueprint/extensions/modpackchecker"
|
||||
elif [ -d ".blueprint/dev/views" ]; then
|
||||
EXT_DIR=".blueprint/dev"
|
||||
else
|
||||
echo "ERROR: Cannot find extension views directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using extension directory: $EXT_DIR"
|
||||
|
||||
# ===========================================
|
||||
# 1. CONSOLE WIDGET INJECTION (Right Column)
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "--- Console Widget ---"
|
||||
|
||||
if [ -f "$EXT_DIR/views/server/wrapper.tsx" ]; then
|
||||
cp "$EXT_DIR/views/server/wrapper.tsx" resources/scripts/components/server/ModpackVersionCard.tsx
|
||||
echo "✓ Copied ModpackVersionCard.tsx"
|
||||
else
|
||||
echo "⚠ wrapper.tsx not found, skipping console widget"
|
||||
fi
|
||||
|
||||
# Inject into AfterInformation.tsx (right column, after stats)
|
||||
AFTER_INFO="resources/scripts/blueprint/components/Server/Terminal/AfterInformation.tsx"
|
||||
if [ -f "$AFTER_INFO" ]; then
|
||||
if ! grep -q "ModpackVersionCard" "$AFTER_INFO" 2>/dev/null; then
|
||||
# Add import after the blueprint/import comment
|
||||
sed -i '/\/\* blueprint\/import \*\//a import ModpackVersionCard from "@/components/server/ModpackVersionCard";' "$AFTER_INFO"
|
||||
# Add component inside the fragment after blueprint/react comment
|
||||
sed -i 's|{/\* blueprint/react \*/}|{/* blueprint/react */}\n <ModpackVersionCard />|' "$AFTER_INFO"
|
||||
echo "✓ Injected ModpackVersionCard into AfterInformation.tsx"
|
||||
else
|
||||
echo "○ ModpackVersionCard already present in AfterInformation.tsx"
|
||||
fi
|
||||
else
|
||||
echo "⚠ AfterInformation.tsx not found, skipping injection"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# 2. DASHBOARD BADGE INJECTION
|
||||
# ===========================================
|
||||
echo ""
|
||||
echo "--- Dashboard Badge ---"
|
||||
|
||||
if [ -f "$EXT_DIR/views/dashboard/UpdateBadge.tsx" ]; then
|
||||
mkdir -p resources/scripts/components/dashboard
|
||||
cp "$EXT_DIR/views/dashboard/UpdateBadge.tsx" resources/scripts/components/dashboard/UpdateBadge.tsx
|
||||
echo "✓ Copied UpdateBadge.tsx"
|
||||
else
|
||||
echo "⚠ UpdateBadge.tsx not found, skipping dashboard badge"
|
||||
fi
|
||||
|
||||
# Inject into ServerRow.tsx (dashboard server list)
|
||||
if ! grep -q "UpdateBadge" resources/scripts/components/dashboard/ServerRow.tsx 2>/dev/null; then
|
||||
sed -i '1i import UpdateBadge from "@/components/dashboard/UpdateBadge";' resources/scripts/components/dashboard/ServerRow.tsx
|
||||
# Targeted replacement: append badge after server name
|
||||
sed -i 's|{server.name}</p>|{server.name}<UpdateBadge serverUuid={server.uuid} /></p>|' resources/scripts/components/dashboard/ServerRow.tsx
|
||||
echo "✓ Injected UpdateBadge into ServerRow.tsx"
|
||||
else
|
||||
echo "○ UpdateBadge already present in ServerRow.tsx"
|
||||
fi
|
||||
|
||||
# ===========================================
|
||||
# NOTE: Console Command (CheckModpackUpdates.php)
|
||||
# ===========================================
|
||||
# The PHP console command is automatically merged by Blueprint via
|
||||
# conf.yml's `requests.app: "app"` setting. No manual copy needed.
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "ModpackChecker injection complete!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Run: yarn build:production"
|
||||
echo " 2. Restart: systemctl restart php8.3-fpm"
|
||||
echo " 3. Test cron: php artisan modpackchecker:check"
|
||||
echo ""
|
||||
@@ -0,0 +1,32 @@
|
||||
info:
|
||||
name: "ModpackChecker"
|
||||
identifier: "modpackchecker"
|
||||
description: "4-platform modpack version checker - supports CurseForge, Modrinth, Technic, and FTB"
|
||||
flags: ""
|
||||
version: "1.0.0"
|
||||
target: "beta-2026-01"
|
||||
author: "Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>"
|
||||
icon: "icon.png"
|
||||
website: "https://firefrostgaming.com/discord"
|
||||
admin:
|
||||
view: "admin/view.blade.php"
|
||||
controller: "admin/controller.php"
|
||||
css: ""
|
||||
wrapper: ""
|
||||
dashboard:
|
||||
css: ""
|
||||
wrapper: ""
|
||||
components: ""
|
||||
data:
|
||||
directory: ""
|
||||
public: ""
|
||||
console: ""
|
||||
requests:
|
||||
views: "views"
|
||||
app: "app"
|
||||
routers:
|
||||
application: ""
|
||||
client: "routes/client.php"
|
||||
web: ""
|
||||
database:
|
||||
migrations: "database/migrations"
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('modpackchecker_servers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Use the string UUID to match Pterodactyl's server identification
|
||||
$table->string('server_uuid')->unique();
|
||||
$table->string('platform')->nullable(); // curseforge, modrinth, technic, ftb
|
||||
$table->string('modpack_id')->nullable();
|
||||
$table->string('modpack_name')->nullable();
|
||||
$table->string('current_version')->nullable();
|
||||
$table->string('latest_version')->nullable();
|
||||
// Flexible string status instead of Enum for future extensibility
|
||||
$table->string('status')->default('unknown');
|
||||
$table->timestamp('last_checked')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// Foreign key - cascade delete when server is removed
|
||||
$table->foreign('server_uuid')->references('uuid')->on('servers')->onDelete('cascade');
|
||||
|
||||
// Indexes for efficient lookups
|
||||
$table->index('status');
|
||||
$table->index('last_checked');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('modpackchecker_servers');
|
||||
}
|
||||
};
|
||||
BIN
services/modpack-version-checker/blueprint-extension/icon.png
Normal file
BIN
services/modpack-version-checker/blueprint-extension/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Pterodactyl\Http\Controllers\ModpackAPIController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ModpackChecker Client Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Blueprint auto-prefixes these with /api/client/extensions/modpackchecker/
|
||||
| So our paths here are relative to that prefix.
|
||||
|
|
||||
*/
|
||||
|
||||
// Resulting URL: /api/client/extensions/modpackchecker/servers/{server}/check
|
||||
Route::post('/servers/{server}/check', [ModpackAPIController::class, 'manualCheck']);
|
||||
|
||||
// Resulting URL: /api/client/extensions/modpackchecker/status
|
||||
Route::get('/status', [ModpackAPIController::class, 'getStatus']);
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* =============================================================================
|
||||
* MODPACK VERSION CHECKER - DASHBOARD BADGE COMPONENT
|
||||
* =============================================================================
|
||||
*
|
||||
* React component that displays a colored indicator dot next to server names
|
||||
* on the Pterodactyl dashboard, showing modpack update status at a glance.
|
||||
*
|
||||
* VISUAL DESIGN:
|
||||
* - 🟢 Frost (#4ECDC4): Server's modpack is up to date
|
||||
* - 🟠 Fire (#FF6B35): Update available for this modpack
|
||||
* - No dot: Server has no modpack configured or not yet checked
|
||||
*
|
||||
* CACHING:
|
||||
* Uses a global cache with 60-second TTL to prevent excessive API calls
|
||||
* while ensuring reasonably fresh data during navigation.
|
||||
*
|
||||
* @package ModpackChecker Blueprint Extension
|
||||
* @author Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>
|
||||
* @version 1.0.0
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import http from '@/api/http';
|
||||
|
||||
interface ServerStatus {
|
||||
update_available: boolean;
|
||||
modpack_name?: string;
|
||||
current_version?: string;
|
||||
latest_version?: string;
|
||||
}
|
||||
|
||||
interface StatusCache {
|
||||
[serverUuid: string]: ServerStatus;
|
||||
}
|
||||
|
||||
// Global cache with TTL support
|
||||
let globalCache: StatusCache | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
let fetchPromise: Promise<StatusCache> | null = null;
|
||||
|
||||
const CACHE_TTL_MS = 60000; // 60 seconds
|
||||
|
||||
/**
|
||||
* Fetch all server statuses with 60-second TTL caching.
|
||||
*/
|
||||
const fetchAllStatuses = async (): Promise<StatusCache> => {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached data if it exists AND is less than 60 seconds old
|
||||
if (globalCache !== null && (now - cacheTimestamp < CACHE_TTL_MS)) {
|
||||
return globalCache;
|
||||
}
|
||||
|
||||
// If a fetch is already in progress, wait for it
|
||||
if (fetchPromise !== null) {
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
// Start new fetch
|
||||
fetchPromise = http.get('/api/client/extensions/modpackchecker/status')
|
||||
.then((response) => {
|
||||
globalCache = response.data || {};
|
||||
cacheTimestamp = Date.now();
|
||||
return globalCache;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('ModpackChecker: Failed to fetch status', error);
|
||||
globalCache = {};
|
||||
return globalCache;
|
||||
})
|
||||
.finally(() => {
|
||||
fetchPromise = null;
|
||||
});
|
||||
|
||||
return fetchPromise;
|
||||
};
|
||||
|
||||
interface UpdateBadgeProps {
|
||||
serverUuid: string;
|
||||
}
|
||||
|
||||
const UpdateBadge: React.FC<UpdateBadgeProps> = ({ serverUuid }) => {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllStatuses()
|
||||
.then((cache) => {
|
||||
setStatus(cache[serverUuid] || null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [serverUuid]);
|
||||
|
||||
// Don't render while loading or if no status data
|
||||
if (loading || !status || !status.modpack_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dotStyle: React.CSSProperties = {
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
marginLeft: '8px',
|
||||
backgroundColor: status.update_available ? '#FF6B35' : '#4ECDC4',
|
||||
boxShadow: status.update_available
|
||||
? '0 0 4px rgba(255, 107, 53, 0.5)'
|
||||
: '0 0 4px rgba(78, 205, 196, 0.5)',
|
||||
};
|
||||
|
||||
const tooltipText = status.update_available
|
||||
? `Update available: ${status.latest_version}`
|
||||
: `Up to date: ${status.latest_version}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={dotStyle}
|
||||
title={tooltipText}
|
||||
aria-label={tooltipText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateBadge;
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import http from '@/api/http';
|
||||
import { faCube } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface VersionData {
|
||||
success: boolean;
|
||||
platform?: string;
|
||||
modpack_id?: string;
|
||||
modpack_name?: string;
|
||||
current_version?: string;
|
||||
latest_version?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ModpackVersionCard: React.FC = () => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [data, setData] = useState<VersionData | null>(null);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (!uuid) return;
|
||||
|
||||
setStatus('loading');
|
||||
try {
|
||||
const response = await http.post(`/api/client/extensions/modpackchecker/servers/${uuid}/check`);
|
||||
setData(response.data);
|
||||
setStatus(response.data.success ? 'success' : 'error');
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 429) {
|
||||
setData({ success: false, error: 'rate_limited' });
|
||||
} else if (error.response?.status === 404) {
|
||||
setData({ success: false, error: 'not_found' });
|
||||
} else {
|
||||
setData({ success: false, error: 'api_error' });
|
||||
}
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
// Convert error codes to short display messages
|
||||
const getErrorMessage = (error?: string): string => {
|
||||
if (!error) return 'Error';
|
||||
if (error.includes('detect') || error.includes('MODPACK')) return 'Not configured';
|
||||
if (error === 'rate_limited') return 'Wait 60s';
|
||||
if (error === 'not_found') return 'Not found';
|
||||
if (error === 'api_error') return 'API error';
|
||||
if (error.length > 20) return 'Check failed';
|
||||
return error;
|
||||
};
|
||||
|
||||
const getBgColor = () => {
|
||||
if (status === 'success' && data?.status === 'update_available') return 'bg-orange-500';
|
||||
if (status === 'success' && data?.success) return 'bg-cyan-500';
|
||||
return 'bg-gray-700';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center rounded shadow-lg relative bg-gray-600 cursor-pointer hover:bg-gray-500 transition-colors',
|
||||
'col-span-3 md:col-span-2 lg:col-span-6',
|
||||
'px-3 py-2 md:p-3 lg:p-4 mt-2'
|
||||
)}
|
||||
onClick={status !== 'loading' ? checkForUpdates : undefined}
|
||||
title={'Click to check for modpack updates'}
|
||||
>
|
||||
<div className={classNames('w-1 h-full absolute left-0 top-0 rounded-l sm:hidden', getBgColor())} />
|
||||
|
||||
<div className={classNames(
|
||||
'hidden flex-shrink-0 items-center justify-center rounded-lg shadow-md w-12 h-12 transition-colors duration-500',
|
||||
'sm:flex sm:mr-4',
|
||||
getBgColor()
|
||||
)}>
|
||||
<FontAwesomeIcon icon={faCube} className={'w-6 h-6 text-gray-50'} />
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col justify-center overflow-hidden w-full'}>
|
||||
<p className={'font-header font-medium leading-tight text-xs md:text-sm text-gray-200'}>
|
||||
Modpack Version
|
||||
</p>
|
||||
<div className={'h-[1.75rem] w-full font-semibold text-gray-50 truncate text-sm'}>
|
||||
{status === 'idle' && <span className={'text-gray-400'}>Click to check</span>}
|
||||
{status === 'loading' && <span className={'text-gray-400'}>Checking...</span>}
|
||||
{status === 'success' && data?.success && <span>{data.latest_version}</span>}
|
||||
{(status === 'error' || (status === 'success' && !data?.success)) && (
|
||||
<span className={'text-red-400'}>{getErrorMessage(data?.error || data?.message)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModpackVersionCard;
|
||||
Binary file not shown.
Reference in New Issue
Block a user