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> |
||
|---|---|---|
| src | ||
| .gitignore | ||
| Cargo.toml | ||
| README.md | ||
rustbot
A small, modular IRC bot in Rust — zero dependencies, with its own IRC protocol layer built to the modern IRC client spec.
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
cargo build # or: cargo build --release
cargo test # unit tests for the message parser
cargo run
Configuration
Settings are layered, each overriding the previous:
- Built-in defaults (
Config::default()insrc/bot.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, 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.
# 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.
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.
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.
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+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).
Roadmap / natural next steps
- Read timeout + client-sent PING to detect dead connections faster.
- STARTTLS (the
tlscap) for networks without a dedicated TLS port. - Split commands into a registry/trait once there are many of them.