70 Commits
v3.5.0 ... main

Author SHA1 Message Date
Claude
47a600eeb5 Fix: Handle server names with subtitles for Discord channel matching
- 'Homestead - A Cozy Survival Experience' now matches 'homestead-chat'
- 'All The Mons (Private) - TX' now matches 'all-the-mons-chat'
- Strips subtitles after ' - ' and removes parentheticals
2026-04-09 20:03:41 +00:00
Claude
e30ff4d694 Fix: Add Discord channel status to matrix body template (inline cards, not partial) 2026-04-09 19:59:36 +00:00
Claude
081bad1279 Add Discord channel status check to server cards
- Checks for 4 channels per server: chat, in-game, forum, voice
- Shows 'All 4 channels configured' or lists missing channels
- Caches Discord channel data for 5 minutes to reduce API calls
2026-04-09 19:55:41 +00:00
Claude
cbf5d219fc Add health check after deploy - confirms Arbiter restarted successfully 2026-04-09 19:50:17 +00:00
Claude
02bddc0baf Fix deploy button: use detached process to avoid 502 on self-restart 2026-04-09 19:48:04 +00:00
Claude
ef562ef59a Add Trinity Console deploy button for Holly/Meg/Michael
- Deploy button in sidebar above username
- POST /admin/system/deploy endpoint
- Updated deploy.sh with locking, logging, user tracking
- Prevents concurrent deploys (mkdir lock)
- Logs who deployed and what commit
- Updated DEPLOYMENT.md with setup instructions

Gemini consultation: confirmed synchronous approach, locking, sudoers config
2026-04-09 19:40:34 +00:00
Claude
dc59e5c1de Add /delserver documentation script
Chronicler: #71
2026-04-08 17:30:12 +00:00
Claude
69200d8ac3 Add /delserver slash command
Deletes complete server setup:
- All channels in category
- The category itself
- The server role

Requires confirm:True to execute.
Without confirm, shows preview of what would be deleted.
Reminds to clean up Carl-bot reaction roles.

Staff only.

Chronicler: #71
2026-04-08 17:27:10 +00:00
Claude
7ecce5da8f Add script to create #staff-commands with documentation
Creates channel in Staff Area with detailed embeds for:
- /link command (everyone)
- /createserver command (staff only)

Chronicler: #71
2026-04-08 17:21:52 +00:00
Claude
06f7afe25d Add /createserver slash command
Creates complete server setup with one command:
- Creates role
- Creates category with 🎮 prefix
- Creates chat, in-game, forum, voice channels
- Applies permission template
- Posts and archives welcome message
- Suggests unused emoji for reaction roles

Staff only. Reminds to configure Carl-bot.

Task #98 Discord Channel Automation
Chronicler: #71
2026-04-08 17:18:28 +00:00
Claude
083885c874 Add emoji prefixes to remaining categories
📢 Welcome & Info
💬 Community Hub
🔊 Voice Channels
📞 Support

Chronicler: #71
2026-04-08 17:05:34 +00:00
Claude
05d23e2dfc Add script to archive welcome posts
Fixes forum channels staying visible when category collapsed.
Archived threads don't count as 'active'.

Chronicler: #71
2026-04-08 17:00:17 +00:00
Claude
940840d69a Fix Wold's Vaults v2 - use role ID directly
Whatever apostrophe variant that is, we're bypassing it.
Role ID: 1491029373640376330

Chronicler: #71
2026-04-08 16:53:42 +00:00
Claude
f5a75d204f Fix Wold's Vaults - curly apostrophe
Role uses ' (curly) not ' (straight)

Chronicler: #71
2026-04-08 16:52:36 +00:00
Claude
40cb6cef31 Add full Discord channel setup script (46 channels)
Task #98 implementation:
- Phase 1: Add 🎮 prefix to existing 5 server categories
- Phase 2: Add forums to existing 5 servers
- Phase 3: Create full setup for 10 new servers (category + chat + in-game + voice + forum)
- Phase 4: Create 📦 Archive category (staff-only)

Includes:
- DRY_RUN mode for safe testing
- Permission overwrites (Wanderer=view, Server Role=interact, Staff=full)
- 15 welcome posts with server-specific content
- 6 standard forum tags per forum
- Rate limiting (500ms between API calls)

Chronicler: #71
2026-04-08 16:50:05 +00:00
Claude
9752c6fd89 Add full Discord channel setup script (46 channels)
Task #98: Discord Channel Automation
- Phase 1: Add forums to existing 5 servers + rename categories
- Phase 2: Create 10 new server categories with all channels
- Phase 3: Create Archive category (staff only)

Includes:
- 15 server-specific welcome posts
- Standard forum tags (6 tags)
- Permission template (Wanderer view-only, Server Role full access)
- Rate limiting (500ms delays)
- Idempotent (skips existing channels)

Chronicler: #71
2026-04-08 16:48:48 +00:00
Claude
911f5801fc Fix .env path to /opt/arbiter-3.0/.env
Chronicler: #71
2026-04-08 16:40:40 +00:00
Claude
8768c6773f Add Discord channel creation test script
Phase 1 test for Task #98 Discord automation.
Creates one test category + one forum with tags + welcome post.
Includes DRY_RUN mode and permission checks.

Chronicler: #71
2026-04-08 16:39:48 +00:00
Claude Chronicler-70
9e4fa13fdb feat(arbiter): Add New Features card to dashboard
Highlights Discord Dashboard and Financials Module with
clickable cards that link directly to the new features.

Chronicler: #70
2026-04-08 15:33:54 +00:00
Claude Chronicler-70
b96ab1fb24 feat(arbiter): Add Discord dashboard to Trinity Console
- New sidebar entry for Discord
- Full server structure visualization
- Channel tree with expandable categories
- Role hierarchy with color badges
- Health checks (orphan channels, empty roles, bot roles)
- Search/filter across channels and roles
- Click channel to see permission overwrites
- Click role to see explicit channel access
- Responsive design with modal details view

Chronicler: #70
2026-04-08 15:30:22 +00:00
Claude Chronicler-70
04bc2e734f feat(arbiter): Add localhost bypass for admin routes debugging 2026-04-08 15:23:20 +00:00
Claude
b639f92da6 fix: Remove incorrect middleware import from discord-audit
Parent router (admin/index.js) already applies requireTrinityAccess
to all child routes. No additional auth middleware needed.

Chronicler #70
2026-04-08 15:19:50 +00:00
Claude
e99ef3b942 feat: Add Discord audit routes to Arbiter
New endpoints for Trinity Console:
- GET /admin/discord/audit — Full server audit (channels, roles, structure)
- GET /admin/discord/channels — Just channels
- GET /admin/discord/roles — Just roles

Returns:
- Server info (name, member count, features)
- Categories with nested children
- Orphan channels (not in categories)
- Role hierarchy with positions and member counts
- Permission overwrites per channel

Uses existing Discord.js client from app.locals.

Chronicler #70
2026-04-08 15:15:20 +00:00
Claude
7cf0eec2db Add module list to v2 teaser
12 planned modules: Dashboard, Players, Servers, Infrastructure,
Financials, Tasks, Docs, Team, Marketing, Chroniclers, System, Health

Chronicler #69
2026-04-08 08:55:17 +00:00
Claude
20b2fab994 Add Trinity Core v2 teaser to dashboard
Features highlighted:
- Trinity Codex AI (natural language queries)
- Smart Notifications (Discord alerts)
- Approval Workflows (Discord button approvals)
- Plugin Architecture (self-registering modules)
- Granular Permissions (RBAC for staff)
- Distributed Mesh (Tailscale-connected servers)

Styled with brand gradient border.

Chronicler #69
2026-04-08 08:53:56 +00:00
Claude
c7c2340321 Add logout button to user profile in sidebar
- Door emoji button next to username
- Links to /auth/logout
- Redirects to home page after logout

Chronicler #69
2026-04-08 08:50:32 +00:00
Claude
460d36c9b2 Remove placeholder notification bell
Non-functional UI element. Notifications will be implemented
properly in Trinity Core.

Chronicler #69
2026-04-08 08:48:38 +00:00
Claude
5bd4c60238 Fix scheduler timezone labels: UTC → Central
Times are stored in Central Time (matching Pterodactyl server config).
Labels were incorrectly showing UTC.

Chronicler #69
2026-04-08 08:46:07 +00:00
Claude
795020b55c Add Export CSV button to Players page
- Exports all players with full subscription data
- Includes: discord_id, minecraft_username, minecraft_uuid, is_staff,
  tier_level, tier_name, status, mrr_value, is_lifetime,
  stripe_customer_id, created_at, updated_at
- Downloads as firefrost-players-YYYY-MM-DD.csv
- Properly escapes CSV values with quotes

Chronicler #69
2026-04-08 08:41:17 +00:00
Claude
a13d9a2c66 Add 10-minute retry for failed server syncs
When hourly sync encounters servers that fail (e.g., mid-restart):
- Logs the failure count
- Schedules automatic retry in 10 minutes
- Retry only targets previously failed servers
- Clears error state on successful retry

Fixes issue where servers in daily restart would stay in error state
until manual intervention.

Chronicler #69
2026-04-08 08:39:34 +00:00
Claude
c2b6610e6d Add version number (v1.0) below Trinity Console title
Small text under the logo in sidebar for version tracking.

Chronicler #69
2026-04-08 08:34:41 +00:00
Claude
7d21b4290a Dashboard: Show last sync date/time instead of just checkmark
- Queries server_sync_log for most recent successful sync
- Displays date (e.g., 'Apr 8') and time (e.g., '3:28 AM')
- Shows yellow dash with 'Never' if no syncs recorded

Chronicler #69
2026-04-08 08:32:28 +00:00
Claude
7f990933df Sync package.json with production dependencies
Added missing dependencies that were installed on server but not in repo:
- axios: ^1.14.0
- connect-pg-simple: ^10.0.0
- date-fns: ^4.1.0

This caused deploy script to fail with MODULE_NOT_FOUND errors.

Chronicler #69
2026-04-08 08:30:17 +00:00
Claude
d121bd21f6 Fix dashboard SQL: use tier_level and mrr_value columns
The subscriptions table uses:
- tier_level (integer) not tier_id
- mrr_value (pre-calculated) not joined to subscription_tiers
- is_lifetime (boolean) not status='lifetime'

Chronicler #69
2026-04-08 08:24:08 +00:00
Claude
91eea2c5ff Add Arbiter deployment script and documentation
Created:
- deploy.sh: One-command deployment script
- DEPLOYMENT.md: Full deployment guide

Features:
- Handles cleanup of old temp directories
- Shallow clone for speed
- Checks for dependency changes
- Verifies service after restart
- Clear error messages

Usage on Command Center:
  bash /opt/arbiter-3.0/deploy.sh

Or remote curl:
  curl -fsSL https://git.firefrostgaming.com/.../deploy.sh | bash

Chronicler #69
2026-04-08 08:22:22 +00:00
Claude
3666241aac Fix Trinity Console dashboard: dynamic server/subscriber counts
Dashboard was showing hardcoded values:
- Servers Online: 12 (should be 22)
- Active Subscribers: 0
- Total MRR: $0

Now fetches live data:
- Server count from Pterodactyl API via getMinecraftServers()
- Subscriber count and MRR from arbiter_db subscriptions table

Files changed:
- src/routes/admin/index.js: async dashboard route with data fetching
- src/views/admin/dashboard.ejs: EJS variables instead of hardcoded values

Chronicler #69
2026-04-08 08:19:10 +00:00
Claude
567164ef7d Add servers-api Cloudflare Worker to version control
- Retrieved from Cloudflare dashboard via MCP connector
- Was 'dashboard only, not in any git repo' - gap now closed
- Original creation: April 3, 2026 by Chronicler #56 (The Velocity)
- Proxies Pterodactyl API for live server status on website

Chronicler #68
2026-04-08 05:44:00 +00:00
Claude (Chronicler #63)
e59ee04b03 fix(modpackchecker): Change check_interval validation from required to nullable
Disabled PRO fields don't submit values, causing validation error.
Now accepts null and defaults to 'daily' in update method.

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 12:59:28 +00:00
Frostystyle
1a3e884186 release(modpackchecker): v1.0.0 packaged blueprint file 2026-04-06 07:53:45 -05:00
Claude (Chronicler #63)
6e15a62378 fix(modpackchecker): Update website link to Discord
conf.yml: Changed website from firefrostgaming.com to firefrostgaming.com/discord
- Discord is the primary support channel
- Link icon in Blueprint admin header now goes directly to support

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 12:45:21 +00:00
Claude (Chronicler #63)
05d2164dce fix(modpackchecker): Console card redesign - StatBlock style + short errors
wrapper.tsx complete rewrite:
- Matches Pterodactyl StatBlock styling exactly
- Uses col-span classes for proper grid layout
- Icon with status color (orange=update, cyan=current, gray=idle)
- Clickable card instead of separate button
- Short error codes for better UX:
  - 'Not configured' (no modpack variables)
  - 'Wait 60s' (rate limited)
  - 'Not found' (404)
  - 'API error' (general failure)
  - 'Check failed' (long error truncated)

build.sh:
- Injects into AfterInformation.tsx (right column)
- Card appears after Network stats

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 12:32:41 +00:00
Claude (Chronicler #63)
c160647f0b fix(modpackchecker): Move card to right column, match StatBlock style
build.sh:
- Changed injection from ServerConsoleContainer to AfterInformation.tsx
- Card now appears in right column after Network stats

wrapper.tsx:
- Redesigned to match Pterodactyl StatBlock aesthetic
- Uses Tailwind classes (bg-gray-600, rounded, etc.)
- FontAwesome cube icon with status colors
- Compact layout: title + Check button on one line
- Fire (#FF6B35/orange-400) for updates, Frost (#4ECDC4/cyan-400) for current

Fixes layout issue identified in Wizard review.

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 12:21:19 +00:00
Claude (Chronicler #63)
d735e3d9db fix(modpackchecker): Wizard review fixes - UI polish
Admin Panel (view.blade.php):
- Changed CurseForge API key from password dots to plain text
- Fixed callouts: dark theme (#1a1a2e) with Frost/Fire accent borders
- Improved code tag styling for readability
- Changed support link to firefrostgaming.com/discord

README.md:
- Added Prerequisites section (PHP 8.1+, Node.js 18+, Yarn, Blueprint)

Reviewed by: Michael 'Frostystyle' Krause (The Wizard)
Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 12:13:14 +00:00
Claude (Chronicler #63)
5a607c8c8b refactor(modpackchecker): Batch 3+4 fixes - frontend, admin, docs
BATCH 3 - Frontend & UI:

wrapper.tsx (Console Widget):
- FIXED: API URL from .../ext/modpackchecker/check to .../check
- Added 429 rate limit handling with user-friendly message

UpdateBadge.tsx (Dashboard Badge):
- Added 60-second TTL to global cache (was infinite)
- Prevents stale data during client-side navigation

admin/view.blade.php:
- Disabled Discord webhook field (PRO TIER badge)
- Disabled Check Interval field (PRO TIER badge)
- Added support callout linking to Discord

BATCH 4 - Documentation:

README.md:
- Fixed architecture diagram (server_uuid, status string)
- Added app/Services/ModpackApiService.php to file structure
- Fixed API endpoint URLs throughout
- Updated installation for BuiltByBit (.blueprint package)
- Updated 'Adding New Platform' instructions for Service pattern
- Added Support section with Discord link
- Changed license to explicit commercial terms

NEW: CHANGELOG.md
- Version history for future updates
- Documents v1.0.0 features

Reviewed by: Gemini AI (Architecture Consultant)
Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 11:47:20 +00:00
Claude (Chronicler #63)
8e37120289 refactor(modpackchecker): Batch 2 fixes - centralized service, rate limiting, schema fixes
NEW: app/Services/ModpackApiService.php
- Centralized API logic for all 4 platforms
- Technic build number cached for 12 hours (RV-Ready)
- Single source of truth for API calls

Controller (ModpackAPIController.php):
- Now uses injected ModpackApiService instead of duplicated code
- Added RateLimiter: 2 requests/minute per server on manualCheck()
- Returns 429 with countdown when rate limited
- Removed 400+ lines of duplicated API code

Console Command (CheckModpackUpdates.php):
- FIXED: updateDatabase() now uses server_uuid (not server_id)
- FIXED: status column uses strings ('update_available', 'up_to_date', 'error')
- FIXED: Technic API now uses dynamic build via service
- Now uses injected ModpackApiService

SECURITY:
- Rate limiting prevents API key abuse via button spam
- Technic build caching reduces external API calls

Reviewed by: Gemini AI (Architecture Consultant)
Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 11:33:11 +00:00
Claude (Chronicler #63)
35315c2e81 refactor(modpackchecker): Batch 1 fixes from Gemini review
Routes (client.php):
- Removed redundant prefixing - Blueprint auto-prefixes with identifier
- Clean paths: /servers/{server}/check and /status
- Added clear comments documenting resulting URLs

Migration:
- Changed enum('status') to string('status') for future flexibility
- Added foreign key constraint: server_uuid -> servers.uuid with cascade delete
- Ensures 'RV-Ready' data integrity - no ghost data on server deletion

Build Script:
- Removed redundant PHP copy logic (Blueprint handles via requests.app)
- Fixed dead code that referenced wrong path for console command
- More targeted sed patterns for better stability
- Added author/version header

Reviewed by: Gemini AI (Architecture Consultant)
Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 11:27:46 +00:00
Claude (Chronicler #63)
845d121fb2 chore(modpackchecker): Update authorship for commercial release
All files now credit: Firefrost Gaming / Frostystyle <dev@firefrostgaming.com>

Updated:
- conf.yml author field
- README.md credits section
- ModpackAPIController.php @author tag
- CheckModpackUpdates.php @author tag
- UpdateBadge.tsx @author tag

Removed internal Chronicler references from commercial codebase.

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 11:20:20 +00:00
Claude (Chronicler #63)
517ec996a9 fix(modpackchecker): getStatus() use server_uuid and status column
BUG: Was using server_id (column doesn't exist) instead of server_uuid
BUG: Was using update_available (column doesn't exist) instead of status

FIXED:
- Changed whereIn('server_id', $serverIds) to whereIn('server_uuid', $serverUuids)
- Changed pluck('id') to pluck('uuid')
- Changed (bool) $status->update_available to $status->status === 'update_available'

This fix makes the dashboard badge API actually work!

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 11:15:22 +00:00
Claude (Chronicler #63)
7437b4fa7b docs(modpackchecker): Fix namespace in README, add icon to file structure
- Changed troubleshooting namespace to Pterodactyl\Http\Controllers
- Added icon.png to file structure documentation

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 10:12:23 +00:00
Claude (Chronicler #63)
6992790104 feat(modpackchecker): Add Gemini-designed extension icon
- Isometric cube with checkmark (version check concept)
- Frost (#4ECDC4) edge on left, Fire (#FF6B35) edge on right
- Subtle Firefrost branding that fits Pterodactyl's UI
- 128x128 PNG with transparency

Designed by Gemini AI, April 2026.

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 10:09:40 +00:00
Claude (Chronicler #63)
5c97b40237 fix(modpackchecker): Fix Technic API 401 error with dynamic build number
ROOT CAUSE (Gemini consultation):
Technic blocks requests with old/deprecated build numbers. The hardcoded
'?build=1' was being rejected as an ancient launcher version.

SOLUTION:
- Fetch current stable launcher build from /launcher/version/stable4
- Use that build number in the modpack request
- Fallback to 999 if version check fails

This 'RV-Ready' approach requires zero maintenance as Technic updates
their launcher versions over time.

ALL 4 PLATFORMS NOW WORKING:
 Modrinth
 FTB
 CurseForge
 Technic

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 10:01:53 +00:00
Claude (Chronicler #63)
326f6529f3 docs(modpackchecker): Update README with correct structure and Technic status
- Updated file structure to show app/Http/Controllers and app/Console/Commands
- Added explanation of why the app/ folder structure is used
- Updated platform support table with working status
- Added note about Technic API 401 error (investigation needed)

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 09:54:39 +00:00
Claude (Chronicler #63)
0f2ece4f88 fix(modpackchecker): Restructure for Blueprint PSR-4 compliance
BREAKING CHANGES - folder structure reorganized:

OLD STRUCTURE (broken):
  Controllers/ModpackAPIController.php
  console/CheckModpackUpdates.php

NEW STRUCTURE (working):
  app/Http/Controllers/ModpackAPIController.php
  app/Console/Commands/CheckModpackUpdates.php

CHANGES:

1. Moved controller to app/Http/Controllers/
   - Namespace changed: Pterodactyl\Http\Controllers
   - This aligns with Laravel's PSR-4 autoloading
   - Blueprint's requests.app field merges into Pterodactyl's app/

2. Moved console command to app/Console/Commands/
   - Now properly registered with Laravel's command system
   - Run with: php artisan modpackchecker:check

3. Updated conf.yml:
   - Set requests.app: 'app' (enables app/ folder merging)
   - Cleared data.directory (was pointing to non-existent folder)
   - Cleared dashboard.wrapper (TSX not supported, use build.sh)

4. Updated routes/client.php:
   - Fixed use statement to match new namespace

TESTED AND VERIFIED:
- blueprint -build: SUCCESS
- yarn build:production: SUCCESS
- php artisan modpackchecker:check: SUCCESS
- API tests passed: Modrinth , FTB , CurseForge 
- Technic API now requires auth (needs investigation)

This commit represents the WORKING state deployed on Dev Panel.

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 09:52:57 +00:00
Claude (Chronicler #63)
e36b20d06e docs(modpackchecker): Comprehensive developer documentation
Added professional-grade documentation throughout the codebase so any
developer can pick up this project and understand it immediately.

PHILOSOPHY:
'Hand someone the repo and say: here's what we built, here's WHY we built
it this way, here's where it's going. Make it better.' — Michael

NEW FILES:
- blueprint-extension/README.md
  - Complete developer onboarding guide (400+ lines)
  - Architecture diagram showing cron → cache → badge flow
  - Installation steps, configuration, usage
  - API reference with example responses
  - Troubleshooting guide
  - Design decisions with rationale

ENHANCED DOCUMENTATION:

ModpackAPIController.php:
- 60-line file header explaining purpose, architecture, critical decisions
- Detailed docblocks on every method
- Explains WHY dashboard reads cache-only (rate limits)
- Documents all four platform APIs with links
- Example request/response for each endpoint

CheckModpackUpdates.php:
- 50-line file header with usage examples
- Recommended cron schedule
- Example console output
- Documents rate limiting strategy
- Explains relationship to dashboard badges

UpdateBadge.tsx:
- 50-line file header explaining the 'dumb badge' architecture
- Detailed comments on global cache pattern
- Documents the fetch-once deduplication strategy
- Explains render conditions and why each exists
- Brand color documentation (Fire/Frost)
- Accessibility notes (aria-label)

WHAT A NEW DEVELOPER NOW KNOWS:
1. The 'why' behind every architectural decision
2. How the cron → cache → badge flow prevents rate limits
3. Which methods call external APIs vs read cache
4. How to add a new platform
5. How to troubleshoot common issues
6. The relationship between all components

This codebase is now ready to hand to a contractor with the words:
'This was made great. Make it awesome.'

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 09:05:48 +00:00
Claude (Chronicler #63)
0cbea6d993 feat(modpackchecker): Phase 5 complete - Dashboard badge and cron job
Phase 5 Components (completing Pyrrhus's work):

NEW FILES:
- views/dashboard/UpdateBadge.tsx: Dashboard badge component
  - Shows 🟢 (up to date) or 🟠 (update available) next to server names
  - Global cache prevents multiple API calls on page load
  - Reads from local database, never calls external APIs directly
  - Fire (#FF6B35) and Frost (#4ECDC4) brand colors

- console/CheckModpackUpdates.php: Laravel cron command
  - Run with: php artisan modpackchecker:check
  - Loops through servers with MODPACK_PLATFORM variable
  - Checks CurseForge, Modrinth, FTB, Technic APIs
  - Rate limited (2s sleep between checks)
  - Stores results in modpackchecker_servers table

UPDATED FILES:
- Controllers/ModpackAPIController.php:
  - Added getStatus() method for dashboard badge endpoint
  - Returns all user's servers' update status in single query
  - Added DB facade import

- routes/client.php:
  - Added GET /extensions/modpackchecker/status route

- build.sh:
  - Complete rewrite for Phase 5
  - Handles both console widget AND dashboard badge
  - Auto-detects extension directory (dev vs extensions)
  - Copies CheckModpackUpdates.php to app/Console/Commands/
  - Injects UpdateBadge into ServerRow.tsx
  - Clear status output and next-steps guide

Architecture (Gemini-approved):
  CRON (hourly) → Database cache → Single API endpoint → React badge
  Dashboard badge is 'dumb' - only reads from cache, never external APIs

Completing work started by Chronicler #62 (Pyrrhus).
UpdateBadge.tsx was lost in Blueprint corruption - reconstructed from
handoff notes and architecture documentation.

Signed-off-by: Claude (Chronicler #63) <claude@firefrostgaming.com>
2026-04-06 08:53:27 +00:00
Claude (Chronicler #62)
1eda8894d5 fix: ModpackChecker Phase 3 complete - working end-to-end pipeline
PHASE 3 COMPLETE - All systems operational on Dev Panel

Changes:
- Renamed controllers/ to Controllers/ (PSR-4 case sensitivity fix)
- Updated namespace to use capital C in Controllers
- Fixed getEggVariable() method to use correct Pterodactyl model structure
  - Changed from whereHas('variable'...) to direct where('env_variable'...)
  - Changed return from variable_value to server_value
- Updated routes/client.php with correct namespace
- Updated wrapper.tsx with correct API path (/api/client/extensions/...)
- Added build.sh for React component injection via sed

Tested and verified:
- Admin UI renders correctly
- Client panel loads without 500 error
- React component appears on server console page
- API call executes successfully
- Returns proper 'no modpack detected' message for unconfigured servers

Key learnings documented:
- Blueprint wrapper field is for Blade only, not TSX
- TSX components require build.sh + sed injection + yarn build
- PHP-FPM OPCache requires restart after adding new classes
- Controller namespace must match directory case exactly

Dev Panel: http://64.50.188.14:128
Test Server UUID: c0a133db-6cb7-497d-a2ed-22ae66eb0de8

Next: Phase 4 - Real modpack testing with CurseForge API

Signed-off-by: Claude (Chronicler #62) <claude@firefrostgaming.com>
2026-04-06 01:39:04 +00:00
Claude (Chronicler #62)
35aded99fe feat(modpackchecker): add Blueprint extension Phase 2 - core architecture
Task #26 Phase 2 Complete — Core Architecture

Files created:
- conf.yml: Blueprint manifest with all paths configured
- admin/controller.php: Admin settings controller (BYOK key, webhook, interval)
- admin/view.blade.php: Admin UI with Trinity-inspired styling
- controllers/ModpackAPIController.php: Client API with all 4 platform integrations
- routes/client.php: Client route for manual version checks
- views/server/wrapper.tsx: React component for server overview page
- database/migrations: Per-server tracking table

Platform Support (all implemented):
- CurseForge (BYOK API key)
- Modrinth (open, no key)
- Technic (open, no key)
- FTB/modpacks.ch (open, no key)

Detection Strategy:
1. Egg Variables (MODPACK_PLATFORM, MODPACK_ID, platform-specific vars)
2. File fingerprinting via DaemonFileRepository (manifest.json, modrinth.index.json)
3. Manual override via admin UI

Next: Phase 3 - Testing on Dev Panel (64.50.188.128)

Signed-off-by: Claude (Chronicler #62) <claude@firefrostgaming.com>
2026-04-06 00:35:01 +00:00
Claude (Chronicler #62)
1a97e82ec8 feat(arbiter): implement Task #87 - Lifecycle handlers with Discord role sync
WHAT THIS ADDS:
- Discord role sync on new subscriptions (checkout.session.completed)
- Discord role removal on chargebacks (charge.dispute.created)
- Grace period expiration job (hourly cron check)
- Automatic downgrade to Awakened when grace period expires

NEW FILES:
- src/services/discordRoleSync.js - Role add/remove/sync functions
- src/sync/graceExpiration.js - Grace period expiration processor

MODIFIED FILES:
- src/routes/stripe.js - Added role sync calls to webhook handlers
- src/discord/events.js - Initialize role sync service on bot ready
- src/sync/cron.js - Added grace period check to hourly job
- src/index.js - Import discordRoleSync service

PHILOSOPHY:
'We Don't Kick People Out' - expired grace periods downgrade to
permanent Awakened tier (tier 1, lifetime). Users keep community
access, just lose premium perks.

ROLE MAPPING (tier_level -> 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

CHARGEBACKS:
- Immediate role removal
- Added to banned_users table
- Full audit logging

Signed-off-by: Claude (Chronicler #62) <claude@firefrostgaming.com>
2026-04-05 14:25:41 +00:00
Claude (Chronicler #61)
bc66fec77a feat: PostgreSQL session store
Replaces MemoryStore with connect-pg-simple.
Sessions now persist across Arbiter restarts.
Table 'session' auto-created if missing.

Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 10:34:44 +00:00
Claude (Chronicler #61)
d9b54187ee fix: Normalize base_time to HH:mm:ss format
HTML time input sends HH:mm, but calculateStagger expects HH:mm:ss.

Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 10:30:36 +00:00
Claude (Chronicler #61)
3e4055c5dc fix: Add CSRF token to update-config form
Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 10:25:07 +00:00
Claude (Chronicler #61)
8a56c920db fix: Remove duplicate code block causing syntax error
Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 10:17:29 +00:00
Claude (Chronicler #61)
22a8a3f92d fix: Simplify audit to catch ALL non-Trinity schedules
Removed power task filter — Pterodactyl doesn't include task
relationships by default. Now catches any schedule not prefixed
with [Trinity].

Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 10:15:14 +00:00
Claude (Chronicler #61)
3ee303244e fix: Use server.identifier instead of server.id in import
Discovery returns 'identifier' field, not 'id'.

Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 10:11:26 +00:00
Claude (Chronicler #61)
71454946e5 fix: Remove EJS includes for express-ejs-layouts compatibility
express-ejs-layouts doesn't support nested includes.
Changed scheduler.ejs to inline the table HTML.
Changed routes to return raw HTML for HTMX partials instead of rendering.

Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 10:07:51 +00:00
Claude (Chronicler #61)
5e8201fd22 feat: Task #94 Global Restart Scheduler
Complete implementation of staggered restart scheduler for Trinity Console.

Database:
- global_restart_config: Node-wide settings (TX1 @ 04:00 UTC, NC1 @ 04:30 UTC)
- server_restart_schedules: Per-server state with sort order
- sync_logs: Audit trail for all sync operations

Backend:
- src/utils/scheduler.js: Stagger calculation with date-fns
- src/lib/ptero-sync.js: Pterodactyl API integration (create/update/delete/audit)
- src/routes/admin/scheduler.js: All CRUD + import + sync + audit routes

Frontend:
- Drag-and-drop server ordering (SortableJS)
- Per-node config cards with base time + interval
- Audit modal to detect and nuke rogue schedules
- Skip toggle for maintenance mode
- Visual sync status indicators

Features:
- Import servers from Pterodactyl discovery
- Recalculate effective times on reorder
- Rate-limited API calls (200ms delay)
- [Trinity] Daily Restart naming convention

Signed-off-by: Claude (Chronicler #61) <claude@firefrostgaming.com>
2026-04-05 09:58:52 +00:00
Claude (Chronicler #60)
2f67708fcf Add Sync All buttons functionality for server matrix
WHAT WAS DONE:
- Added POST /admin/servers/sync-all/:node endpoint
  - Accepts 'tx1' or 'nc1' as node parameter
  - Syncs whitelist to all servers on that node
  - Returns count of synced/errors

- Wired up buttons in index.ejs with htmx
  - hx-post to the new endpoint
  - Results display in #sync-result span

Files changed:
- services/arbiter-3.0/src/routes/admin/servers.js (+45 lines)
- services/arbiter-3.0/src/views/admin/servers/index.ejs

Signed-off-by: Claude (Chronicler #60) <claude@firefrostgaming.com>
2026-04-05 08:34:50 +00:00
Claude (Chronicler #60)
e23f44ad67 Restore nest filter for server discovery
WHAT WAS DONE:
- Re-added MINECRAFT_NEST_IDS filtering
- Keeps the node ID mapping fix (2→NC1, 3→TX1)

WHY:
Non-Minecraft servers were appearing in the matrix.
We need to filter to only show Minecraft servers.

Signed-off-by: Claude (Chronicler #60) <claude@firefrostgaming.com>
2026-04-05 08:32:07 +00:00
Claude (Chronicler #60)
62ddb8b8b6 Remove nest filter from server discovery
WHAT WAS DONE:
- Removed MINECRAFT_NEST_IDS filtering
- Now shows ALL servers from Pterodactyl, not just Minecraft nests

WHY:
Trinity Console should show all servers for management,
not just those in specific nests.

Signed-off-by: Claude (Chronicler #60) <claude@firefrostgaming.com>
2026-04-05 08:24:42 +00:00
Claude (Chronicler #60)
291b329067 Fix Task #91: Server matrix node detection
WHAT WAS DONE:
- discovery.js: Added node field to server objects
  - Maps Pterodactyl node ID 2 → NC1
  - Maps Pterodactyl node ID 3 → TX1
  - Also includes raw nodeId for debugging

- servers.js: Simplified grouping logic
  - Removed fallback checks for 'Node 2', 'Node 3', name patterns
  - Now uses clean s.node === 'TX1' / 'NC1' checks

THE BUG:
getMinecraftServers() was only returning identifier and name,
but the matrix filter was checking s.node which was undefined.
Servers were being grouped by name pattern fallback only.

Files changed:
- services/arbiter-3.0/src/panel/discovery.js (+8 lines)
- services/arbiter-3.0/src/routes/admin/servers.js (simplified)

Signed-off-by: Claude (Chronicler #60) <claude@firefrostgaming.com>
2026-04-05 08:23:14 +00:00
59 changed files with 7901 additions and 45 deletions

View 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** 💙🔥❄️

View 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
}
});
}
}
}

View 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** 🔥❄️

View 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

View File

@@ -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;

View File

@@ -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",

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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 };

View 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 };

View File

@@ -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);
});
}

View File

@@ -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) {

View 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
};

View File

@@ -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);

View 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;

View File

@@ -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;

View File

@@ -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');
}

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;
}

View 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
};

View File

@@ -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");
});
}

View 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 };

View File

@@ -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 };
}
}

View 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 };

View File

@@ -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>

View 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">&times;</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>
<% } %>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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* 🔥❄️💙

View 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* 🔥❄️💙

View File

@@ -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',
];
}
}

View File

@@ -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>

View File

@@ -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()])
);
}
}

View File

@@ -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);
}
}

View File

@@ -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',
];
}
}

View 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 ""

View File

@@ -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"

View File

@@ -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');
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

View File

@@ -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']);

View File

@@ -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;

View File

@@ -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;