|
|
||
|---|---|---|
| .forgejo/workflows | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.toml | ||
| README.md | ||
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 — a hero
with live stats, a top-3 podium, a ranked ledger with per-thing trend
sparklines, a recent-activity feed, and a top-givers board. Each network's board
shows where its karma comes from — server, channels, and each channel's IRC
topic (e.g. #argentina). 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 +
channels + topics, which the bot refreshes as it joins and as topics change),
and renders one self-contained HTML page (no CSS/JS/font dependencies,
all IRC-supplied text HTML-escaped). A systemd timer regenerates it every minute
and nginx serves it 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), 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
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).