rubot/README.md
2026-07-29 20:44:26 +00:00

7.9 KiB

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.

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

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 Actions — it never touches the socket, so it stays pure and unit-testable:

pub trait Module {
    fn name(&self) -> &'static str;
    fn commands(&self) -> &'static [CommandSpec];
    fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
}

At startup the bot builds a command → module route table and auto-generates help from every module's CommandSpecs. 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/<name>.rs implementing Module, then add one line to all() in src/bot/modules/mod.rs:

pub fn all(network: &str) -> Vec<Box<dyn Module>> {
    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 <command> describes one.

Command Does
ping / echo <text> / hello basics
roll [NdM] roll dice (default 1d6)
karma [thing] / karmabottom (bump with thing++ / thing--) karma + rank, or the top/bottom list
md5 / sha1 / sha256 / hash <text> hex digests
add <location> then w [location] weather (wttr.in), per-user location
crypto <sym> coin price in USD (Bitstamp)
wiki <term> Wikipedia summary
define <word> / urban <term> dictionary / Urban Dictionary
tinyurl <url> shorten a URL
down <host> is a site reachable?

Build & run

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 filekey = value, see 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.

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.

# 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.

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.

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 302CAP REQCAP 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<dyn Read + Write>, since the single-threaded event loop never needs the old read/write socket split (which TLS can't do anyway).