Restructure into a library crate with modular bot and irc
Split the single-binary crate into a reusable `rustbot` library (src/lib.rs)
plus a thin supervisor binary. The monolithic irc.rs and bot.rs become module
folders:
irc/ message (wire parsing), connection (TCP/TLS transport), encoding
bot/ mod (state + event loop), caps (IRCv3 cap negotiation + SASL),
commands (message routing + command table)
config Config + layered loading, moved out of main.rs and bot.rs
main.rs is now just the per-network connect/reconnect supervisor over the
library API. Unit tests move out of the source files into tests/ integration
tests (config.rs, protocol.rs) that drive the public surface — prefix parsing
is now covered through Message::parse, and config layering through the new
public config::parse_str. Behavior is unchanged; no new dependencies.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e7e805cb87
commit
eb3b8fec40
16 changed files with 1189 additions and 1116 deletions
|
|
@ -6,11 +6,15 @@ description = "A modular, dependency-free IRC bot in Rust with a hand-rolled IRC
|
|||
license = "MIT"
|
||||
authors = ["a.srenov"]
|
||||
|
||||
[lib]
|
||||
name = "rustbot"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rustbot"
|
||||
path = "src/main.rs"
|
||||
|
||||
# The IRC protocol layer is hand-rolled in `src/irc.rs` (modern IRC client
|
||||
# The IRC protocol layer is hand-rolled in `src/irc/` (modern IRC client
|
||||
# spec: https://modern.ircdocs.horse/). The sole dependency is native-tls for
|
||||
# the optional TLS transport; it wraps the system OpenSSL, so TLS security
|
||||
# fixes arrive through normal OS updates rather than a vendored crypto crate.
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -5,20 +5,26 @@ protocol layer built to the [modern IRC client spec](https://modern.ircdocs.hors
|
|||
|
||||
## 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 |
|
||||
rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top:
|
||||
|
||||
The layers are deliberately separable: `irc.rs` knows nothing about the bot, and
|
||||
`bot.rs` knows nothing about how the process is launched.
|
||||
| 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 + event loop), `caps` (IRCv3 cap negotiation + SASL), `commands` (message routing + command table) |
|
||||
| `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 command is usually a one-file change in `src/bot/commands.rs`.
|
||||
|
||||
## Build & run
|
||||
|
||||
```sh
|
||||
cargo build # or: cargo build --release
|
||||
cargo test # unit tests for the message parser
|
||||
cargo test # integration tests in tests/ (config + protocol)
|
||||
cargo run
|
||||
```
|
||||
|
||||
|
|
@ -26,7 +32,7 @@ cargo run
|
|||
|
||||
Settings are layered, each overriding the previous:
|
||||
|
||||
1. **Built-in defaults** (`Config::default()` in `src/bot.rs`)
|
||||
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
|
||||
|
||||
|
|
|
|||
351
src/bot.rs
351
src/bot.rs
|
|
@ -1,351 +0,0 @@
|
|||
//! Bot behavior: configuration, registration, the event loop, and commands.
|
||||
//!
|
||||
//! This layer is transport-agnostic in spirit: it drives a [`Connection`] from
|
||||
//! `irc.rs` and turns incoming [`Message`]s into actions. To add a feature you
|
||||
//! usually only touch [`Bot::handle`] or [`Bot::handle_command`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
|
||||
use crate::irc::{base64_encode, now_unix, parse_server_time, Connection, Message};
|
||||
|
||||
/// IRCv3 capabilities we request when the server offers them. `sasl` is added
|
||||
/// separately, only when credentials are configured.
|
||||
const WANTED_CAPS: &[&str] = &[
|
||||
"message-tags",
|
||||
"server-time",
|
||||
"batch",
|
||||
"echo-message",
|
||||
"account-tag",
|
||||
"extended-join",
|
||||
"multi-prefix",
|
||||
"away-notify",
|
||||
"chghost",
|
||||
"setname",
|
||||
"userhost-in-names",
|
||||
"cap-notify",
|
||||
"labeled-response",
|
||||
];
|
||||
|
||||
/// A message whose `server-time` is older than this is treated as replayed
|
||||
/// history. Wide enough to absorb network latency and modest clock skew, but
|
||||
/// far below the age of real chat history (minutes to weeks).
|
||||
const HISTORY_GRACE_SECS: u64 = 60;
|
||||
|
||||
/// Everything the bot needs to know to connect and behave.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Short label for this network, used in logs and as the thread name. Set
|
||||
/// via an INI-style `[section]` header (or a `name =` key); defaults to the
|
||||
/// server host. Only one network? It stays out of the logs entirely.
|
||||
pub name: String,
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
/// Connect over TLS (typically port 6697) instead of plaintext.
|
||||
pub tls: bool,
|
||||
pub nick: String,
|
||||
pub user: String,
|
||||
pub realname: String,
|
||||
pub channels: Vec<String>,
|
||||
/// Prefix that marks a chat message as a bot command, e.g. `!`.
|
||||
pub command_prefix: String,
|
||||
/// Optional server password (`PASS`). Not the same as SASL/NickServ.
|
||||
pub password: Option<String>,
|
||||
/// SASL PLAIN credentials. When both are set (and the server offers `sasl`),
|
||||
/// the bot authenticates during capability negotiation.
|
||||
pub sasl_user: Option<String>,
|
||||
pub sasl_pass: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Config {
|
||||
Config {
|
||||
name: String::new(),
|
||||
server: "irc.tchatou.fr".to_string(),
|
||||
port: 6667,
|
||||
tls: false,
|
||||
nick: "rubot".to_string(),
|
||||
user: "rubot".to_string(),
|
||||
realname: "rubot".to_string(),
|
||||
channels: vec!["#devs".to_string()],
|
||||
command_prefix: "!".to_string(),
|
||||
password: None,
|
||||
sasl_user: None,
|
||||
sasl_pass: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bot: owns its configuration and connection, and runs one session.
|
||||
pub struct Bot {
|
||||
config: Config,
|
||||
conn: Connection,
|
||||
/// The nick we are currently trying to use (may drift from `config.nick`
|
||||
/// if the server reports a collision).
|
||||
current_nick: String,
|
||||
registered: bool,
|
||||
/// Capabilities the server advertised in `CAP LS`.
|
||||
available_caps: HashSet<String>,
|
||||
/// Capabilities we successfully negotiated (`CAP ACK`).
|
||||
enabled_caps: HashSet<String>,
|
||||
/// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`).
|
||||
batches: HashMap<String, String>,
|
||||
/// Whether the SASL exchange has begun this session.
|
||||
sasl_started: bool,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
pub fn new(config: Config, conn: Connection) -> Bot {
|
||||
let current_nick = config.nick.clone();
|
||||
Bot {
|
||||
config,
|
||||
conn,
|
||||
current_nick,
|
||||
registered: false,
|
||||
available_caps: HashSet::new(),
|
||||
enabled_caps: HashSet::new(),
|
||||
batches: HashMap::new(),
|
||||
sasl_started: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register with the server, then process messages until disconnect.
|
||||
///
|
||||
/// Returns `Ok(())` when the server closes the connection cleanly; the
|
||||
/// caller (main) decides whether to reconnect.
|
||||
pub fn run(&mut self) -> io::Result<()> {
|
||||
self.register()?;
|
||||
while let Some(msg) = self.conn.read_message()? {
|
||||
self.handle(&msg)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Begin registration: open IRCv3 capability negotiation, then send
|
||||
/// NICK/USER. The server withholds `001` until we send `CAP END`, which
|
||||
/// happens once caps (and optional SASL) are settled — see [`Bot::handle`].
|
||||
fn register(&mut self) -> io::Result<()> {
|
||||
if let Some(password) = self.config.password.clone() {
|
||||
self.conn.pass(&password)?;
|
||||
}
|
||||
self.conn.cap_ls()?;
|
||||
let nick = self.current_nick.clone();
|
||||
self.conn.nick(&nick)?;
|
||||
let (user, realname) = (self.config.user.clone(), self.config.realname.clone());
|
||||
self.conn.user(&user, &realname)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a single incoming message.
|
||||
fn handle(&mut self, msg: &Message) -> io::Result<()> {
|
||||
match msg.command.as_str() {
|
||||
// Keep-alive: reply immediately or the server will time us out.
|
||||
"PING" => {
|
||||
let token = msg.trailing().unwrap_or("").to_string();
|
||||
self.conn.pong(&token)?;
|
||||
}
|
||||
// IRCv3 capability negotiation and SASL.
|
||||
"CAP" => self.handle_cap(msg)?,
|
||||
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
||||
"903" => self.conn.cap_end()?, // RPL_SASLSUCCESS
|
||||
// SASL failed/aborted/locked/too-long/already-authed — proceed anyway.
|
||||
"902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?,
|
||||
// Track open/closed batches so we can recognise replayed history.
|
||||
"BATCH" => self.handle_batch(msg),
|
||||
// RPL_WELCOME: registration succeeded — now it's safe to join.
|
||||
"001" => {
|
||||
self.registered = true;
|
||||
for channel in self.config.channels.clone() {
|
||||
self.conn.join(&channel)?;
|
||||
}
|
||||
}
|
||||
// ERR_NICKNAMEINUSE: append an underscore and try again.
|
||||
"433" => {
|
||||
self.current_nick.push('_');
|
||||
let nick = self.current_nick.clone();
|
||||
self.conn.nick(&nick)?;
|
||||
}
|
||||
"PRIVMSG" => self.handle_privmsg(msg)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle a `CAP` reply: collect advertised caps, request the ones we want,
|
||||
/// and record what was acknowledged.
|
||||
fn handle_cap(&mut self, msg: &Message) -> io::Result<()> {
|
||||
// :server CAP <target> <subcommand> [*] :<space-separated caps>
|
||||
match msg.param(1) {
|
||||
Some("LS") => {
|
||||
// A "*" in the third field means the list continues in more lines.
|
||||
let more = msg.param(2) == Some("*");
|
||||
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
||||
// Caps may be advertised as "name=value" (e.g. sasl=PLAIN).
|
||||
let name = cap.split('=').next().unwrap_or(cap);
|
||||
self.available_caps.insert(name.to_string());
|
||||
}
|
||||
if !more {
|
||||
self.request_caps()?;
|
||||
}
|
||||
}
|
||||
Some("ACK") => {
|
||||
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
||||
self.enabled_caps
|
||||
.insert(cap.trim_start_matches(['-', '~', '=']).to_string());
|
||||
}
|
||||
self.after_cap_ack()?;
|
||||
}
|
||||
Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on
|
||||
_ => {} // NEW/DEL: ignored for now
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Request the subset of [`WANTED_CAPS`] the server actually offers, plus
|
||||
/// `sasl` when credentials are configured. Ends negotiation if none apply.
|
||||
fn request_caps(&mut self) -> io::Result<()> {
|
||||
let mut wanted: Vec<&str> = WANTED_CAPS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| self.available_caps.contains(*c))
|
||||
.collect();
|
||||
if self.sasl_credentials().is_some() && self.available_caps.contains("sasl") {
|
||||
wanted.push("sasl");
|
||||
}
|
||||
if wanted.is_empty() {
|
||||
self.conn.cap_end()
|
||||
} else {
|
||||
self.conn.cap_req(&wanted.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
/// After caps are acknowledged, either start SASL or finish negotiation.
|
||||
fn after_cap_ack(&mut self) -> io::Result<()> {
|
||||
if self.enabled_caps.contains("sasl")
|
||||
&& self.sasl_credentials().is_some()
|
||||
&& !self.sasl_started
|
||||
{
|
||||
self.sasl_started = true;
|
||||
self.conn.authenticate("PLAIN") // server replies with "AUTHENTICATE +"
|
||||
} else {
|
||||
self.conn.cap_end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Respond to the server's SASL prompt with base64 PLAIN credentials.
|
||||
fn handle_authenticate(&mut self, msg: &Message) -> io::Result<()> {
|
||||
if msg.param(0) == Some("+") {
|
||||
if let Some((user, pass)) = self.sasl_credentials() {
|
||||
// SASL PLAIN payload: authzid \0 authcid \0 passwd (authzid empty).
|
||||
let encoded = base64_encode(format!("\0{user}\0{pass}").as_bytes());
|
||||
self.conn.authenticate(&encoded)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Track IRCv3 batches so [`Bot::is_historical`] can spot replayed history.
|
||||
fn handle_batch(&mut self, msg: &Message) {
|
||||
// :server BATCH +<ref> <type> [params] opens; BATCH -<ref> closes.
|
||||
let Some(reference) = msg.param(0) else { return };
|
||||
if let Some(name) = reference.strip_prefix('+') {
|
||||
self.batches
|
||||
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
|
||||
} else if let Some(name) = reference.strip_prefix('-') {
|
||||
self.batches.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
fn sasl_credentials(&self) -> Option<(String, String)> {
|
||||
match (&self.config.sasl_user, &self.config.sasl_pass) {
|
||||
(Some(u), Some(p)) => Some((u.clone(), p.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this message replayed history rather than something happening now?
|
||||
/// Two independent signals: membership in a chathistory/playback BATCH, or a
|
||||
/// `server-time` tag older than [`HISTORY_GRACE_SECS`].
|
||||
fn is_historical(&self, msg: &Message) -> bool {
|
||||
if let Some(reference) = msg.tag("batch") {
|
||||
if let Some(btype) = self.batches.get(reference) {
|
||||
if btype.contains("chathistory") || btype.contains("playback") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(stamp) = msg.tag("time").and_then(parse_server_time) {
|
||||
if now_unix().saturating_sub(stamp) > HISTORY_GRACE_SECS {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Handle a channel or private message, routing recognised commands.
|
||||
fn handle_privmsg(&mut self, msg: &Message) -> io::Result<()> {
|
||||
// Ignore replayed channel history (InspIRCd's on-join playback), or the
|
||||
// bot would re-answer old commands on every reconnect.
|
||||
if self.is_historical(msg) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(target) = msg.param(0) else { return Ok(()) };
|
||||
let text = msg.trailing().unwrap_or("");
|
||||
let sender = msg.nick().unwrap_or("");
|
||||
|
||||
// Never react to our own messages. Some networks (like this one) echo
|
||||
// channel messages back to the sender; without this guard a command
|
||||
// whose reply starts with the prefix could trigger the bot on itself.
|
||||
if sender.eq_ignore_ascii_case(&self.current_nick) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reply in-channel for channel messages, or back to the user for DMs.
|
||||
let reply_to = if is_channel(target) {
|
||||
target.to_string()
|
||||
} else {
|
||||
sender.to_string()
|
||||
};
|
||||
|
||||
// Only react to text that starts with the configured command prefix.
|
||||
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut parts = rest.split_whitespace();
|
||||
let command = parts.next().unwrap_or("").to_lowercase();
|
||||
let args: Vec<&str> = parts.collect();
|
||||
self.handle_command(&reply_to, sender, &command, &args)
|
||||
}
|
||||
|
||||
/// The command table. Add new bot commands here.
|
||||
fn handle_command(
|
||||
&mut self,
|
||||
reply_to: &str,
|
||||
sender: &str,
|
||||
command: &str,
|
||||
args: &[&str],
|
||||
) -> io::Result<()> {
|
||||
match command {
|
||||
"ping" => self.conn.privmsg(reply_to, "pong")?,
|
||||
"echo" => self.conn.privmsg(reply_to, &args.join(" "))?,
|
||||
"hello" => {
|
||||
let text = format!("hello, {sender}!");
|
||||
self.conn.privmsg(reply_to, &text)?;
|
||||
}
|
||||
"help" => {
|
||||
let p = &self.config.command_prefix;
|
||||
let text = format!("commands: {p}ping, {p}echo <text>, {p}hello, {p}help");
|
||||
self.conn.privmsg(reply_to, &text)?;
|
||||
}
|
||||
_ => {} // Unknown command: stay silent rather than spam channels.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels start with one of the standard channel-type prefixes.
|
||||
fn is_channel(target: &str) -> bool {
|
||||
target.starts_with(['#', '&', '+', '!'])
|
||||
}
|
||||
109
src/bot/caps.rs
Normal file
109
src/bot/caps.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//! IRCv3 capability negotiation and SASL PLAIN authentication.
|
||||
//!
|
||||
//! These are methods on [`Bot`]; they are `pub(super)` where the event loop in
|
||||
//! [`super`] dispatches to them, and private otherwise.
|
||||
|
||||
use std::io;
|
||||
|
||||
use super::Bot;
|
||||
use crate::irc::{base64_encode, Message};
|
||||
|
||||
/// IRCv3 capabilities we request when the server offers them. `sasl` is added
|
||||
/// separately, only when credentials are configured.
|
||||
const WANTED_CAPS: &[&str] = &[
|
||||
"message-tags",
|
||||
"server-time",
|
||||
"batch",
|
||||
"echo-message",
|
||||
"account-tag",
|
||||
"extended-join",
|
||||
"multi-prefix",
|
||||
"away-notify",
|
||||
"chghost",
|
||||
"setname",
|
||||
"userhost-in-names",
|
||||
"cap-notify",
|
||||
"labeled-response",
|
||||
];
|
||||
|
||||
impl Bot {
|
||||
/// Handle a `CAP` reply: collect advertised caps, request the ones we want,
|
||||
/// and record what was acknowledged.
|
||||
pub(super) fn handle_cap(&mut self, msg: &Message) -> io::Result<()> {
|
||||
// :server CAP <target> <subcommand> [*] :<space-separated caps>
|
||||
match msg.param(1) {
|
||||
Some("LS") => {
|
||||
// A "*" in the third field means the list continues in more lines.
|
||||
let more = msg.param(2) == Some("*");
|
||||
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
||||
// Caps may be advertised as "name=value" (e.g. sasl=PLAIN).
|
||||
let name = cap.split('=').next().unwrap_or(cap);
|
||||
self.available_caps.insert(name.to_string());
|
||||
}
|
||||
if !more {
|
||||
self.request_caps()?;
|
||||
}
|
||||
}
|
||||
Some("ACK") => {
|
||||
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
||||
self.enabled_caps
|
||||
.insert(cap.trim_start_matches(['-', '~', '=']).to_string());
|
||||
}
|
||||
self.after_cap_ack()?;
|
||||
}
|
||||
Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on
|
||||
_ => {} // NEW/DEL: ignored for now
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Request the subset of [`WANTED_CAPS`] the server actually offers, plus
|
||||
/// `sasl` when credentials are configured. Ends negotiation if none apply.
|
||||
fn request_caps(&mut self) -> io::Result<()> {
|
||||
let mut wanted: Vec<&str> = WANTED_CAPS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|c| self.available_caps.contains(*c))
|
||||
.collect();
|
||||
if self.sasl_credentials().is_some() && self.available_caps.contains("sasl") {
|
||||
wanted.push("sasl");
|
||||
}
|
||||
if wanted.is_empty() {
|
||||
self.conn.cap_end()
|
||||
} else {
|
||||
self.conn.cap_req(&wanted.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
/// After caps are acknowledged, either start SASL or finish negotiation.
|
||||
fn after_cap_ack(&mut self) -> io::Result<()> {
|
||||
if self.enabled_caps.contains("sasl")
|
||||
&& self.sasl_credentials().is_some()
|
||||
&& !self.sasl_started
|
||||
{
|
||||
self.sasl_started = true;
|
||||
self.conn.authenticate("PLAIN") // server replies with "AUTHENTICATE +"
|
||||
} else {
|
||||
self.conn.cap_end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Respond to the server's SASL prompt with base64 PLAIN credentials.
|
||||
pub(super) fn handle_authenticate(&mut self, msg: &Message) -> io::Result<()> {
|
||||
if msg.param(0) == Some("+") {
|
||||
if let Some((user, pass)) = self.sasl_credentials() {
|
||||
// SASL PLAIN payload: authzid \0 authcid \0 passwd (authzid empty).
|
||||
let encoded = base64_encode(format!("\0{user}\0{pass}").as_bytes());
|
||||
self.conn.authenticate(&encoded)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sasl_credentials(&self) -> Option<(String, String)> {
|
||||
match (&self.config.sasl_user, &self.config.sasl_pass) {
|
||||
(Some(u), Some(p)) => Some((u.clone(), p.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/bot/commands.rs
Normal file
115
src/bot/commands.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//! Message routing and the command table — the part you extend most.
|
||||
//!
|
||||
//! [`Bot::handle_privmsg`] filters out replayed history and the bot's own
|
||||
//! echo, then routes prefixed commands to [`Bot::handle_command`]. Add new
|
||||
//! commands there.
|
||||
|
||||
use std::io;
|
||||
|
||||
use super::Bot;
|
||||
use crate::irc::{now_unix, parse_server_time, Message};
|
||||
|
||||
/// A message whose `server-time` is older than this is treated as replayed
|
||||
/// history. Wide enough to absorb network latency and modest clock skew, but
|
||||
/// far below the age of real chat history (minutes to weeks).
|
||||
const HISTORY_GRACE_SECS: u64 = 60;
|
||||
|
||||
impl Bot {
|
||||
/// Track IRCv3 batches so [`Bot::is_historical`] can spot replayed history.
|
||||
pub(super) fn handle_batch(&mut self, msg: &Message) {
|
||||
// :server BATCH +<ref> <type> [params] opens; BATCH -<ref> closes.
|
||||
let Some(reference) = msg.param(0) else { return };
|
||||
if let Some(name) = reference.strip_prefix('+') {
|
||||
self.batches
|
||||
.insert(name.to_string(), msg.param(1).unwrap_or("").to_string());
|
||||
} else if let Some(name) = reference.strip_prefix('-') {
|
||||
self.batches.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this message replayed history rather than something happening now?
|
||||
/// Two independent signals: membership in a chathistory/playback BATCH, or a
|
||||
/// `server-time` tag older than [`HISTORY_GRACE_SECS`].
|
||||
fn is_historical(&self, msg: &Message) -> bool {
|
||||
if let Some(reference) = msg.tag("batch") {
|
||||
if let Some(btype) = self.batches.get(reference) {
|
||||
if btype.contains("chathistory") || btype.contains("playback") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(stamp) = msg.tag("time").and_then(parse_server_time) {
|
||||
if now_unix().saturating_sub(stamp) > HISTORY_GRACE_SECS {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Handle a channel or private message, routing recognised commands.
|
||||
pub(super) fn handle_privmsg(&mut self, msg: &Message) -> io::Result<()> {
|
||||
// Ignore replayed channel history (InspIRCd's on-join playback), or the
|
||||
// bot would re-answer old commands on every reconnect.
|
||||
if self.is_historical(msg) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(target) = msg.param(0) else { return Ok(()) };
|
||||
let text = msg.trailing().unwrap_or("");
|
||||
let sender = msg.nick().unwrap_or("");
|
||||
|
||||
// Never react to our own messages. Some networks (like this one) echo
|
||||
// channel messages back to the sender; without this guard a command
|
||||
// whose reply starts with the prefix could trigger the bot on itself.
|
||||
if sender.eq_ignore_ascii_case(&self.current_nick) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Reply in-channel for channel messages, or back to the user for DMs.
|
||||
let reply_to = if is_channel(target) {
|
||||
target.to_string()
|
||||
} else {
|
||||
sender.to_string()
|
||||
};
|
||||
|
||||
// Only react to text that starts with the configured command prefix.
|
||||
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut parts = rest.split_whitespace();
|
||||
let command = parts.next().unwrap_or("").to_lowercase();
|
||||
let args: Vec<&str> = parts.collect();
|
||||
self.handle_command(&reply_to, sender, &command, &args)
|
||||
}
|
||||
|
||||
/// The command table. Add new bot commands here.
|
||||
fn handle_command(
|
||||
&mut self,
|
||||
reply_to: &str,
|
||||
sender: &str,
|
||||
command: &str,
|
||||
args: &[&str],
|
||||
) -> io::Result<()> {
|
||||
match command {
|
||||
"ping" => self.conn.privmsg(reply_to, "pong")?,
|
||||
"echo" => self.conn.privmsg(reply_to, &args.join(" "))?,
|
||||
"hello" => {
|
||||
let text = format!("hello, {sender}!");
|
||||
self.conn.privmsg(reply_to, &text)?;
|
||||
}
|
||||
"help" => {
|
||||
let p = &self.config.command_prefix;
|
||||
let text = format!("commands: {p}ping, {p}echo <text>, {p}hello, {p}help");
|
||||
self.conn.privmsg(reply_to, &text)?;
|
||||
}
|
||||
_ => {} // Unknown command: stay silent rather than spam channels.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels start with one of the standard channel-type prefixes.
|
||||
fn is_channel(target: &str) -> bool {
|
||||
target.starts_with(['#', '&', '+', '!'])
|
||||
}
|
||||
112
src/bot/mod.rs
Normal file
112
src/bot/mod.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//! Bot behavior: the [`Bot`] state, registration, and the event loop.
|
||||
//!
|
||||
//! This layer is transport-agnostic in spirit: it drives an [`crate::irc::Connection`]
|
||||
//! and turns incoming [`Message`]s into actions. The behaviour is split across
|
||||
//! sibling modules — capability negotiation and SASL live in [`caps`], and
|
||||
//! message routing plus the command table live in [`commands`]. To add a
|
||||
//! feature you usually only touch [`commands`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::irc::{Connection, Message};
|
||||
|
||||
mod caps;
|
||||
mod commands;
|
||||
|
||||
/// The bot: owns its configuration and connection, and runs one session.
|
||||
pub struct Bot {
|
||||
config: Config,
|
||||
conn: Connection,
|
||||
/// The nick we are currently trying to use (may drift from `config.nick`
|
||||
/// if the server reports a collision).
|
||||
current_nick: String,
|
||||
registered: bool,
|
||||
/// Capabilities the server advertised in `CAP LS`.
|
||||
available_caps: HashSet<String>,
|
||||
/// Capabilities we successfully negotiated (`CAP ACK`).
|
||||
enabled_caps: HashSet<String>,
|
||||
/// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`).
|
||||
batches: HashMap<String, String>,
|
||||
/// Whether the SASL exchange has begun this session.
|
||||
sasl_started: bool,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
pub fn new(config: Config, conn: Connection) -> Bot {
|
||||
let current_nick = config.nick.clone();
|
||||
Bot {
|
||||
config,
|
||||
conn,
|
||||
current_nick,
|
||||
registered: false,
|
||||
available_caps: HashSet::new(),
|
||||
enabled_caps: HashSet::new(),
|
||||
batches: HashMap::new(),
|
||||
sasl_started: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register with the server, then process messages until disconnect.
|
||||
///
|
||||
/// Returns `Ok(())` when the server closes the connection cleanly; the
|
||||
/// caller (the supervisor) decides whether to reconnect.
|
||||
pub fn run(&mut self) -> io::Result<()> {
|
||||
self.register()?;
|
||||
while let Some(msg) = self.conn.read_message()? {
|
||||
self.handle(&msg)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Begin registration: open IRCv3 capability negotiation, then send
|
||||
/// NICK/USER. The server withholds `001` until we send `CAP END`, which
|
||||
/// happens once caps (and optional SASL) are settled — see [`Bot::handle`].
|
||||
fn register(&mut self) -> io::Result<()> {
|
||||
if let Some(password) = self.config.password.clone() {
|
||||
self.conn.pass(&password)?;
|
||||
}
|
||||
self.conn.cap_ls()?;
|
||||
let nick = self.current_nick.clone();
|
||||
self.conn.nick(&nick)?;
|
||||
let (user, realname) = (self.config.user.clone(), self.config.realname.clone());
|
||||
self.conn.user(&user, &realname)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dispatch a single incoming message.
|
||||
fn handle(&mut self, msg: &Message) -> io::Result<()> {
|
||||
match msg.command.as_str() {
|
||||
// Keep-alive: reply immediately or the server will time us out.
|
||||
"PING" => {
|
||||
let token = msg.trailing().unwrap_or("").to_string();
|
||||
self.conn.pong(&token)?;
|
||||
}
|
||||
// IRCv3 capability negotiation and SASL (see `caps`).
|
||||
"CAP" => self.handle_cap(msg)?,
|
||||
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
||||
"903" => self.conn.cap_end()?, // RPL_SASLSUCCESS
|
||||
// SASL failed/aborted/locked/too-long/already-authed — proceed anyway.
|
||||
"902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?,
|
||||
// Track open/closed batches so we can recognise replayed history.
|
||||
"BATCH" => self.handle_batch(msg),
|
||||
// RPL_WELCOME: registration succeeded — now it's safe to join.
|
||||
"001" => {
|
||||
self.registered = true;
|
||||
for channel in self.config.channels.clone() {
|
||||
self.conn.join(&channel)?;
|
||||
}
|
||||
}
|
||||
// ERR_NICKNAMEINUSE: append an underscore and try again.
|
||||
"433" => {
|
||||
self.current_nick.push('_');
|
||||
let nick = self.current_nick.clone();
|
||||
self.conn.nick(&nick)?;
|
||||
}
|
||||
"PRIVMSG" => self.handle_privmsg(msg)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
275
src/config.rs
Normal file
275
src/config.rs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
//! Configuration: the [`Config`] one network needs, and how a config file
|
||||
//! (with optional `[network]` sections) plus environment variables are layered
|
||||
//! into one [`Config`] per network.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::log;
|
||||
|
||||
/// Everything the bot needs to know to connect to one network and behave.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Short label for this network, used in logs and as the thread name. Set
|
||||
/// via an INI-style `[section]` header (or a `name =` key); defaults to the
|
||||
/// server host. Only one network? It stays out of the logs entirely.
|
||||
pub name: String,
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
/// Connect over TLS (typically port 6697) instead of plaintext.
|
||||
pub tls: bool,
|
||||
pub nick: String,
|
||||
pub user: String,
|
||||
pub realname: String,
|
||||
pub channels: Vec<String>,
|
||||
/// Prefix that marks a chat message as a bot command, e.g. `!`.
|
||||
pub command_prefix: String,
|
||||
/// Optional server password (`PASS`). Not the same as SASL/NickServ.
|
||||
pub password: Option<String>,
|
||||
/// SASL PLAIN credentials. When both are set (and the server offers `sasl`),
|
||||
/// the bot authenticates during capability negotiation.
|
||||
pub sasl_user: Option<String>,
|
||||
pub sasl_pass: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Config {
|
||||
Config {
|
||||
name: String::new(),
|
||||
server: "irc.tchatou.fr".to_string(),
|
||||
port: 6667,
|
||||
tls: false,
|
||||
nick: "rubot".to_string(),
|
||||
user: "rubot".to_string(),
|
||||
realname: "rubot".to_string(),
|
||||
channels: vec!["#devs".to_string()],
|
||||
command_prefix: "!".to_string(),
|
||||
password: None,
|
||||
sasl_user: None,
|
||||
sasl_pass: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `key = value` pair with its 1-based source line, for diagnostics.
|
||||
type Entry = (usize, String, String);
|
||||
|
||||
/// Build one [`Config`] per network by layering sources.
|
||||
///
|
||||
/// The config file is INI-flavoured: `key = value` lines, optionally grouped
|
||||
/// under `[network]` section headers. Keys *before* the first header are shared
|
||||
/// defaults; each `[section]` defines one network that inherits those defaults
|
||||
/// and overrides them. A file with **no** sections yields a single network —
|
||||
/// exactly the historical behaviour.
|
||||
///
|
||||
/// Precedence within a network: built-in defaults < shared file defaults <
|
||||
/// environment variables < that network's section.
|
||||
pub fn load() -> Vec<Config> {
|
||||
let path = config_path();
|
||||
let content = path.as_ref().and_then(|p| match fs::read_to_string(p) {
|
||||
Ok(s) => {
|
||||
log(&format!("loaded config from {}", p.display()));
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
log(&format!("could not read config {}: {e}", p.display()));
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let (toplevel, sections) = match &content {
|
||||
Some(text) => parse_sections(text),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
// Shared defaults: built-ins, then top-level keys, then environment. Each
|
||||
// network is derived from this base.
|
||||
let mut base = Config::default();
|
||||
for (line, key, value) in &toplevel {
|
||||
apply_kv(&mut base, key, value, &loc(&path, *line));
|
||||
}
|
||||
apply_env(&mut base);
|
||||
|
||||
assemble(base, sections, &path)
|
||||
}
|
||||
|
||||
/// Parse networks from config *text* alone — no file access, no environment.
|
||||
/// Handy for tests and for embedding rustbot. Warnings reference `line N`.
|
||||
pub fn parse_str(content: &str) -> Vec<Config> {
|
||||
let (toplevel, sections) = parse_sections(content);
|
||||
let mut base = Config::default();
|
||||
for (line, key, value) in &toplevel {
|
||||
apply_kv(&mut base, key, value, &format!("line {line}"));
|
||||
}
|
||||
assemble(base, sections, &None)
|
||||
}
|
||||
|
||||
/// Derive one finished [`Config`] per network from the shared `base` and the
|
||||
/// parsed sections. With no sections, `base` itself is the single network.
|
||||
/// `path` is used only to describe locations in warnings.
|
||||
fn assemble(base: Config, sections: Vec<(String, Vec<Entry>)>, path: &Option<PathBuf>) -> Vec<Config> {
|
||||
let mut configs = Vec::new();
|
||||
if sections.is_empty() {
|
||||
let mut c = base;
|
||||
finalize(&mut c);
|
||||
configs.push(c);
|
||||
} else {
|
||||
for (name, entries) in sections {
|
||||
let mut c = base.clone();
|
||||
c.name = name;
|
||||
for (line, key, value) in &entries {
|
||||
apply_kv(&mut c, key, value, &loc(path, *line));
|
||||
}
|
||||
finalize(&mut c);
|
||||
configs.push(c);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
/// Per-network finishing touches: default the label to the server host, and
|
||||
/// honour the TLS-port convention (6697) when TLS is on but the port is still
|
||||
/// the plaintext default.
|
||||
fn finalize(c: &mut Config) {
|
||||
if c.name.is_empty() {
|
||||
c.name = c.server.clone();
|
||||
}
|
||||
if c.tls && c.port == 6667 {
|
||||
c.port = 6697;
|
||||
}
|
||||
}
|
||||
|
||||
/// Split config text into shared top-level entries and `[name]` sections.
|
||||
///
|
||||
/// Blank lines and whole-line `#`/`;` comments are ignored. A `[name]` line
|
||||
/// opens a section; entries after it belong to that network until the next
|
||||
/// header. Lines that are neither a header nor `key = value` are reported and
|
||||
/// skipped.
|
||||
fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
||||
let mut toplevel: Vec<Entry> = Vec::new();
|
||||
let mut sections: Vec<(String, Vec<Entry>)> = Vec::new();
|
||||
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
let lineno = i + 1;
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
// Section header: `[name]`.
|
||||
if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
sections.push((inner.trim().to_string(), Vec::new()));
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
log(&format!("line {lineno}: ignoring line without '='"));
|
||||
continue;
|
||||
};
|
||||
let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
match sections.last_mut() {
|
||||
Some((_, entries)) => entries.push(entry),
|
||||
None => toplevel.push(entry),
|
||||
}
|
||||
}
|
||||
(toplevel, sections)
|
||||
}
|
||||
|
||||
/// Apply a single `key = value` pair onto `c`. `where_` describes the source
|
||||
/// location for warnings (e.g. `rustbot.conf:12`).
|
||||
fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
||||
match key {
|
||||
"name" => c.name = value.to_string(),
|
||||
"server" => c.server = value.to_string(),
|
||||
"port" => match value.parse() {
|
||||
Ok(p) => c.port = p,
|
||||
Err(_) => log(&format!("{where_}: invalid port '{value}'")),
|
||||
},
|
||||
"tls" => c.tls = parse_bool(value),
|
||||
"nick" => c.nick = value.to_string(),
|
||||
"user" => c.user = value.to_string(),
|
||||
"realname" => c.realname = value.to_string(),
|
||||
"channels" => c.channels = split_list(value),
|
||||
"prefix" | "command_prefix" => c.command_prefix = value.to_string(),
|
||||
"password" => c.password = (!value.is_empty()).then(|| value.to_string()),
|
||||
"sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()),
|
||||
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
|
||||
other => log(&format!("{where_}: unknown key '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a source location for a line, including the file path when known.
|
||||
fn loc(path: &Option<PathBuf>, line: usize) -> String {
|
||||
match path {
|
||||
Some(p) => format!("{}:{}", p.display(), line),
|
||||
None => format!("line {line}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which config file to read: CLI argument, then `RUSTBOT_CONFIG`, then
|
||||
/// `./rustbot.conf` if it happens to exist. Returns `None` when there is none.
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
if let Some(arg) = std::env::args().nth(1) {
|
||||
return Some(PathBuf::from(arg));
|
||||
}
|
||||
if let Ok(env) = std::env::var("RUSTBOT_CONFIG") {
|
||||
return Some(PathBuf::from(env));
|
||||
}
|
||||
let default = PathBuf::from("rustbot.conf");
|
||||
default.exists().then_some(default)
|
||||
}
|
||||
|
||||
/// Overlay environment variables onto `c` (applied to the shared defaults, so
|
||||
/// they affect every network unless a `[section]` overrides them).
|
||||
///
|
||||
/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`,
|
||||
/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`,
|
||||
/// `IRC_SASL_USER`, `IRC_SASL_PASS`.
|
||||
fn apply_env(c: &mut Config) {
|
||||
if let Ok(v) = std::env::var("IRC_SERVER") {
|
||||
c.server = v;
|
||||
}
|
||||
if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) {
|
||||
c.port = p;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_TLS") {
|
||||
c.tls = parse_bool(&v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_NICK") {
|
||||
c.user = v.clone();
|
||||
c.nick = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_REALNAME") {
|
||||
c.realname = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_CHANNELS") {
|
||||
c.channels = split_list(&v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_PREFIX") {
|
||||
c.command_prefix = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_PASSWORD") {
|
||||
c.password = Some(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_SASL_USER") {
|
||||
c.sasl_user = Some(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_SASL_PASS") {
|
||||
c.sasl_pass = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a boolean config value. Accepts `1`, `true`, `yes`, `on` (any case).
|
||||
fn parse_bool(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
/// Split a channel list on commas and/or whitespace, dropping empties.
|
||||
fn split_list(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
450
src/irc.rs
450
src/irc.rs
|
|
@ -1,450 +0,0 @@
|
|||
//! Minimal IRC protocol layer — the "IRC library".
|
||||
//!
|
||||
//! Implements just enough of the modern IRC client protocol
|
||||
//! (<https://modern.ircdocs.horse/>) to drive a bot:
|
||||
//!
|
||||
//! * [`Message`] — parsing of the wire format `@tags :source COMMAND params`,
|
||||
//! including IRCv3 message tags and trailing parameters.
|
||||
//! * [`Prefix`] — the `nick!user@host` (or server name) message source.
|
||||
//! * [`Connection`] — a blocking TCP transport with convenience senders.
|
||||
//!
|
||||
//! It is deliberately self-contained and dependency-free, so it can grow into a
|
||||
//! small reusable IRC library independent of the bot logic in `bot.rs`.
|
||||
#![allow(dead_code)] // This is a library-style module; not every helper is used yet.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use native_tls::TlsConnector;
|
||||
|
||||
/// The source of a message: `nick!user@host`, or a bare nick / server name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Prefix {
|
||||
/// The full, unparsed prefix as received.
|
||||
pub raw: String,
|
||||
pub nick: Option<String>,
|
||||
pub user: Option<String>,
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
impl Prefix {
|
||||
/// Parse a prefix per the grammar `servername / (nick [ [ "!" user ] "@" host ])`.
|
||||
fn parse(raw: &str) -> Prefix {
|
||||
let (nick, user, host) = if let Some((n, rest)) = raw.split_once('!') {
|
||||
match rest.split_once('@') {
|
||||
Some((u, h)) => (Some(n), Some(u), Some(h)),
|
||||
None => (Some(n), Some(rest), None),
|
||||
}
|
||||
} else if let Some((n, h)) = raw.split_once('@') {
|
||||
(Some(n), None, Some(h))
|
||||
} else {
|
||||
// A bare nick or a server name — keep it as the nick for convenience.
|
||||
(Some(raw), None, None)
|
||||
};
|
||||
|
||||
Prefix {
|
||||
raw: raw.to_string(),
|
||||
nick: nick.map(str::to_string),
|
||||
user: user.map(str::to_string),
|
||||
host: host.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best display name for the sender: the nick if we have one, else the raw prefix.
|
||||
pub fn name(&self) -> &str {
|
||||
self.nick.as_deref().unwrap_or(&self.raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed IRC message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
/// IRCv3 message tags (already unescaped). Empty when none were present.
|
||||
pub tags: HashMap<String, String>,
|
||||
/// The message source, if the server included one.
|
||||
pub prefix: Option<Prefix>,
|
||||
/// The command, upper-cased (e.g. `PRIVMSG`) or a 3-digit numeric (e.g. `001`).
|
||||
pub command: String,
|
||||
/// Command parameters; the last one may be a "trailing" value containing spaces.
|
||||
pub params: Vec<String>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Parse a single line of IRC (the trailing CRLF may be present or not).
|
||||
///
|
||||
/// Returns `None` for blank or structurally invalid lines.
|
||||
pub fn parse(line: &str) -> Option<Message> {
|
||||
let mut rest = line.trim_end_matches(['\r', '\n']).trim_start();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1. Tags: `@key=value;key2 ` (optional, space-terminated).
|
||||
let mut tags = HashMap::new();
|
||||
if let Some(stripped) = rest.strip_prefix('@') {
|
||||
let (tag_str, r) = stripped.split_once(' ')?;
|
||||
rest = r.trim_start();
|
||||
for item in tag_str.split(';').filter(|s| !s.is_empty()) {
|
||||
match item.split_once('=') {
|
||||
Some((k, v)) => tags.insert(k.to_string(), unescape_tag_value(v)),
|
||||
None => tags.insert(item.to_string(), String::new()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Source / prefix: `:nick!user@host ` (optional, space-terminated).
|
||||
let mut prefix = None;
|
||||
if let Some(stripped) = rest.strip_prefix(':') {
|
||||
let (p, r) = stripped.split_once(' ')?;
|
||||
prefix = Some(Prefix::parse(p));
|
||||
rest = r.trim_start();
|
||||
}
|
||||
|
||||
// 3. Command (required).
|
||||
let (command, mut rest) = match rest.split_once(' ') {
|
||||
Some((c, r)) => (c, r.trim_start()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 4. Parameters. A parameter beginning with ':' is the trailing param and
|
||||
// consumes the remainder of the line verbatim (spaces included).
|
||||
let mut params = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
if let Some(trailing) = rest.strip_prefix(':') {
|
||||
params.push(trailing.to_string());
|
||||
break;
|
||||
}
|
||||
match rest.split_once(' ') {
|
||||
Some((p, r)) => {
|
||||
if !p.is_empty() {
|
||||
params.push(p.to_string());
|
||||
}
|
||||
rest = r.trim_start();
|
||||
}
|
||||
None => {
|
||||
params.push(rest.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Message {
|
||||
tags,
|
||||
prefix,
|
||||
command: command.to_uppercase(),
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the parameter at `index`, if present.
|
||||
pub fn param(&self, index: usize) -> Option<&str> {
|
||||
self.params.get(index).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The last parameter — for `PRIVMSG`/`NOTICE`/`PING` this is the message text.
|
||||
pub fn trailing(&self) -> Option<&str> {
|
||||
self.params.last().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The sender's nick, if the message carried a `nick!user@host` prefix.
|
||||
pub fn nick(&self) -> Option<&str> {
|
||||
self.prefix.as_ref().and_then(|p| p.nick.as_deref())
|
||||
}
|
||||
|
||||
/// The value of an IRCv3 message tag (e.g. `time`, `batch`), if present.
|
||||
pub fn tag(&self, key: &str) -> Option<&str> {
|
||||
self.tags.get(key).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unescape an IRCv3 tag value per <https://ircv3.net/specs/extensions/message-tags>.
|
||||
fn unescape_tag_value(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some(':') => out.push(';'),
|
||||
Some('s') => out.push(' '),
|
||||
Some('\\') => out.push('\\'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some('n') => out.push('\n'),
|
||||
Some(other) => out.push(other), // unknown escape: drop the backslash
|
||||
None => {} // trailing backslash: ignore
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Any bidirectional byte stream: a plain `TcpStream` or a TLS session. The
|
||||
/// blanket impl covers both, so [`Connection`] can treat them uniformly.
|
||||
trait Stream: Read + Write {}
|
||||
impl<T: Read + Write> Stream for T {}
|
||||
|
||||
/// A blocking IRC connection over plain TCP or TLS.
|
||||
///
|
||||
/// The event loop is single-threaded (read a message, handle it, maybe write a
|
||||
/// reply, repeat), so one stream serves both directions: reads go through the
|
||||
/// `BufReader`, writes through `get_mut()`. This is what lets a single TLS
|
||||
/// session — which cannot be cloned into independent halves — work unchanged.
|
||||
pub struct Connection {
|
||||
inner: BufReader<Box<dyn Stream>>,
|
||||
/// Prefix prepended to wire logs (`[name] `) so simultaneous networks stay
|
||||
/// distinguishable. Empty for a single network, keeping its output terse.
|
||||
log_prefix: String,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// Connect to `host:port`, optionally wrapping the socket in TLS.
|
||||
///
|
||||
/// TLS uses the system trust store (via `native-tls`/OpenSSL), with `host`
|
||||
/// supplied as the SNI name and for certificate verification.
|
||||
pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result<Connection> {
|
||||
let tcp = TcpStream::connect((host, port))?;
|
||||
let stream: Box<dyn Stream> = if use_tls {
|
||||
let connector =
|
||||
TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?;
|
||||
let tls = connector
|
||||
.connect(host, tcp)
|
||||
.map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?;
|
||||
Box::new(tls)
|
||||
} else {
|
||||
Box::new(tcp)
|
||||
};
|
||||
Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() })
|
||||
}
|
||||
|
||||
/// Set a short label prepended to this connection's wire logs, so output
|
||||
/// from multiple simultaneous networks stays distinguishable. An empty
|
||||
/// label clears it (the terse single-network default).
|
||||
pub fn set_label(&mut self, label: &str) {
|
||||
self.log_prefix = if label.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("[{label}] ")
|
||||
};
|
||||
}
|
||||
|
||||
/// Read and parse the next message. Returns `Ok(None)` on a clean EOF
|
||||
/// (the server closed the connection). Blank/unparseable lines are skipped.
|
||||
pub fn read_message(&mut self) -> io::Result<Option<Message>> {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if self.inner.read_line(&mut line)? == 0 {
|
||||
return Ok(None); // EOF
|
||||
}
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
eprintln!("{}<< {trimmed}", self.log_prefix);
|
||||
if let Some(msg) = Message::parse(trimmed) {
|
||||
return Ok(Some(msg));
|
||||
}
|
||||
// Unparseable line: skip and keep reading.
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one raw, pre-formatted IRC line. The CRLF terminator is added here;
|
||||
/// any embedded CR/LF are neutralised to prevent command injection.
|
||||
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||
let clean = line.replace(['\r', '\n'], " ");
|
||||
eprintln!("{}>> {clean}", self.log_prefix);
|
||||
let writer = self.inner.get_mut();
|
||||
write!(writer, "{clean}\r\n")?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
// --- Convenience senders -------------------------------------------------
|
||||
|
||||
pub fn pass(&mut self, password: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PASS {password}"))
|
||||
}
|
||||
|
||||
pub fn nick(&mut self, nick: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("NICK {nick}"))
|
||||
}
|
||||
|
||||
pub fn user(&mut self, user: &str, realname: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("USER {user} 0 * :{realname}"))
|
||||
}
|
||||
|
||||
pub fn join(&mut self, channel: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("JOIN {channel}"))
|
||||
}
|
||||
|
||||
pub fn pong(&mut self, token: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PONG :{token}"))
|
||||
}
|
||||
|
||||
pub fn privmsg(&mut self, target: &str, text: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PRIVMSG {target} :{text}"))
|
||||
}
|
||||
|
||||
pub fn notice(&mut self, target: &str, text: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("NOTICE {target} :{text}"))
|
||||
}
|
||||
|
||||
pub fn quit(&mut self, reason: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("QUIT :{reason}"))
|
||||
}
|
||||
|
||||
// --- IRCv3 capability negotiation ---------------------------------------
|
||||
|
||||
pub fn cap_ls(&mut self) -> io::Result<()> {
|
||||
self.send_raw("CAP LS 302")
|
||||
}
|
||||
|
||||
pub fn cap_req(&mut self, caps: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("CAP REQ :{caps}"))
|
||||
}
|
||||
|
||||
pub fn cap_end(&mut self) -> io::Result<()> {
|
||||
self.send_raw("CAP END")
|
||||
}
|
||||
|
||||
pub fn authenticate(&mut self, payload: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("AUTHENTICATE {payload}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Base64-encode bytes (standard alphabet, padded). Used for SASL PLAIN so we
|
||||
/// stay dependency-free.
|
||||
pub fn base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
||||
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
|
||||
out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Current Unix time in whole seconds (0 if the clock predates the epoch).
|
||||
pub fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Parse an IRCv3 `server-time` tag (`YYYY-MM-DDThh:mm:ss[.fff]Z`) into Unix
|
||||
/// seconds. Returns `None` if the value isn't in the expected UTC format.
|
||||
pub fn parse_server_time(s: &str) -> Option<u64> {
|
||||
let (date, time) = s.split_once('T')?;
|
||||
let time = time.trim_end_matches('Z').split('.').next()?; // drop fractional secs
|
||||
let mut d = date.splitn(3, '-');
|
||||
let year: i64 = d.next()?.parse().ok()?;
|
||||
let month: i64 = d.next()?.parse().ok()?;
|
||||
let day: i64 = d.next()?.parse().ok()?;
|
||||
let mut t = time.splitn(3, ':');
|
||||
let hour: i64 = t.next()?.parse().ok()?;
|
||||
let min: i64 = t.next()?.parse().ok()?;
|
||||
let sec: i64 = t.next()?.parse().ok()?;
|
||||
let secs = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + min * 60 + sec;
|
||||
u64::try_from(secs).ok()
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch for a proleptic-Gregorian date.
|
||||
/// Howard Hinnant's algorithm: <http://howardhinnant.github.io/date_algorithms.html>
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_privmsg_with_prefix() {
|
||||
let m = Message::parse(":alice!a@host PRIVMSG #chan :hi there\r\n").unwrap();
|
||||
assert_eq!(m.command, "PRIVMSG");
|
||||
assert_eq!(m.nick(), Some("alice"));
|
||||
assert_eq!(m.param(0), Some("#chan"));
|
||||
assert_eq!(m.trailing(), Some("hi there"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ping_without_prefix() {
|
||||
let m = Message::parse("PING :LAG1234").unwrap();
|
||||
assert_eq!(m.command, "PING");
|
||||
assert!(m.prefix.is_none());
|
||||
assert_eq!(m.trailing(), Some("LAG1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_unescapes_tags() {
|
||||
let m = Message::parse("@id=123;key=a\\sb\\:c :n!u@h PRIVMSG #c :yo").unwrap();
|
||||
assert_eq!(m.tags.get("id").map(String::as_str), Some("123"));
|
||||
assert_eq!(m.tags.get("key").map(String::as_str), Some("a b;c"));
|
||||
assert_eq!(m.command, "PRIVMSG");
|
||||
assert_eq!(m.trailing(), Some("yo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_numeric_reply() {
|
||||
let m = Message::parse(":srv 001 me :Welcome to the network").unwrap();
|
||||
assert_eq!(m.command, "001");
|
||||
assert_eq!(m.param(0), Some("me"));
|
||||
assert_eq!(m.trailing(), Some("Welcome to the network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_prefix_is_split() {
|
||||
let p = Prefix::parse("nick!user@host.example");
|
||||
assert_eq!(p.nick.as_deref(), Some("nick"));
|
||||
assert_eq!(p.user.as_deref(), Some("user"));
|
||||
assert_eq!(p.host.as_deref(), Some("host.example"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_is_none() {
|
||||
assert!(Message::parse(" \r\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_known_vectors() {
|
||||
assert_eq!(base64_encode(b""), "");
|
||||
assert_eq!(base64_encode(b"f"), "Zg==");
|
||||
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
||||
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||
// A SASL PLAIN payload: authzid="" authcid="me" passwd="pw"
|
||||
assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_time_parses_to_unix_seconds() {
|
||||
assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0));
|
||||
assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800));
|
||||
assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200));
|
||||
assert_eq!(parse_server_time("not-a-timestamp"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_message_tags_via_accessor() {
|
||||
let m = Message::parse("@time=2021-01-01T00:00:00Z;batch=42 :s PRIVMSG #c :hi").unwrap();
|
||||
assert_eq!(m.tag("batch"), Some("42"));
|
||||
assert_eq!(m.tag("time"), Some("2021-01-01T00:00:00Z"));
|
||||
assert_eq!(m.tag("missing"), None);
|
||||
}
|
||||
}
|
||||
141
src/irc/connection.rs
Normal file
141
src/irc/connection.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
//! The blocking IRC transport: a plain-TCP or TLS byte stream wrapped with
|
||||
//! line reading and convenience senders.
|
||||
|
||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||
use std::net::TcpStream;
|
||||
|
||||
use native_tls::TlsConnector;
|
||||
|
||||
use super::Message;
|
||||
|
||||
/// Any bidirectional byte stream: a plain `TcpStream` or a TLS session. The
|
||||
/// blanket impl covers both, so [`Connection`] can treat them uniformly.
|
||||
trait Stream: Read + Write {}
|
||||
impl<T: Read + Write> Stream for T {}
|
||||
|
||||
/// A blocking IRC connection over plain TCP or TLS.
|
||||
///
|
||||
/// The event loop is single-threaded (read a message, handle it, maybe write a
|
||||
/// reply, repeat), so one stream serves both directions: reads go through the
|
||||
/// `BufReader`, writes through `get_mut()`. This is what lets a single TLS
|
||||
/// session — which cannot be cloned into independent halves — work unchanged.
|
||||
pub struct Connection {
|
||||
inner: BufReader<Box<dyn Stream>>,
|
||||
/// Prefix prepended to wire logs (`[name] `) so simultaneous networks stay
|
||||
/// distinguishable. Empty for a single network, keeping its output terse.
|
||||
log_prefix: String,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// Connect to `host:port`, optionally wrapping the socket in TLS.
|
||||
///
|
||||
/// TLS uses the system trust store (via `native-tls`/OpenSSL), with `host`
|
||||
/// supplied as the SNI name and for certificate verification.
|
||||
pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result<Connection> {
|
||||
let tcp = TcpStream::connect((host, port))?;
|
||||
let stream: Box<dyn Stream> = if use_tls {
|
||||
let connector =
|
||||
TlsConnector::new().map_err(|e| io::Error::other(format!("TLS init: {e}")))?;
|
||||
let tls = connector
|
||||
.connect(host, tcp)
|
||||
.map_err(|e| io::Error::other(format!("TLS handshake: {e}")))?;
|
||||
Box::new(tls)
|
||||
} else {
|
||||
Box::new(tcp)
|
||||
};
|
||||
Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() })
|
||||
}
|
||||
|
||||
/// Set a short label prepended to this connection's wire logs, so output
|
||||
/// from multiple simultaneous networks stays distinguishable. An empty
|
||||
/// label clears it (the terse single-network default).
|
||||
pub fn set_label(&mut self, label: &str) {
|
||||
self.log_prefix = if label.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("[{label}] ")
|
||||
};
|
||||
}
|
||||
|
||||
/// Read and parse the next message. Returns `Ok(None)` on a clean EOF
|
||||
/// (the server closed the connection). Blank/unparseable lines are skipped.
|
||||
pub fn read_message(&mut self) -> io::Result<Option<Message>> {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if self.inner.read_line(&mut line)? == 0 {
|
||||
return Ok(None); // EOF
|
||||
}
|
||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
eprintln!("{}<< {trimmed}", self.log_prefix);
|
||||
if let Some(msg) = Message::parse(trimmed) {
|
||||
return Ok(Some(msg));
|
||||
}
|
||||
// Unparseable line: skip and keep reading.
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one raw, pre-formatted IRC line. The CRLF terminator is added here;
|
||||
/// any embedded CR/LF are neutralised to prevent command injection.
|
||||
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||
let clean = line.replace(['\r', '\n'], " ");
|
||||
eprintln!("{}>> {clean}", self.log_prefix);
|
||||
let writer = self.inner.get_mut();
|
||||
write!(writer, "{clean}\r\n")?;
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
// --- Convenience senders -------------------------------------------------
|
||||
|
||||
pub fn pass(&mut self, password: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PASS {password}"))
|
||||
}
|
||||
|
||||
pub fn nick(&mut self, nick: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("NICK {nick}"))
|
||||
}
|
||||
|
||||
pub fn user(&mut self, user: &str, realname: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("USER {user} 0 * :{realname}"))
|
||||
}
|
||||
|
||||
pub fn join(&mut self, channel: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("JOIN {channel}"))
|
||||
}
|
||||
|
||||
pub fn pong(&mut self, token: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PONG :{token}"))
|
||||
}
|
||||
|
||||
pub fn privmsg(&mut self, target: &str, text: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("PRIVMSG {target} :{text}"))
|
||||
}
|
||||
|
||||
pub fn notice(&mut self, target: &str, text: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("NOTICE {target} :{text}"))
|
||||
}
|
||||
|
||||
pub fn quit(&mut self, reason: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("QUIT :{reason}"))
|
||||
}
|
||||
|
||||
// --- IRCv3 capability negotiation ---------------------------------------
|
||||
|
||||
pub fn cap_ls(&mut self) -> io::Result<()> {
|
||||
self.send_raw("CAP LS 302")
|
||||
}
|
||||
|
||||
pub fn cap_req(&mut self, caps: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("CAP REQ :{caps}"))
|
||||
}
|
||||
|
||||
pub fn cap_end(&mut self) -> io::Result<()> {
|
||||
self.send_raw("CAP END")
|
||||
}
|
||||
|
||||
pub fn authenticate(&mut self, payload: &str) -> io::Result<()> {
|
||||
self.send_raw(&format!("AUTHENTICATE {payload}"))
|
||||
}
|
||||
}
|
||||
59
src/irc/encoding.rs
Normal file
59
src/irc/encoding.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//! Small self-contained helpers: base64 (for SASL) and `server-time` parsing.
|
||||
//! Kept dependency-free so the protocol layer needs nothing but TLS.
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Base64-encode bytes (standard alphabet, padded). Used for SASL PLAIN so we
|
||||
/// stay dependency-free.
|
||||
pub fn base64_encode(input: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
||||
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(ALPHABET[(n >> 18 & 63) as usize] as char);
|
||||
out.push(ALPHABET[(n >> 12 & 63) as usize] as char);
|
||||
out.push(if chunk.len() > 1 { ALPHABET[(n >> 6 & 63) as usize] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { ALPHABET[(n & 63) as usize] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Current Unix time in whole seconds (0 if the clock predates the epoch).
|
||||
pub fn now_unix() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Parse an IRCv3 `server-time` tag (`YYYY-MM-DDThh:mm:ss[.fff]Z`) into Unix
|
||||
/// seconds. Returns `None` if the value isn't in the expected UTC format.
|
||||
pub fn parse_server_time(s: &str) -> Option<u64> {
|
||||
let (date, time) = s.split_once('T')?;
|
||||
let time = time.trim_end_matches('Z').split('.').next()?; // drop fractional secs
|
||||
let mut d = date.splitn(3, '-');
|
||||
let year: i64 = d.next()?.parse().ok()?;
|
||||
let month: i64 = d.next()?.parse().ok()?;
|
||||
let day: i64 = d.next()?.parse().ok()?;
|
||||
let mut t = time.splitn(3, ':');
|
||||
let hour: i64 = t.next()?.parse().ok()?;
|
||||
let min: i64 = t.next()?.parse().ok()?;
|
||||
let sec: i64 = t.next()?.parse().ok()?;
|
||||
let secs = days_from_civil(year, month, day) * 86_400 + hour * 3_600 + min * 60 + sec;
|
||||
u64::try_from(secs).ok()
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch for a proleptic-Gregorian date.
|
||||
/// Howard Hinnant's algorithm: <http://howardhinnant.github.io/date_algorithms.html>
|
||||
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
168
src/irc/message.rs
Normal file
168
src/irc/message.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! Parsing of the IRC wire format into [`Message`] / [`Prefix`] values.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The source of a message: `nick!user@host`, or a bare nick / server name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Prefix {
|
||||
/// The full, unparsed prefix as received.
|
||||
pub raw: String,
|
||||
pub nick: Option<String>,
|
||||
pub user: Option<String>,
|
||||
pub host: Option<String>,
|
||||
}
|
||||
|
||||
impl Prefix {
|
||||
/// Parse a prefix per the grammar `servername / (nick [ [ "!" user ] "@" host ])`.
|
||||
fn parse(raw: &str) -> Prefix {
|
||||
let (nick, user, host) = if let Some((n, rest)) = raw.split_once('!') {
|
||||
match rest.split_once('@') {
|
||||
Some((u, h)) => (Some(n), Some(u), Some(h)),
|
||||
None => (Some(n), Some(rest), None),
|
||||
}
|
||||
} else if let Some((n, h)) = raw.split_once('@') {
|
||||
(Some(n), None, Some(h))
|
||||
} else {
|
||||
// A bare nick or a server name — keep it as the nick for convenience.
|
||||
(Some(raw), None, None)
|
||||
};
|
||||
|
||||
Prefix {
|
||||
raw: raw.to_string(),
|
||||
nick: nick.map(str::to_string),
|
||||
user: user.map(str::to_string),
|
||||
host: host.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best display name for the sender: the nick if we have one, else the raw prefix.
|
||||
pub fn name(&self) -> &str {
|
||||
self.nick.as_deref().unwrap_or(&self.raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed IRC message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
/// IRCv3 message tags (already unescaped). Empty when none were present.
|
||||
pub tags: HashMap<String, String>,
|
||||
/// The message source, if the server included one.
|
||||
pub prefix: Option<Prefix>,
|
||||
/// The command, upper-cased (e.g. `PRIVMSG`) or a 3-digit numeric (e.g. `001`).
|
||||
pub command: String,
|
||||
/// Command parameters; the last one may be a "trailing" value containing spaces.
|
||||
pub params: Vec<String>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Parse a single line of IRC (the trailing CRLF may be present or not).
|
||||
///
|
||||
/// Returns `None` for blank or structurally invalid lines.
|
||||
pub fn parse(line: &str) -> Option<Message> {
|
||||
let mut rest = line.trim_end_matches(['\r', '\n']).trim_start();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1. Tags: `@key=value;key2 ` (optional, space-terminated).
|
||||
let mut tags = HashMap::new();
|
||||
if let Some(stripped) = rest.strip_prefix('@') {
|
||||
let (tag_str, r) = stripped.split_once(' ')?;
|
||||
rest = r.trim_start();
|
||||
for item in tag_str.split(';').filter(|s| !s.is_empty()) {
|
||||
match item.split_once('=') {
|
||||
Some((k, v)) => tags.insert(k.to_string(), unescape_tag_value(v)),
|
||||
None => tags.insert(item.to_string(), String::new()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Source / prefix: `:nick!user@host ` (optional, space-terminated).
|
||||
let mut prefix = None;
|
||||
if let Some(stripped) = rest.strip_prefix(':') {
|
||||
let (p, r) = stripped.split_once(' ')?;
|
||||
prefix = Some(Prefix::parse(p));
|
||||
rest = r.trim_start();
|
||||
}
|
||||
|
||||
// 3. Command (required).
|
||||
let (command, mut rest) = match rest.split_once(' ') {
|
||||
Some((c, r)) => (c, r.trim_start()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 4. Parameters. A parameter beginning with ':' is the trailing param and
|
||||
// consumes the remainder of the line verbatim (spaces included).
|
||||
let mut params = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
if let Some(trailing) = rest.strip_prefix(':') {
|
||||
params.push(trailing.to_string());
|
||||
break;
|
||||
}
|
||||
match rest.split_once(' ') {
|
||||
Some((p, r)) => {
|
||||
if !p.is_empty() {
|
||||
params.push(p.to_string());
|
||||
}
|
||||
rest = r.trim_start();
|
||||
}
|
||||
None => {
|
||||
params.push(rest.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Message {
|
||||
tags,
|
||||
prefix,
|
||||
command: command.to_uppercase(),
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the parameter at `index`, if present.
|
||||
pub fn param(&self, index: usize) -> Option<&str> {
|
||||
self.params.get(index).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The last parameter — for `PRIVMSG`/`NOTICE`/`PING` this is the message text.
|
||||
pub fn trailing(&self) -> Option<&str> {
|
||||
self.params.last().map(String::as_str)
|
||||
}
|
||||
|
||||
/// The sender's nick, if the message carried a `nick!user@host` prefix.
|
||||
pub fn nick(&self) -> Option<&str> {
|
||||
self.prefix.as_ref().and_then(|p| p.nick.as_deref())
|
||||
}
|
||||
|
||||
/// The value of an IRCv3 message tag (e.g. `time`, `batch`), if present.
|
||||
pub fn tag(&self, key: &str) -> Option<&str> {
|
||||
self.tags.get(key).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unescape an IRCv3 tag value per <https://ircv3.net/specs/extensions/message-tags>.
|
||||
fn unescape_tag_value(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some(':') => out.push(';'),
|
||||
Some('s') => out.push(' '),
|
||||
Some('\\') => out.push('\\'),
|
||||
Some('r') => out.push('\r'),
|
||||
Some('n') => out.push('\n'),
|
||||
Some(other) => out.push(other), // unknown escape: drop the backslash
|
||||
None => {} // trailing backslash: ignore
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
24
src/irc/mod.rs
Normal file
24
src/irc/mod.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
//! Minimal IRC protocol layer — the "IRC library".
|
||||
//!
|
||||
//! Implements just enough of the modern IRC client protocol
|
||||
//! (<https://modern.ircdocs.horse/>) to drive a bot:
|
||||
//!
|
||||
//! * [`Message`] / [`Prefix`] — parsing of the wire format
|
||||
//! `@tags :source COMMAND params`, including IRCv3 message tags and
|
||||
//! trailing parameters ([`message`]).
|
||||
//! * [`Connection`] — a blocking TCP/TLS transport with convenience senders
|
||||
//! ([`connection`]).
|
||||
//! * Small self-contained helpers — base64 and `server-time` parsing
|
||||
//! ([`encoding`]) — so the layer stays dependency-free apart from TLS.
|
||||
//!
|
||||
//! It is deliberately self-contained, so it can grow into a small reusable IRC
|
||||
//! library independent of the bot logic in [`crate::bot`].
|
||||
#![allow(dead_code)] // Library-style module; not every helper/sender is used yet.
|
||||
|
||||
mod connection;
|
||||
mod encoding;
|
||||
mod message;
|
||||
|
||||
pub use connection::Connection;
|
||||
pub use encoding::{base64_encode, now_unix, parse_server_time};
|
||||
pub use message::{Message, Prefix};
|
||||
23
src/lib.rs
Normal file
23
src/lib.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//! rustbot — a small, modular IRC bot, split into a reusable library (this
|
||||
//! crate) and a thin binary (`src/main.rs`).
|
||||
//!
|
||||
//! Modules:
|
||||
//! * [`irc`] — protocol + transport (the "IRC library"): message parsing,
|
||||
//! IRCv3 tags, and a blocking TCP/TLS [`irc::Connection`].
|
||||
//! * [`bot`] — behavior: registration, capability negotiation, the event
|
||||
//! loop, and commands, driving one [`irc::Connection`].
|
||||
//! * [`config`] — the layered [`config::Config`] and how it is loaded from a
|
||||
//! file (with optional `[network]` sections) and environment.
|
||||
//!
|
||||
//! The binary adds only a per-network connect/reconnect supervisor on top, so
|
||||
//! the whole bot is exercisable — and reusable — through this library's API.
|
||||
|
||||
pub mod bot;
|
||||
pub mod config;
|
||||
pub mod irc;
|
||||
|
||||
/// Tiny stderr logger shared by the library and the binary. Swap for the
|
||||
/// `log`/`tracing` crates if you outgrow it.
|
||||
pub fn log(msg: &str) {
|
||||
eprintln!("[rustbot] {msg}");
|
||||
}
|
||||
313
src/main.rs
313
src/main.rs
|
|
@ -1,29 +1,17 @@
|
|||
//! rustbot — a small, modular IRC bot.
|
||||
//!
|
||||
//! Layers:
|
||||
//! * `irc` — protocol + transport (the "IRC library")
|
||||
//! * `bot` — behavior: registration, event loop, commands
|
||||
//! * `main` — configuration and the connect/reconnect supervisor
|
||||
//!
|
||||
//! Configuration is layered (see [`load_config`]): built-in defaults, then a
|
||||
//! `key = value` config file, then environment variables — so the same binary
|
||||
//! can target any network without a rebuild. The file may define several
|
||||
//! `[network]` sections, in which case the bot connects to all of them at once,
|
||||
//! one supervisor thread each.
|
||||
//! rustbot binary — a per-network connect/reconnect supervisor built on the
|
||||
//! `rustbot` library. All the real logic lives in the library
|
||||
//! (`config`, `irc`, `bot`); this file only spawns and keeps sessions alive.
|
||||
|
||||
mod bot;
|
||||
mod irc;
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use bot::{Bot, Config};
|
||||
use irc::Connection;
|
||||
use rustbot::bot::Bot;
|
||||
use rustbot::config::{self, Config};
|
||||
use rustbot::irc::Connection;
|
||||
use rustbot::log;
|
||||
|
||||
fn main() {
|
||||
let configs = load_config();
|
||||
let configs = config::load();
|
||||
if configs.is_empty() {
|
||||
log("no networks configured; nothing to do");
|
||||
std::process::exit(1);
|
||||
|
|
@ -93,288 +81,3 @@ fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> {
|
|||
let mut bot = Bot::new(config.clone(), conn);
|
||||
bot.run()
|
||||
}
|
||||
|
||||
/// Build one [`Config`] per network by layering sources.
|
||||
///
|
||||
/// The config file is INI-flavoured: `key = value` lines, optionally grouped
|
||||
/// under `[network]` section headers. Keys *before* the first header are shared
|
||||
/// defaults; each `[section]` defines one network that inherits those defaults
|
||||
/// and overrides them. A file with **no** sections yields a single network —
|
||||
/// exactly the historical behaviour.
|
||||
///
|
||||
/// Precedence within a network: built-in defaults < shared file defaults <
|
||||
/// environment variables < that network's section.
|
||||
fn load_config() -> Vec<Config> {
|
||||
let path = config_path();
|
||||
let content = path.as_ref().and_then(|p| match fs::read_to_string(p) {
|
||||
Ok(s) => {
|
||||
log(&format!("loaded config from {}", p.display()));
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
log(&format!("could not read config {}: {e}", p.display()));
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let (toplevel, sections) = match &content {
|
||||
Some(text) => parse_sections(text),
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
// Shared defaults: built-ins, then top-level keys, then environment. Each
|
||||
// network is derived from this base.
|
||||
let mut base = Config::default();
|
||||
for (line, key, value) in &toplevel {
|
||||
apply_kv(&mut base, key, value, &loc(&path, *line));
|
||||
}
|
||||
apply_env(&mut base);
|
||||
|
||||
assemble(base, sections, &path)
|
||||
}
|
||||
|
||||
/// Derive one finished [`Config`] per network from the shared `base` and the
|
||||
/// parsed sections. With no sections, `base` itself is the single network.
|
||||
/// `path` is used only to describe locations in warnings.
|
||||
fn assemble(base: Config, sections: Vec<(String, Vec<Entry>)>, path: &Option<PathBuf>) -> Vec<Config> {
|
||||
let mut configs = Vec::new();
|
||||
if sections.is_empty() {
|
||||
let mut c = base;
|
||||
finalize(&mut c);
|
||||
configs.push(c);
|
||||
} else {
|
||||
for (name, entries) in sections {
|
||||
let mut c = base.clone();
|
||||
c.name = name;
|
||||
for (line, key, value) in &entries {
|
||||
apply_kv(&mut c, key, value, &loc(path, *line));
|
||||
}
|
||||
finalize(&mut c);
|
||||
configs.push(c);
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
|
||||
/// Per-network finishing touches: default the label to the server host, and
|
||||
/// honour the TLS-port convention (6697) when TLS is on but the port is still
|
||||
/// the plaintext default.
|
||||
fn finalize(c: &mut Config) {
|
||||
if c.name.is_empty() {
|
||||
c.name = c.server.clone();
|
||||
}
|
||||
if c.tls && c.port == 6667 {
|
||||
c.port = 6697;
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `key = value` pair with its 1-based source line, for diagnostics.
|
||||
type Entry = (usize, String, String);
|
||||
|
||||
/// Split config text into shared top-level entries and `[name]` sections.
|
||||
///
|
||||
/// Blank lines and whole-line `#`/`;` comments are ignored. A `[name]` line
|
||||
/// opens a section; entries after it belong to that network until the next
|
||||
/// header. Lines that are neither a header nor `key = value` are reported and
|
||||
/// skipped.
|
||||
fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
||||
let mut toplevel: Vec<Entry> = Vec::new();
|
||||
let mut sections: Vec<(String, Vec<Entry>)> = Vec::new();
|
||||
|
||||
for (i, raw) in content.lines().enumerate() {
|
||||
let line = raw.trim();
|
||||
let lineno = i + 1;
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
// Section header: `[name]`.
|
||||
if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||
sections.push((inner.trim().to_string(), Vec::new()));
|
||||
continue;
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
log(&format!("line {lineno}: ignoring line without '='"));
|
||||
continue;
|
||||
};
|
||||
let entry = (lineno, key.trim().to_ascii_lowercase(), value.trim().to_string());
|
||||
match sections.last_mut() {
|
||||
Some((_, entries)) => entries.push(entry),
|
||||
None => toplevel.push(entry),
|
||||
}
|
||||
}
|
||||
(toplevel, sections)
|
||||
}
|
||||
|
||||
/// Apply a single `key = value` pair onto `c`. `where_` describes the source
|
||||
/// location for warnings (e.g. `rustbot.conf:12`).
|
||||
fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
||||
match key {
|
||||
"name" => c.name = value.to_string(),
|
||||
"server" => c.server = value.to_string(),
|
||||
"port" => match value.parse() {
|
||||
Ok(p) => c.port = p,
|
||||
Err(_) => log(&format!("{where_}: invalid port '{value}'")),
|
||||
},
|
||||
"tls" => c.tls = parse_bool(value),
|
||||
"nick" => c.nick = value.to_string(),
|
||||
"user" => c.user = value.to_string(),
|
||||
"realname" => c.realname = value.to_string(),
|
||||
"channels" => c.channels = split_list(value),
|
||||
"prefix" | "command_prefix" => c.command_prefix = value.to_string(),
|
||||
"password" => c.password = (!value.is_empty()).then(|| value.to_string()),
|
||||
"sasl_user" => c.sasl_user = (!value.is_empty()).then(|| value.to_string()),
|
||||
"sasl_pass" => c.sasl_pass = (!value.is_empty()).then(|| value.to_string()),
|
||||
other => log(&format!("{where_}: unknown key '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a source location for a top-level line, including the file path.
|
||||
fn loc(path: &Option<PathBuf>, line: usize) -> String {
|
||||
match path {
|
||||
Some(p) => format!("{}:{}", p.display(), line),
|
||||
None => format!("line {line}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which config file to read: CLI argument, then `RUSTBOT_CONFIG`, then
|
||||
/// `./rustbot.conf` if it happens to exist. Returns `None` when there is none.
|
||||
fn config_path() -> Option<PathBuf> {
|
||||
if let Some(arg) = std::env::args().nth(1) {
|
||||
return Some(PathBuf::from(arg));
|
||||
}
|
||||
if let Ok(env) = std::env::var("RUSTBOT_CONFIG") {
|
||||
return Some(PathBuf::from(env));
|
||||
}
|
||||
let default = PathBuf::from("rustbot.conf");
|
||||
default.exists().then_some(default)
|
||||
}
|
||||
|
||||
/// Overlay environment variables onto `c` (applied to the shared defaults, so
|
||||
/// they affect every network unless a `[section]` overrides them).
|
||||
///
|
||||
/// Vars: `IRC_SERVER`, `IRC_PORT`, `IRC_TLS`, `IRC_NICK`, `IRC_REALNAME`,
|
||||
/// `IRC_CHANNELS` (comma/space-separated), `IRC_PREFIX`, `IRC_PASSWORD`,
|
||||
/// `IRC_SASL_USER`, `IRC_SASL_PASS`.
|
||||
fn apply_env(c: &mut Config) {
|
||||
if let Ok(v) = std::env::var("IRC_SERVER") {
|
||||
c.server = v;
|
||||
}
|
||||
if let Some(p) = std::env::var("IRC_PORT").ok().and_then(|v| v.parse().ok()) {
|
||||
c.port = p;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_TLS") {
|
||||
c.tls = parse_bool(&v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_NICK") {
|
||||
c.user = v.clone();
|
||||
c.nick = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_REALNAME") {
|
||||
c.realname = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_CHANNELS") {
|
||||
c.channels = split_list(&v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_PREFIX") {
|
||||
c.command_prefix = v;
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_PASSWORD") {
|
||||
c.password = Some(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_SASL_USER") {
|
||||
c.sasl_user = Some(v);
|
||||
}
|
||||
if let Ok(v) = std::env::var("IRC_SASL_PASS") {
|
||||
c.sasl_pass = Some(v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a boolean config value. Accepts `1`, `true`, `yes`, `on` (any case).
|
||||
fn parse_bool(value: &str) -> bool {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
}
|
||||
|
||||
/// Split a channel list on commas and/or whitespace, dropping empties.
|
||||
fn split_list(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Tiny stderr logger. Swap for the `log`/`tracing` crates if you outgrow it.
|
||||
fn log(msg: &str) {
|
||||
eprintln!("[rustbot] {msg}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Build the network list from config text, mirroring [`load_config`] but
|
||||
/// without touching the filesystem or environment (so tests are hermetic).
|
||||
fn build(content: &str) -> Vec<Config> {
|
||||
let (toplevel, sections) = parse_sections(content);
|
||||
let mut base = Config::default();
|
||||
for (line, key, value) in &toplevel {
|
||||
apply_kv(&mut base, key, value, &format!("line {line}"));
|
||||
}
|
||||
assemble(base, sections, &None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_config_is_a_single_network() {
|
||||
let cfgs = build("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n");
|
||||
assert_eq!(cfgs.len(), 1);
|
||||
assert_eq!(cfgs[0].server, "irc.example.org");
|
||||
assert_eq!(cfgs[0].nick, "flatbot");
|
||||
assert_eq!(cfgs[0].channels, vec!["#a", "#b"]);
|
||||
// The label defaults to the server host.
|
||||
assert_eq!(cfgs[0].name, "irc.example.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_inherit_top_level_defaults_and_override() {
|
||||
let cfgs = build(
|
||||
"nick = shared\nchannels = #lobby\n\
|
||||
[libera]\nserver = irc.libera.chat\ntls = true\n\
|
||||
[oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n",
|
||||
);
|
||||
assert_eq!(cfgs.len(), 2);
|
||||
|
||||
let libera = &cfgs[0];
|
||||
assert_eq!(libera.name, "libera");
|
||||
assert_eq!(libera.server, "irc.libera.chat");
|
||||
assert_eq!(libera.nick, "shared"); // inherited
|
||||
assert_eq!(libera.channels, vec!["#lobby"]); // inherited
|
||||
assert!(libera.tls);
|
||||
assert_eq!(libera.port, 6697); // TLS-port convention applied per network
|
||||
|
||||
let oftc = &cfgs[1];
|
||||
assert_eq!(oftc.name, "oftc");
|
||||
assert_eq!(oftc.server, "irc.oftc.net");
|
||||
assert_eq!(oftc.nick, "other"); // overridden
|
||||
assert_eq!(oftc.channels, vec!["#oftc"]); // overridden
|
||||
assert!(!oftc.tls); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_may_rename_itself() {
|
||||
let cfgs = build("[net]\nname = pretty\nserver = irc.example.org\n");
|
||||
assert_eq!(cfgs.len(), 1);
|
||||
assert_eq!(cfgs[0].name, "pretty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_blanks_are_ignored() {
|
||||
let (toplevel, sections) = parse_sections("# comment\n; also\n\n \nserver = x\n");
|
||||
assert!(sections.is_empty());
|
||||
assert_eq!(toplevel.len(), 1);
|
||||
assert_eq!(toplevel[0].1, "server");
|
||||
assert_eq!(toplevel[0].2, "x");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
tests/config.rs
Normal file
55
tests/config.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//! Integration tests for config layering and `[network]` sections, exercising
|
||||
//! the public `rustbot::config::parse_str` (no filesystem or environment).
|
||||
|
||||
use rustbot::config::parse_str;
|
||||
|
||||
#[test]
|
||||
fn flat_config_is_a_single_network() {
|
||||
let cfgs = parse_str("server = irc.example.org\nnick = flatbot\nchannels = #a #b\n");
|
||||
assert_eq!(cfgs.len(), 1);
|
||||
assert_eq!(cfgs[0].server, "irc.example.org");
|
||||
assert_eq!(cfgs[0].nick, "flatbot");
|
||||
assert_eq!(cfgs[0].channels, vec!["#a", "#b"]);
|
||||
// The label defaults to the server host.
|
||||
assert_eq!(cfgs[0].name, "irc.example.org");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_inherit_top_level_defaults_and_override() {
|
||||
let cfgs = parse_str(
|
||||
"nick = shared\nchannels = #lobby\n\
|
||||
[libera]\nserver = irc.libera.chat\ntls = true\n\
|
||||
[oftc]\nserver = irc.oftc.net\nnick = other\nchannels = #oftc\n",
|
||||
);
|
||||
assert_eq!(cfgs.len(), 2);
|
||||
|
||||
let libera = &cfgs[0];
|
||||
assert_eq!(libera.name, "libera");
|
||||
assert_eq!(libera.server, "irc.libera.chat");
|
||||
assert_eq!(libera.nick, "shared"); // inherited
|
||||
assert_eq!(libera.channels, vec!["#lobby"]); // inherited
|
||||
assert!(libera.tls);
|
||||
assert_eq!(libera.port, 6697); // TLS-port convention applied per network
|
||||
|
||||
let oftc = &cfgs[1];
|
||||
assert_eq!(oftc.name, "oftc");
|
||||
assert_eq!(oftc.server, "irc.oftc.net");
|
||||
assert_eq!(oftc.nick, "other"); // overridden
|
||||
assert_eq!(oftc.channels, vec!["#oftc"]); // overridden
|
||||
assert!(!oftc.tls); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_section_may_rename_itself() {
|
||||
let cfgs = parse_str("[net]\nname = pretty\nserver = irc.example.org\n");
|
||||
assert_eq!(cfgs.len(), 1);
|
||||
assert_eq!(cfgs[0].name, "pretty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_blanks_are_ignored() {
|
||||
// A lone commented/blank file yields the built-in default single network.
|
||||
let cfgs = parse_str("# comment\n; also\n\n \n");
|
||||
assert_eq!(cfgs.len(), 1);
|
||||
assert_eq!(cfgs[0].server, "irc.tchatou.fr"); // built-in default
|
||||
}
|
||||
80
tests/protocol.rs
Normal file
80
tests/protocol.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! Integration tests for the IRC protocol layer, exercising the public
|
||||
//! `rustbot::irc` API: message parsing, IRCv3 tags, base64, and server-time.
|
||||
|
||||
use rustbot::irc::{base64_encode, parse_server_time, Message};
|
||||
|
||||
#[test]
|
||||
fn parses_privmsg_with_prefix() {
|
||||
let m = Message::parse(":alice!a@host PRIVMSG #chan :hi there\r\n").unwrap();
|
||||
assert_eq!(m.command, "PRIVMSG");
|
||||
assert_eq!(m.nick(), Some("alice"));
|
||||
assert_eq!(m.param(0), Some("#chan"));
|
||||
assert_eq!(m.trailing(), Some("hi there"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ping_without_prefix() {
|
||||
let m = Message::parse("PING :LAG1234").unwrap();
|
||||
assert_eq!(m.command, "PING");
|
||||
assert!(m.prefix.is_none());
|
||||
assert_eq!(m.trailing(), Some("LAG1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_unescapes_tags() {
|
||||
let m = Message::parse("@id=123;key=a\\sb\\:c :n!u@h PRIVMSG #c :yo").unwrap();
|
||||
assert_eq!(m.tag("id"), Some("123"));
|
||||
assert_eq!(m.tag("key"), Some("a b;c"));
|
||||
assert_eq!(m.command, "PRIVMSG");
|
||||
assert_eq!(m.trailing(), Some("yo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_numeric_reply() {
|
||||
let m = Message::parse(":srv 001 me :Welcome to the network").unwrap();
|
||||
assert_eq!(m.command, "001");
|
||||
assert_eq!(m.param(0), Some("me"));
|
||||
assert_eq!(m.trailing(), Some("Welcome to the network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_prefix_is_split() {
|
||||
// Prefix parsing is exercised through the public `Message::parse`.
|
||||
let m = Message::parse(":nick!user@host.example PRIVMSG #c :hi").unwrap();
|
||||
let p = m.prefix.expect("prefix present");
|
||||
assert_eq!(p.nick.as_deref(), Some("nick"));
|
||||
assert_eq!(p.user.as_deref(), Some("user"));
|
||||
assert_eq!(p.host.as_deref(), Some("host.example"));
|
||||
assert_eq!(p.name(), "nick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_is_none() {
|
||||
assert!(Message::parse(" \r\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_matches_known_vectors() {
|
||||
assert_eq!(base64_encode(b""), "");
|
||||
assert_eq!(base64_encode(b"f"), "Zg==");
|
||||
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
||||
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||
// A SASL PLAIN payload: authzid="" authcid="me" passwd="pw"
|
||||
assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_time_parses_to_unix_seconds() {
|
||||
assert_eq!(parse_server_time("1970-01-01T00:00:00.000Z"), Some(0));
|
||||
assert_eq!(parse_server_time("2020-01-01T00:00:00Z"), Some(1_577_836_800));
|
||||
assert_eq!(parse_server_time("2021-01-01T00:00:00.123Z"), Some(1_609_459_200));
|
||||
assert_eq!(parse_server_time("not-a-timestamp"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_message_tags_via_accessor() {
|
||||
let m = Message::parse("@time=2021-01-01T00:00:00Z;batch=42 :s PRIVMSG #c :hi").unwrap();
|
||||
assert_eq!(m.tag("batch"), Some("42"));
|
||||
assert_eq!(m.tag("time"), Some("2021-01-01T00:00:00Z"));
|
||||
assert_eq!(m.tag("missing"), None);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue