rubot/README.md

10 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 / 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 <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?

Karma web page

The live karma board is published at https://karma.devtronic.pro. 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.

cargo build --release --bin karma-web
./target/release/karma-web <data-dir> <out.html> [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

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, 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). Whole-line #/; comments only — inline comments would clash with channel #s.

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.

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

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