Connect to several IRC networks at once. The config file gains optional `[network]` section headers: keys before the first header are shared defaults, and each section defines one network that inherits and overrides them. A file with no sections is a single network exactly as before, so existing configs are unaffected. main spawns one supervisor thread per network, each with its own connect/reconnect/backoff loop, so a failure on one network never disturbs the others. The blocking Bot/Connection are reused unchanged — no async runtime, no new dependencies. With more than one network, wire logs are tagged with the section name; a lone network stays unprefixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
5.4 KiB
Markdown
149 lines
5.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
|
|
|
|
| File | Layer | Responsibility |
|
|
|---------------|------------------|-----------------------------------------------------------|
|
|
| `src/irc.rs` | protocol + I/O | Parse/serialize IRC messages (incl. IRCv3 tags), TCP transport |
|
|
| `src/bot.rs` | behavior | Registration, event loop, command handling |
|
|
| `src/main.rs` | wiring | Layered config, one connect/reconnect supervisor thread per network |
|
|
|
|
The layers are deliberately separable: `irc.rs` knows nothing about the bot, and
|
|
`bot.rs` knows nothing about how the process is launched.
|
|
|
|
## Build & run
|
|
|
|
```sh
|
|
cargo build # or: cargo build --release
|
|
cargo test # unit tests for the message parser
|
|
cargo run
|
|
```
|
|
|
|
## Configuration
|
|
|
|
Settings are layered, each overriding the previous:
|
|
|
|
1. **Built-in defaults** (`Config::default()` in `src/bot.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.
|