Rusty — Technical Documentation
Full technical reference for the Rusty Discord bot. See overview.md for the feature and command guide.
Table of Contents
- System Overview
- Admin Permission System
- RCON Integration
- DeepSeaWatcher Plugin
- Deep Ocean State Logic
- Background Loops
- Data Files
- Log Files
- Wanted Board
- Events System
- Ping Role Menu
- 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
| Role | How set | Access |
|---|---|---|
| Owner | BOT_OWNER_ID in .env | All commands including /adminadd, /adminremove, /admins |
| Admin | Added via /adminadd, stored in data/admins.json | All admin commands |
| Everyone | Default | Public 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:
| Key | Written when |
|---|---|
ocean_open | Every successful poll |
ocean_opened_at | Estimated from now - (open_duration - remaining) |
ocean_closes_at | Every poll while open — always current |
ocean_closed_at | On open→closed transition, or estimated from opened_at + open_minutes on startup |
ocean_next_open_at | Every 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.
| Loop | Interval | What it does |
|---|---|---|
_player_loop | 60s | Fetches player list via RCON, upserts embed |
_ocean_loop | 30s | Rebuilds Deep Ocean embed, upserts to channel |
_wipe_loop | 300s | Rebuilds wipe countdown embed, upserts to channel |
_status_loop | 30s | Rotates bot presence through 4 statuses |
_rcon_ocean_loop | 30s | Polls deepsea.status, writes to state.json |
_rcon_broadcast_loop | persistent | Listens 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:
X / Y players onlineDeep Ocean: OPENorOcean opens in Xh YmWipe in X daysorWIPE DAY!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).
| File | Contents |
|---|---|
admins.json | Array of admin user ID strings |
events.json | Array of event objects |
register.json | Object keyed by Steam64 — full Discord + Steam identity records |
roles.json | Array of ping role configuration entries |
rules.json | Array of {title, body} rule objects |
state.json | Mixed persistent state — message IDs, ocean timestamps, wipe date |
wanted.json | Array 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.logon 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
| Value | Display | Note |
|---|---|---|
doa | Dead or Alive | Skull required if brought in dead |
dead | Dead | Skull required as proof |
alive | Alive | Target 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 toclaimed, DMs the posterwanted:close— poster or admin only. Sets status toclosed, 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
| Status | Colour |
|---|---|
| Upcoming | Blue #5865F2 |
| Live | Green #57F287 |
| Ended | Grey #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:
| Event | Listener | Filter |
|---|---|---|
| Message deleted | on_message_delete | Skips bots and the register channel |
| Message edited | on_message_edit | Only logs if content actually changed |
| Slash command used | on_interaction | Only InteractionType.application_command |
All embeds include the user's avatar, display name, channel, and UTC timestamp.