246 lines
10 KiB
Markdown
246 lines
10 KiB
Markdown
# 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<Action>;
|
|
}
|
|
```
|
|
|
|
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/<name>.rs` implementing `Module`,
|
|
then add one line to `all()` in `src/bot/modules/mod.rs`:
|
|
|
|
```rust
|
|
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.
|
|
|
|
```sh
|
|
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
|
|
|
|
```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). 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.
|
|
|
|
```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<dyn Read + Write>`, since the single-threaded event loop never needs the
|
|
old read/write socket split (which TLS can't do anyway).
|