11 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:
- Built-in defaults (
Config::default()insrc/config.rs) - A config file —
key = value, seerustbot.conf - 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), and admin (nicks allowed to run rehash; see
Live reload). 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, andlocale/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.
[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
passwordinrustbot.conf, add it to.gitignoreso 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 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+batchlet the bot recognise replayed history. On join, InspIRCd replays recent channel lines (wrapped in achathistorybatch). Without this the bot re-answered old commands on every reconnect; now such messages are ignored (seeBot::is_historical).sasl(PLAIN) authenticates to services during negotiation. Setsasl_user/sasl_passin the config (orIRC_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).