# rustbot A small, modular IRC bot in Rust — **almost dependency-free** (only `native-tls` for the TLS transport), with a hand-rolled IRC protocol layer, HTTP client, JSON parser, and hashes, built to the [modern IRC client spec](https://modern.ircdocs.horse/). ## Structure rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top: | Path | Layer | Responsibility | |-----------------|----------------|-------------------------------------------------------------------| | `src/irc/` | protocol + I/O | `message` (parse IRCv3 wire format), `connection` (TCP/TLS transport), `encoding` (base64, server-time) | | `src/bot/` | behavior | `mod` (state + loop), `caps` (IRCv3 + SASL), `commands` (routing + help), `module` (plugin trait), `modules/` (feature modules) | | `src/config.rs` | configuration | Layered `Config`; multi-network `[section]` parsing | | `src/http.rs` | utility | Minimal HTTPS GET over `native-tls`, with retry | | `src/json.rs` | utility | Recursive-descent JSON parser | | `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library | | `src/main.rs` | binary | Thin per-network connect/reconnect supervisor | | `tests/` | tests | Integration tests against the public API, one file per subsystem | The layers are deliberately separable: `irc` knows nothing about the bot, `bot` knows nothing about how the process is launched, and `main` only supervises. Adding a feature is a new **module** — see [Modules](#modules). ## Modules Features are **modules**: self-contained units behind the `Module` trait (`src/bot/module.rs`). A module declares the commands it provides and turns each invocation into `Action`s — it never touches the socket, so it stays pure and unit-testable: ```rust pub trait Module { fn name(&self) -> &'static str; fn commands(&self) -> &'static [CommandSpec]; fn on_command(&mut self, cmd: &Command) -> Vec; } ``` At startup the bot builds a `command → module` route table and auto-generates `help` from every module's `CommandSpec`s. Each network gets its own fresh set of modules, so stateful ones (like the dice roller's PRNG or the weather cache) keep per-network state with no locking. **To add a module**, create `src/bot/modules/.rs` implementing `Module`, then add one line to `all()` in `src/bot/modules/mod.rs`: ```rust pub fn all(network: &str) -> Vec> { vec![ Box::new(builtins::Builtins), Box::new(dice::Dice::new()), Box::new(myfeature::MyFeature::new()), // <- your module ] } ``` Web modules fetch with the shared `http::get` and parse with `json::Json`; each has a pure formatter unit-tested against a canned API body (no network needed). ## Commands Triggered by the configured prefix (default `!`, set with `prefix`); `help` lists them at runtime and `help ` describes one. | Command | Does | |---|---| | `ping` / `echo ` / `hello` | basics | | `roll [NdM]` | roll dice (default `1d6`) | | `md5` / `sha1` / `sha256` / `hash ` | hex digests | | `add ` then `w [location]` | weather (wttr.in), per-user location | | `crypto ` | coin price in USD (Bitstamp) | | `wiki ` | Wikipedia summary | | `define ` / `urban ` | dictionary / Urban Dictionary | | `tinyurl ` | shorten a URL | | `down ` | is a site reachable? | ## Build & run ```sh cargo build # or: cargo build --release cargo test # integration tests in tests/ cargo run ``` ## Configuration Settings are layered, each overriding the previous: 1. **Built-in defaults** (`Config::default()` in `src/config.rs`) 2. **A config file** — `key = value`, see [`rustbot.conf`](rustbot.conf) 3. **Environment variables** — handy for one-off overrides The config file is found via: the **first CLI argument**, else `RUSTBOT_CONFIG`, else `./rustbot.conf` if present. ```sh cargo run # uses ./rustbot.conf cargo run -- /etc/rustbot.conf # explicit path RUSTBOT_CONFIG=/etc/rustbot.conf cargo run ``` Config file keys: `server`, `port`, `tls`, `nick`, `user`, `realname`, `channels` (comma/space-separated), `prefix`, `verbose` (wire logging, default on), `password`, `sasl_user`, `sasl_pass`, and `name` (a network's log label). Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. Web modules write per-network state under `RUSTBOT_DATA_DIR` (default `.`). ### Multiple networks Group keys under `[name]` section headers to connect to several networks at once — one supervisor thread each, with independent reconnect/backoff. Keys *before* the first header are shared defaults every network inherits; each section overrides them. A file with no headers is a single network (the historical behaviour), and its logs stay unprefixed. ```ini # shared defaults, inherited by every network below nick = rubot realname = rubot [tchatou] server = irc.tchatou.fr tls = true channels = #devs [libera] server = irc.libera.chat tls = true channels = #rust, #rust-beginners nick = rubot_ # override just for this network ``` With more than one network, each network's log lines are tagged with its section name (`[libera] << …`) so the interleaved output stays readable. Environment overrides: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`, `IRC_CHANNELS`, `IRC_PREFIX`, `IRC_VERBOSE`, `IRC_PASSWORD`, `IRC_SASL_USER`, `IRC_SASL_PASS`. ```sh IRC_NICK=mybot IRC_CHANNELS='#test' cargo run # override the file for one run ``` > If you put a real `password` in `rustbot.conf`, add it to `.gitignore` so the > secret isn't committed. ## Deployment (systemd) Runs as a system service (`User=debian`), auto-restarts on crash, and starts on boot. The unit is kept in the repo as [`rustbot.service`](rustbot.service). ```sh cargo build --release sudo cp rustbot.service /etc/systemd/system/rustbot.service sudo systemctl daemon-reload sudo systemctl enable --now rustbot # start now + on boot systemctl status rustbot # health journalctl -u rustbot -f # live logs (<< in, >> out; verbose=false to quiet) sudo systemctl restart rustbot # after a rebuild ``` One-off overrides without touching `rustbot.conf`: put `IRC_*` lines in `/etc/default/rustbot` (env wins over the config file), then restart. ## IRCv3 The bot negotiates capabilities (`CAP LS 302` → `CAP REQ` → `CAP END`) and enables everything useful the server offers: `message-tags`, `server-time`, `batch`, `echo-message`, `account-tag`, `extended-join`, `multi-prefix`, `away-notify`, `chghost`, `setname`, `userhost-in-names`, `cap-notify`, `labeled-response`. Two of these do real work: - **`server-time` + `batch`** let the bot recognise **replayed history**. On join, InspIRCd replays recent channel lines (wrapped in a `chathistory` batch). Without this the bot re-answered old commands on every reconnect; now such messages are ignored (see `Bot::is_historical`). - **`sasl`** (PLAIN) authenticates to services during negotiation. Set `sasl_user`/`sasl_pass` in the config (or `IRC_SASL_USER`/`IRC_SASL_PASS`). base64 is hand-rolled, so this stays dependency-free. ## TLS Set `tls = true` and the port defaults to 6697. The transport wraps the socket with `native-tls`, which verifies certificates against the **system OpenSSL trust store** — so TLS security fixes arrive via OS updates rather than a vendored crypto crate. Plaintext still works with `tls = false` / `port = 6667`. Plain TCP and TLS share one code path: `Connection` holds a single `Box`, since the single-threaded event loop never needs the old read/write socket split (which TLS can't do anyway).