# 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`) | | `karma [thing]` / `karmabottom` / `karmaweb` (bump with `thing++ [reason]` / `thing--`) | karma + rank + last reason, or top/bottom (with a link to the web board when `karma_url` is set); `karmaweb` prints that link; milestones announced | | `md5` / `sha1` / `sha256` / `hash ` | hex digests | | `add ` then `w [location]` / `fc [location]` | weather + 3-day forecast (wttr.in), per-user location | | `crypto ` / `coins` | coin price in USD (Bitstamp) / top-10 by market cap (CoinGecko) | | `wiki ` | Wikipedia summary | | `define ` / `urban ` | dictionary / Urban Dictionary | | `dict` / `definitions` / `thes` / `jargon` / `term` / `acronym` / `element` / `bible` / `biblename` / `law` `` | DICT (RFC 2229) lookups on dict.org — WordNet, GCIDE, Moby thesaurus, Jargon File, FOLDOC, VERA, elements, Easton/Hitchcock, Bouvier | | `tinyurl ` | shorten a URL | | `down ` | is a site reachable? | | `worldtime ` | current time in a timezone | | `8ball ` / `rock` / `paper` / `scissors` | magic 8-ball, rock-paper-scissors | | `joke` / `fact` / `catfact` / `advice` / `catgif` | random fun (keyless APIs) | | `bofh` / `devexcuse` / `chuck` / `startup` / `buzz` | excuse & buzzword generators | | `coffee [nick]` / `ty [nick]` / `drinks` | social actions | ## Karma web page The live karma board is published at ****. It has a hero with live stats, a top-3 podium, a ranked ledger (filter + sort, click a row to expand its full history), per-thing trend sparklines, a "rising this week" strip, a karma-over-time chart, a recent-activity feed, top givers, most debated, and a milestones timeline. Each network's board shows where its karma comes from — an online/offline dot, server, joinable channel links, live user counts, and each channel's IRC topic (URLs linkified). The page carries Open Graph tags for rich link previews, and the same data is exposed as JSON at `/karma.json`. A second binary, `karma-web`, reads the per-network `karma.*.json` totals, the append-only `karma.*.events.jsonl` history the bot writes on every bump, and a `karma.*.meta.json` source sidecar (server, webchat base, a liveness heartbeat, and per-channel topic + user count, which the bot refreshes as it joins, on topic/NAMES changes, and on each server PING). It renders one self-contained HTML page (no CSS/JS/font dependencies, all IRC-supplied text HTML-escaped) plus `karma.json`. A systemd timer regenerates them every minute and nginx serves over TLS. ```sh cargo build --release --bin karma-web ./target/release/karma-web [locale] # locale: en|es|fr (default en) ``` On mars: `karma-web.service` + `karma-web.timer` write `/var/www/karma/index.html`, served by the `karma.devtronic.pro.conf` vhost. ## 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`, `name` (a network's log label), and `locale` (`en`/`es`/`fr`, default `en`), `webchat` (per-network webchat base for the board's channel "join" links, e.g. `https://web.libera.chat/`), and `karma_url` (public web board — enables the `karmaweb` command and a link on the `karma`/`karmabottom` top lists; empty disables both), and `admin` (nicks allowed to run `rehash`; see [Live reload](#live-reload-rehash)). Whole-line `#`/`;` comments only — inline comments would clash with channel `#`s. ### Live reload (rehash) Editing `rustbot.conf` doesn't require a restart. An admin sends **`rehash`** (the command prefix + `rehash`) in a channel or PM and the bot re-reads the file and hot-applies this network's section: - **live**: `channels` (JOIN new / PART removed), `nick`, `prefix`, `verbose`, and `locale` / `karma_url` / `webchat` (modules are re-instantiated). - **needs a restart**: `server`, `port`, `tls`, SASL — the summary says so. Set `admin = yournick` (comma/space list) to authorize; unauthorized attempts are ignored (and logged). Adding a *new* module or a whole new `[network]` still needs a rebuild/restart — new compiled code can't load at runtime. ### Locales `locale` sets a network's language, so a community sees the bot in its own tongue — e.g. `#argentina` on libera runs in Spanish. It currently drives the `karma` module's replies and milestone announcements (English, Spanish, French); other modules fall back to English. The web board takes its own locale as `karma-web`'s third argument (or `RUSTBOT_LOCALE`), so the page can match the audience. ```ini [libera] channels = #argentina locale = es # karma replies + milestones in Spanish ``` 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).