MercuryHQ / Docs
/ Discord Bots / Rusty / Technical

Rusty — Technical Documentation

Full technical reference for the Rusty Discord bot. See overview.md for the feature and command guide.


Table of Contents

  1. System Overview
  2. Admin Permission System
  3. RCON Integration
  4. DeepSeaWatcher Plugin
  5. Deep Ocean State Logic
  6. Background Loops
  7. Data Files
  8. Log Files
  9. Wanted Board
  10. Events System
  11. Ping Role Menu
  12. Mod Logging

System Overview

Rusty is a discord.py 2.3+ bot using a cog-based architecture. Each feature lives in its own cog under cogs/. The entry point is main.py, which runs a crash-recovery loop — if the bot crashes for any reason it restarts automatically after 10 seconds.

main.py  →  setup_hook  →  loads all cogs  →  syncs slash commands  →  on_ready
                                                                            ↓
                                                                     starts loops
                                                                     (player, ocean,
                                                                      wipe, status,
                                                                      RCON broadcast)

All configuration is loaded from .env via config.py. Optional channels return None when not set — every feature checks for this and silently skips if disabled.

File Structure

.
├── main.py                 # Bot class, crash recovery, coloured logging, error handler
├── config.py               # All env loading — _require() for mandatory, _optional_int() optional
├── cogs/
│   ├── admin.py            # /adminadd, /adminremove, /admins (owner_only)
│   ├── events.py           # /event, /cancelevent, /events + auto live/ended loop
│   ├── live.py             # Player, ocean, wipe, status, RCON loops
│   ├── logs.py             # on_message_delete, on_message_edit, on_interaction → mod log
│   ├── roles.py            # Button ping role menu + on_interaction handler
│   ├── roster.py           # Register channel watcher, /roster, /whois, notes, etc.
│   ├── rules.py            # Rule management
│   └── wanted.py           # Wanted board, WantedView (persistent), expiry loop
├── utils/
│   ├── admin.py            # is_owner(), is_admin(), admin_only(), owner_only()
│   ├── battlemetrics.py    # Player list fetch + embed builder (wraps RCON, not BM API)
│   ├── deep_ocean.py       # get_deep_ocean_status(), build_deep_ocean_embed()
│   ├── game_logs.py        # Rotating log files — deaths/chat/connections/admin/startup
│   ├── rcon.py             # Raw TCP WebSocket RCON — send_command(), listen_broadcasts()
│   ├── steam.py            # is_valid_steam64(), fetch_steam_name()
│   ├── storage.py          # load_json(), save_json(), path constants
│   ├── views.py            # ConfirmView (Yes/No prompt)
│   └── wipe.py             # get_wipe_date(), build_wipe_embed()
├── plugins/
│   └── DeepSeaWatcher.cs   # Oxide plugin — adds deepsea.status RCON command
├── docs/
│   ├── overview.md         # Features and command reference
│   └── technical.md        # This file
└── data/                   # Runtime JSON (auto-created)

Admin Permission System

Rusty uses a custom permission system entirely independent of Discord's built-in permissions.

Roles

RoleHow setAccess
OwnerBOT_OWNER_ID in .envAll commands including /adminadd, /adminremove, /admins
AdminAdded via /adminadd, stored in data/admins.jsonAll admin commands
EveryoneDefaultPublic commands only

Implementation

utils/admin.py provides four exports:

is_owner(user_id)   # str/int → bool — checks against BOT_OWNER_ID
is_admin(user_id)   # str/int → bool — is_owner() OR id in admins.json
admin_only()        # app_commands.check() decorator for admin commands
owner_only()        # app_commands.check() decorator for owner-only commands

When a check fails, main.py's on_app_command_error catches the CheckFailure and sends an ephemeral "no permission" message. is_admin() is also called directly in WantedView.close_btn where a decorator isn't available.


RCON Integration

Rust's RCON uses WebSocket but sends an invalid Sec-WebSocket-Accept header, which causes standard WebSocket libraries to reject the handshake. Rusty uses a raw asyncio TCP implementation in utils/rcon.py that performs the WebSocket upgrade manually and skips header validation.

send_command

response = await rcon.send_command(host, port, password, "command")

Opens a TCP connection, performs the WebSocket handshake, authenticates with the password, sends one command, reads the JSON response, closes. Used for one-shot queries like deepsea.status and serverinfo.

listen_broadcasts

await rcon.listen_broadcasts(host, port, password, on_broadcast, on_connect=None)

Maintains a persistent connection. Reconnects automatically on disconnect with exponential backoff. Calls on_broadcast(message) for every incoming message with identifier in (0, -1). Calls on_connect() on each successful connection — used to detect server restarts.

Message format

All RCON messages arrive as JSON:

{"Identifier": 0, "Message": "text", "Type": "Generic", "Stacktrace": ""}

Chat messages use Identifier: -1 and the Message field is a nested JSON string:

{"Channel": 0, "Message": "hello", "UserId": "76561198012345678", "Username": "Player"}

Channel values: 0 = Global, 1 = Team, 2 = Server.


DeepSeaWatcher Plugin

The Oxide plugin at plugins/DeepSeaWatcher.cs must be installed in the server's oxide/plugins/ directory.

How it works

On load, the plugin searches all loaded assemblies at runtime for DeepSeaManager, DeepSea, or DeepSeaController — whichever Facepunch named it in the current Rust build. It uses reflection to read TimeToWipe (seconds remaining while open) and TimeToNextOpening (seconds until next opening).

Because TimeToNextOpening can reset to 0 after a plugin reload or server restart mid-cycle, the plugin also tracks its own _openedAtUnix and _closedAtUnix Unix timestamps. When TimeToNextOpening is 0, deepsea.status falls back to calculating time from _closedAtUnix + CLOSED_DURATION_SECS.

RCON command

deepsea.status
→ "true:9240"         open, closes in 9240 seconds
→ "false:5400"        closed, opens in 5400 seconds
→ "error:not_ready"   plugin failed to initialise

Broadcast messages

On state change, the plugin outputs to the server console (picked up by the RCON broadcast listener in LiveCog):

DEEPSEA_OPEN:9240
DEEPSEA_CLOSED:5400

It also sends a coloured in-game Server.Broadcast to all players.

Configuration

The only hardcoded value is CLOSED_DURATION_SECS = 5400 (90 minutes). If your server uses a different closed duration, update this constant before uploading.

Installation

Copy DeepSeaWatcher.cs to oxide/plugins/. Oxide compiles and loads it automatically. Check the server console for:

DeepSeaWatcher loaded — initial state: OPEN/CLOSED

Deep Ocean State Logic

The bot determines Deep Ocean state using three sources in priority order. The source is selected at runtime in utils/deep_ocean.py.

Priority 1 — RCON state (ground truth)

When RCON is configured and has polled at least once (ocean_open key exists in data/state.json), this is used exclusively. _poll_ocean_rcon() in LiveCog runs every 30 seconds and writes:

KeyWritten when
ocean_openEvery successful poll
ocean_opened_atEstimated from now - (open_duration - remaining)
ocean_closes_atEvery poll while open — always current
ocean_closed_atOn open→closed transition, or estimated from opened_at + open_minutes on startup
ocean_next_open_atEvery poll while closed — always current

Priority 2 — Cycle math

If RCON is not configured or has never polled, and OCEAN_CYCLE_START is set in .env, the bot calculates all window times mathematically from the known anchor time and cycle durations:

Cycle = OCEAN_OPEN_MINUTES open → OCEAN_CLOSED_MINUTES closed → repeat
elapsed = (now - OCEAN_CYCLE_START) % cycle_length

Priority 3 — Schedule fallback

DEEP_OCEAN_SCHEDULE in config.py — a hardcoded list of ("HH:MM", "HH:MM") UTC windows. Least reliable as it ignores server restarts and cycle drift.


Background Loops

All loops start from LiveCog.on_ready. Each runs as an independent asyncio task.

LoopIntervalWhat it does
_player_loop60sFetches player list via RCON, upserts embed
_ocean_loop30sRebuilds Deep Ocean embed, upserts to channel
_wipe_loop300sRebuilds wipe countdown embed, upserts to channel
_status_loop30sRotates bot presence through 4 statuses
_rcon_ocean_loop30sPolls deepsea.status, writes to state.json
_rcon_broadcast_looppersistentListens for RCON broadcasts, parses game events

Embed upsert pattern

_upsert(channel_id, msg_id, embed) tries to edit an existing message. On NotFound (message deleted) it posts a fresh one and saves the new ID. Message IDs are persisted to data/state.json via bot.save_state() so they survive restarts.

Bot status rotation

Cycles every 30 seconds:

  1. X / Y players online
  2. Deep Ocean: OPEN or Ocean opens in Xh Ym
  3. Wipe in X days or WIPE DAY!
  4. Up Xh Ym (bot uptime since last start)

Server restart detection

The broadcast listener's on_connect callback fires on every successful RCON connection. The first connection on startup is skipped. All subsequent connections indicate a server restart — game_logs.rotate_all() is called to begin fresh log files.


Data Files

All files live in data/ and are created automatically on first run by load_json(path, default).

FileContents
admins.jsonArray of admin user ID strings
events.jsonArray of event objects
register.jsonObject keyed by Steam64 — full Discord + Steam identity records
roles.jsonArray of ping role configuration entries
rules.jsonArray of {title, body} rule objects
state.jsonMixed persistent state — message IDs, ocean timestamps, wipe date
wanted.jsonArray of bounty entries

register.json schema

{
  "76561198012345678": {
    "steam64": "76561198012345678",
    "steam_name": "PlayerName",
    "discord_id": "123456789012345678",
    "discord_username": "username",
    "discord_avatar": "https://cdn.discordapp.com/avatars/...",
    "linked_at": "2026-05-14T12:00:00+00:00",
    "notes": [
      { "text": "...", "added_by": "admin", "added_at": "2026-05-14T12:00:00+00:00" }
    ]
  }
}

state.json schema

{
  "player_msg_id": 123456789,
  "ocean_msg_id": 123456789,
  "wipe_msg_id": 123456789,
  "roles_msg_id": 123456789,
  "wipe_date": "2026-06-01T18:00:00+00:00",
  "ocean_open": false,
  "ocean_opened_at": 1778695481,
  "ocean_closes_at": 1778706281,
  "ocean_closed_at": 1778706281,
  "ocean_next_open_at": 1778711681
}

Log Files

RCON broadcast events are parsed and written to rotating files under logs/. Each category has its own subfolder.

logs/
  startup/      startup.log
  chat/         chat.log
  deaths/       deaths.log
  connections/  connections.log
  admin/        admin.log

Rotation

  • Game logs (chat, deaths, connections, admin) — rotate on server restart detection
  • Startup log — rotates on every bot start
  • Current file renamed to name.YYYY-MM-DD_HH-MM-SS.log on rotation
  • Maximum 9 archived files per category (10 total)
  • Oldest pruned automatically
  • On Windows, if the rename fails due to a file lock (VS Code, antivirus), the file is copied to the archive name and truncated in-place

Log line format

2026-05-14 12:00:00 | KILL    | PlayerA[123] → PlayerB[456] at (100, 200, 300)
2026-05-14 12:00:01 | GLOBAL  | PlayerA[123]: hello
2026-05-14 12:00:02 | JOIN    | PlayerA[123]
2026-05-14 12:00:03 | LEAVE   | PlayerA[123] — disconnected

Wanted Board

Bounty types

ValueDisplayNote
doaDead or AliveSkull required if brought in dead
deadDeadSkull required as proof
aliveAliveTarget must be returned alive

Status flow

open → claimed → closed
  ↓
expired  (if expires_at is set and time passes)

Persistent buttons

WantedView is registered at startup via bot.add_view(WantedView()) so buttons survive restarts. Uses stable custom_id values:

  • wanted:claim — any user can claim. Sets status to claimed, DMs the poster
  • wanted:close — poster or admin only. Sets status to closed, removes buttons

Expiry loop

WantedCog._expiry_loop (tasks.loop, every 1 minute) checks all active bounties with expires_at set. On expiry, sets status to expired and edits the Discord embed.


Events System

Status flow

upcoming → live → ended

EventsCog._event_loop (tasks.loop, every 1 minute) compares start_ts and end_ts against current Unix time. On any transition the Discord embed is edited in-place.

Embed colours

StatusColour
UpcomingBlue #5865F2
LiveGreen #57F287
EndedGrey #555555

Ping Role Menu

RolesCog handles role assignment via on_interaction, listening for any component interaction with a custom_id starting with role:. It extracts the role ID, checks membership, and adds or removes the role. The response is always ephemeral.

This differs from WantedView — roles use dynamic custom_id values (role:{role_id}) handled in a listener rather than a registered persistent View, because the set of roles changes at runtime.

The menu embed and buttons are regenerated and the existing message is edited whenever /addpingrole or /removepingrole is run.


Mod Logging

LogsCog listens to three Discord events and posts formatted embeds to MOD_LOG_CHANNEL_ID:

EventListenerFilter
Message deletedon_message_deleteSkips bots and the register channel
Message editedon_message_editOnly logs if content actually changed
Slash command usedon_interactionOnly InteractionType.application_command

All embeds include the user's avatar, display name, channel, and UTC timestamp.