rubot/README.md

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