rubot/README.md
2026-07-29 17:32:33 +00:00

194 lines
7.4 KiB
Markdown

# rustbot
A small, modular IRC bot in Rust — **zero dependencies**, with its own IRC
protocol layer 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/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 (`config.rs`, `protocol.rs`) against the public API |
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) 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() -> Vec<Box<dyn Module>> {
vec![
Box::new(builtins::Builtins),
Box::new(dice::Dice::new()),
Box::new(myfeature::MyFeature::new()), // <- your module
]
}
```
Shipped modules: **`builtins`** (`ping`, `echo`, `hello`), **`dice`**
(`roll [NdM]`), and **`weather`** (`add <location>` then `w [location]` via
wttr.in; per-user locations saved to a per-network JSON file, path from
`RUSTBOT_DATA_DIR`). Module tests live in `tests/` — construct a module and
assert on the `Action`s it returns, no network required.
## Build & run
```sh
cargo build # or: cargo build --release
cargo test # integration tests in tests/ (config + protocol)
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`, `password`, `sasl_user`,
`sasl_pass`, and `name` (a network's log label). Whole-line `#`/`;` comments
only — inline comments would clash with channel `#`s.
### 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_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.
## Built-in commands
With the default `!` prefix:
- `!ping``pong`
- `!echo <text>` → echoes text back
- `!hello` → greets the sender
- `!help` → lists commands
Add your own in `Bot::handle_command` in `src/bot.rs`.
## 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)
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).
## Roadmap / natural next steps
- **Read timeout + client-sent PING** to detect dead connections faster.
- **STARTTLS** (the `tls` cap) for networks without a dedicated TLS port.
- Split commands into a registry/trait once there are many of them.