Documentation menu

Discord Bot

The Citadel Discord bot provides full server management directly in your Discord server — interactive panels, slash commands, admin actions, mod management, and live feeds.

Setup

1. Create a Discord Application

  1. Go to the Discord Developer Portal
  2. Click New Application and name it "Citadel"
  3. Go to BotAdd Bot
  4. Copy the Bot Token
  5. Enable Message Content Intent under Privileged Gateway Intents

2. Invite the Bot

Generate an invite URL under OAuth2 → URL Generator:

  • Scopes: bot, applications.commands
  • Permissions: Send Messages, Embed Links, Use External Emojis, Read Message History, Use Slash Commands

3. Configure Environment Variables

Add the following to your .env:

DISCORD_BOT_TOKEN=your-bot-token
DISCORD_CLIENT_ID=your-application-client-id
DISCORD_GUILD_ID=your-discord-server-id
DISCORD_ADMIN_ROLE_ID=your-admin-role-id
DISCORD_BOT_API_KEY=a-random-secret-key
VariableRequiredDescription
DISCORD_BOT_TOKENYesBot token from the Developer Portal
DISCORD_CLIENT_IDYesApplication ID (OAuth2 page)
DISCORD_GUILD_IDNoYour Discord server ID (for guild-scoped command registration — faster updates)
DISCORD_ADMIN_ROLE_IDYesDiscord role ID that grants admin actions. If not set, all admin actions are denied (fail-closed)
DISCORD_BOT_API_KEYYesShared secret for bot-to-backend API authentication

4. Run the Bot

# Standalone
node discord-bot/bot.js

# With PM2
pm2 start discord-bot/bot.js --name citadel-bot

# As part of the Windows Service (runs with the backend)
npm run service:install

Architecture

The bot is organized into a modular file structure:

discord-bot/
├── bot.js              # Entry point — client, router, presence, shutdown
├── config.js           # Environment configuration
├── api.js              # Backend API client with user attribution
├── commands/           # 17 slash commands (auto-loaded by index.js)
│   ├── index.js        # Auto-loader + registerCommands()
│   ├── panel.js        # /panel — ephemeral control panel
│   ├── setup.js        # /setup — persistent panel in channel
│   ├── status.js       # /status
│   ├── players.js      # /players
│   ├── rcon.js         # /rcon
│   ├── broadcast.js    # /broadcast
│   ├── restart.js      # /restart
│   ├── playerinfo.js   # /playerinfo
│   ├── heal.js         # /heal
│   ├── kill.js         # /kill
│   ├── teleport.js     # /teleport
│   ├── spawnitem.js    # /spawnitem
│   ├── unstuck.js      # /unstuck
│   ├── freeze.js       # /freeze
│   ├── strip.js        # /strip
│   ├── explode.js      # /explode
│   └── dm.js           # /dm
├── handlers/
│   ├── buttons.js      # 40 button handlers (dispatch map)
│   ├── selectMenus.js  # Server/category/player selects
│   └── modals.js       # Modal submission handlers
├── ui/
│   ├── embeds.js       # Embed builders (status, players, errors, etc.)
│   ├── components.js   # Buttons, modals, select menus, action rows
│   └── colors.js       # Color palette
└── utils/
    ├── permissions.js   # Admin role check
    ├── cooldowns.js     # Per-user per-action cooldown system
    ├── formatting.js    # Playtime, uptime, progress bars
    └── sanitize.js      # Input validation & markdown escaping

Commands

General Commands

CommandDescriptionAdmin
/panelOpen an ephemeral interactive control panelNo
/setupDeploy a persistent control panel in the current channelYes
/statusQuick server status check (CPU, RAM, FPS, players, uptime)No
/playersView all online playersNo

Server Commands

CommandDescriptionAdmin
/rcon <command>Execute a BattlEye RCON commandYes
/broadcast <message>Send a message to all online playersYes
/restart [countdown]Restart the server (now, 60s, or 5m countdown)Yes

Admin Action Commands

CommandDescriptionAdmin
/playerinfo <steamid>Look up player stats, sessions, K/D ratioYes
/heal <steamid>Heal a player to full healthYes
/kill <steamid>Kill a playerYes
/teleport <steamid> <x> <y> [z]Teleport a player to coordinatesYes
/spawnitem <steamid> <item> [qty]Spawn an item on a player (max qty: 100)Yes
/unstuck <steamid>Teleport a stuck player to the terrain surfaceYes
/freeze <steamid> [unfreeze]Freeze or unfreeze a player in placeYes
/strip <steamid>Strip all gear from a playerYes
/explode <steamid>Explode a playerYes
/dm <steamid> <message>Send a direct in-game message to a playerYes

Interactive Control Panel

The /panel and /setup commands deploy a rich interactive panel with:

Core Buttons

  • Status — Refresh the server status embed
  • Start / Stop / Restart — Server lifecycle controls with confirmation dialogs

Category Dropdown

Select a category to reveal its action buttons:

CategoryActions
ServerLock, Unlock, Broadcast, RCON
PlayersPlayer List, Kick Player, Player Info
ModsMod List, Install, Uninstall, Enable, Disable
IntelChat Feed, Killfeed, Leaderboard, Watchlist, Priority Queue, Time/Weather
Admin ActionsHeal, Unstuck, Spawn Item, Teleport, Message, Freeze, Strip Gear, Kill, Explode (via player select menus)

Multi-Server Support

If multiple servers are configured, a server selector dropdown appears at the top of the panel. Switching servers updates all subsequent actions to target the selected server.

Security

The bot sits behind three independent policy layers. Each one is enforceable on its own; together they answer "is this Discord user allowed to run this Citadel action right now?"

Layer 1 — Citadel role permissions

Every /api/discord/action call is gated against a built-in Citadel role named discord-bot. Out of the box this role has * (all permissions) so existing deployments work unchanged after the upgrade — but you can narrow it from Settings → Users & Roles to only the actions the bot should be able to drive. A common starter floor:

PermissionWhat it grants
server.viewStatus / player list reads
server.restart/restart command
players.kick/kick
chat.send/broadcast, /dm

Each Discord-driven action maps to a specific permission inside the backend's ACTION_PERMISSIONS table. If the discord-bot role doesn't grant that permission, the call is rejected with a discord.denied audit-log row and the bot returns a "permission denied" embed to the user.

The legacy DISCORD_ADMIN_ROLE_ID Discord-side check still runs as well — both layers must allow the action.

Layer 2 — Verified attribution (HMAC)

Each call from the bot to /api/discord/action is signed with HMAC-SHA256 over (timestamp, action, discordUserId) using a shared bot secret, sent as:

X-Discord-Ts:  <unix-seconds>
X-Discord-Sig: <hex-hmac>

The backend verifies before processing. Mismatched or missing signatures return 403 with a discord.sig-rejected audit row. Legacy bots that don't yet send these headers still work but get tagged as "Discord Bot (unverified)" in the audit log so you can spot un-upgraded fleets at a glance.

The win is audit-trail integrity: an attacker who steals DISCORD_BOT_API_KEY can no longer impersonate a specific Discord user — they can only call the API as themselves, and even that gets flagged as unverified.

Layer 3 — Per-Discord-user role mapping

By default every Discord user that passes Layer 1 + Layer 2 acts with the discord-bot role's permission set. To grant or restrict specific Discord users beyond that floor, map them to a different Citadel role via data/discord-user-roles.json. The mapping is managed through the API:

MethodPathDescription
GET/api/discord/user-rolesList all per-user mappings
PUT/api/discord/user-roles/:discordUserIdSet a mapping (body { "role": "moderator" })
DELETE/api/discord/user-roles/:discordUserIdRemove a mapping (user falls back to the discord-bot role floor)

All three require the calling Citadel user to have users.manage permission. Mapping changes are recorded as discord.user-role.set / discord.user-role.remove audit events.

A practical example: pin your head moderator's Discord ID to a moderator Citadel role with broader permissions than discord-bot, while everyone else stays on the bot-floor permissions you set in Layer 1.

Cooldown System

Per-user per-action cooldowns prevent command spam (this runs alongside the three layers above, not instead of them):

TierCooldownActions
Query3 secondsStatus, players, mods, intel feeds, leaderboard
Admin10 secondsHeal, kill, teleport, spawn, unstuck, freeze, strip, explode, message, kick, RCON, broadcast, mod operations
Control30 secondsStart, stop, restart

Input Validation

All user inputs are validated before reaching the backend:

  • Steam64 IDs — Must be a 17-digit number starting with 7656119
  • Coordinates — Must be finite numbers
  • Workshop IDs — Must be numeric strings (up to 15 digits)
  • Broadcast messages — Control characters stripped, limited to 256 characters
  • Player names — Markdown characters escaped in embeds to prevent formatting exploits

Audit Trail

Every action from Discord is logged in the backend audit system with the Discord user's tag and ID, plus a flag for whether the call was HMAC-verified (Layer 2). Actions appear in the Citadel audit log alongside web panel actions, providing a unified activity record. See the Audit Log Codes reference for the full list of discord.* action strings.

API Authentication

The bot authenticates to the backend using a shared DISCORD_BOT_API_KEY with timing-safe comparison. This key is separate from user JWT tokens. Layer 2's HMAC then binds each individual call to a specific Discord user on top of that — knowing the API key alone doesn't let you forge attributed actions.

Bot Presence

The bot's Discord status automatically updates every 60 seconds:

  • Online — Shows total players across all running servers (e.g., "12/120 players | 2 servers")
  • Idle — Shows "All servers offline" when no servers are running

Troubleshooting

Bot doesn't respond to commands

  • Verify DISCORD_BOT_TOKEN and DISCORD_CLIENT_ID are correct in .env
  • Ensure the bot has been invited with applications.commands scope
  • Check that the backend is running and accessible at the configured PANEL_API_URL

Admin commands say "Admin role required"

  • Set DISCORD_ADMIN_ROLE_ID in .env to the ID of your admin Discord role
  • Ensure the user has that role assigned in Discord
  • Right-click the role in Discord → Copy ID (enable Developer Mode in Discord settings)

Commands timeout with no response

  • The backend API must be reachable from the bot. Default: http://localhost:3001
  • Check that DISCORD_BOT_API_KEY matches between the bot's .env and the backend's .env

"Cooldown active" messages

  • Wait for the cooldown to expire (3s for queries, 10s for admin, 30s for controls)
  • Each user has independent cooldowns — other users are not affected