Remove code comments
This commit is contained in:
parent
a5e58493d8
commit
52d7c13013
17 changed files with 18 additions and 335 deletions
|
|
@ -1,15 +1,8 @@
|
||||||
//! 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 std::io;
|
||||||
|
|
||||||
use super::Bot;
|
use super::Bot;
|
||||||
use crate::irc::{base64_encode, Message};
|
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] = &[
|
const WANTED_CAPS: &[&str] = &[
|
||||||
"message-tags",
|
"message-tags",
|
||||||
"server-time",
|
"server-time",
|
||||||
|
|
@ -27,16 +20,11 @@ const WANTED_CAPS: &[&str] = &[
|
||||||
];
|
];
|
||||||
|
|
||||||
impl Bot {
|
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<()> {
|
pub(super) fn handle_cap(&mut self, msg: &Message) -> io::Result<()> {
|
||||||
// :server CAP <target> <subcommand> [*] :<space-separated caps>
|
|
||||||
match msg.param(1) {
|
match msg.param(1) {
|
||||||
Some("LS") => {
|
Some("LS") => {
|
||||||
// A "*" in the third field means the list continues in more lines.
|
|
||||||
let more = msg.param(2) == Some("*");
|
let more = msg.param(2) == Some("*");
|
||||||
for cap in msg.trailing().unwrap_or("").split_whitespace() {
|
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);
|
let name = cap.split('=').next().unwrap_or(cap);
|
||||||
self.available_caps.insert(name.to_string());
|
self.available_caps.insert(name.to_string());
|
||||||
}
|
}
|
||||||
|
|
@ -51,14 +39,12 @@ impl Bot {
|
||||||
}
|
}
|
||||||
self.after_cap_ack()?;
|
self.after_cap_ack()?;
|
||||||
}
|
}
|
||||||
Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on
|
Some("NAK") => self.conn.cap_end()?,
|
||||||
_ => {} // NEW/DEL: ignored for now
|
_ => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
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<()> {
|
fn request_caps(&mut self) -> io::Result<()> {
|
||||||
let mut wanted: Vec<&str> = WANTED_CAPS
|
let mut wanted: Vec<&str> = WANTED_CAPS
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -75,24 +61,21 @@ impl Bot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After caps are acknowledged, either start SASL or finish negotiation.
|
|
||||||
fn after_cap_ack(&mut self) -> io::Result<()> {
|
fn after_cap_ack(&mut self) -> io::Result<()> {
|
||||||
if self.enabled_caps.contains("sasl")
|
if self.enabled_caps.contains("sasl")
|
||||||
&& self.sasl_credentials().is_some()
|
&& self.sasl_credentials().is_some()
|
||||||
&& !self.sasl_started
|
&& !self.sasl_started
|
||||||
{
|
{
|
||||||
self.sasl_started = true;
|
self.sasl_started = true;
|
||||||
self.conn.authenticate("PLAIN") // server replies with "AUTHENTICATE +"
|
self.conn.authenticate("PLAIN")
|
||||||
} else {
|
} else {
|
||||||
self.conn.cap_end()
|
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<()> {
|
pub(super) fn handle_authenticate(&mut self, msg: &Message) -> io::Result<()> {
|
||||||
if msg.param(0) == Some("+") {
|
if msg.param(0) == Some("+") {
|
||||||
if let Some((user, pass)) = self.sasl_credentials() {
|
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());
|
let encoded = base64_encode(format!("\0{user}\0{pass}").as_bytes());
|
||||||
self.conn.authenticate(&encoded)?;
|
self.conn.authenticate(&encoded)?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,13 @@
|
||||||
//! 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 std::io;
|
||||||
|
|
||||||
use super::module::{Action, Command};
|
use super::module::{Action, Command};
|
||||||
use super::Bot;
|
use super::Bot;
|
||||||
use crate::irc::{now_unix, parse_server_time, Message};
|
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;
|
const HISTORY_GRACE_SECS: u64 = 60;
|
||||||
|
|
||||||
impl Bot {
|
impl Bot {
|
||||||
/// Track IRCv3 batches so [`Bot::is_historical`] can spot replayed history.
|
|
||||||
pub(super) fn handle_batch(&mut self, msg: &Message) {
|
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 };
|
let Some(reference) = msg.param(0) else { return };
|
||||||
if let Some(name) = reference.strip_prefix('+') {
|
if let Some(name) = reference.strip_prefix('+') {
|
||||||
self.batches
|
self.batches
|
||||||
|
|
@ -28,9 +17,6 @@ impl Bot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
fn is_historical(&self, msg: &Message) -> bool {
|
||||||
if let Some(reference) = msg.tag("batch") {
|
if let Some(reference) = msg.tag("batch") {
|
||||||
if let Some(btype) = self.batches.get(reference) {
|
if let Some(btype) = self.batches.get(reference) {
|
||||||
|
|
@ -47,10 +33,7 @@ impl Bot {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a channel or private message, routing recognised commands.
|
|
||||||
pub(super) fn handle_privmsg(&mut self, msg: &Message) -> io::Result<()> {
|
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) {
|
if self.is_historical(msg) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -59,21 +42,16 @@ impl Bot {
|
||||||
let text = msg.trailing().unwrap_or("");
|
let text = msg.trailing().unwrap_or("");
|
||||||
let sender = msg.nick().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) {
|
if sender.eq_ignore_ascii_case(&self.current_nick) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reply in-channel for channel messages, or back to the user for DMs.
|
|
||||||
let reply_to = if is_channel(target) {
|
let reply_to = if is_channel(target) {
|
||||||
target.to_string()
|
target.to_string()
|
||||||
} else {
|
} else {
|
||||||
sender.to_string()
|
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 {
|
let Some(rest) = text.strip_prefix(&self.config.command_prefix) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
@ -84,8 +62,6 @@ impl Bot {
|
||||||
self.handle_command(&reply_to, sender, &command, &args)
|
self.handle_command(&reply_to, sender, &command, &args)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Route a command to the module that declares it and execute the actions
|
|
||||||
/// it returns. `help` is handled here, aggregating every module's commands.
|
|
||||||
fn handle_command(
|
fn handle_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
reply_to: &str,
|
reply_to: &str,
|
||||||
|
|
@ -99,7 +75,7 @@ impl Bot {
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(&idx) = self.routes.get(command) else {
|
let Some(&idx) = self.routes.get(command) else {
|
||||||
return Ok(()); // Unknown command: stay silent rather than spam channels.
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let cmd = Command {
|
let cmd = Command {
|
||||||
|
|
@ -120,8 +96,6 @@ impl Bot {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the `help` reply: with no argument, list every module's commands;
|
|
||||||
/// with a command name, show that command's description.
|
|
||||||
fn help_text(&self, arg: Option<&str>) -> String {
|
fn help_text(&self, arg: Option<&str>) -> String {
|
||||||
let p = &self.config.command_prefix;
|
let p = &self.config.command_prefix;
|
||||||
if let Some(name) = arg {
|
if let Some(name) = arg {
|
||||||
|
|
@ -143,7 +117,6 @@ impl Bot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Channels start with one of the standard channel-type prefixes.
|
|
||||||
fn is_channel(target: &str) -> bool {
|
fn is_channel(target: &str) -> bool {
|
||||||
target.starts_with(['#', '&', '+', '!'])
|
target.starts_with(['#', '&', '+', '!'])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,3 @@
|
||||||
//! Bot behavior: the [`Bot`] state, registration, and the event loop.
|
|
||||||
//!
|
|
||||||
//! Behaviour is split across sibling modules: capability negotiation and SASL
|
|
||||||
//! live in [`caps`], and message routing in [`commands`]. Features live behind
|
|
||||||
//! the [`Module`] plugin trait ([`module`]) and are registered in [`modules`],
|
|
||||||
//! so adding a command is a new file in `bot/modules/` plus one line in
|
|
||||||
//! [`modules::all`] — no changes to the core dispatch.
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
|
|
@ -19,26 +11,16 @@ pub mod modules;
|
||||||
|
|
||||||
pub use module::{Action, Command, CommandSpec, Module};
|
pub use module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
||||||
/// The bot: owns its configuration, connection, and feature modules, and runs
|
|
||||||
/// one session.
|
|
||||||
pub struct Bot {
|
pub struct Bot {
|
||||||
config: Config,
|
config: Config,
|
||||||
conn: Connection,
|
conn: Connection,
|
||||||
/// The nick we are currently trying to use (may drift from `config.nick`
|
|
||||||
/// if the server reports a collision).
|
|
||||||
current_nick: String,
|
current_nick: String,
|
||||||
registered: bool,
|
registered: bool,
|
||||||
/// Capabilities the server advertised in `CAP LS`.
|
|
||||||
available_caps: HashSet<String>,
|
available_caps: HashSet<String>,
|
||||||
/// Capabilities we successfully negotiated (`CAP ACK`).
|
|
||||||
enabled_caps: HashSet<String>,
|
enabled_caps: HashSet<String>,
|
||||||
/// Open IRCv3 batches: reference -> batch type (e.g. `chathistory`).
|
|
||||||
batches: HashMap<String, String>,
|
batches: HashMap<String, String>,
|
||||||
/// Whether the SASL exchange has begun this session.
|
|
||||||
sasl_started: bool,
|
sasl_started: bool,
|
||||||
/// Registered feature modules (a fresh set per network/session).
|
|
||||||
modules: Vec<Box<dyn Module>>,
|
modules: Vec<Box<dyn Module>>,
|
||||||
/// Command word -> index into `modules`, built from each module's specs.
|
|
||||||
routes: HashMap<&'static str, usize>,
|
routes: HashMap<&'static str, usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +28,6 @@ impl Bot {
|
||||||
pub fn new(config: Config, conn: Connection) -> Bot {
|
pub fn new(config: Config, conn: Connection) -> Bot {
|
||||||
let current_nick = config.nick.clone();
|
let current_nick = config.nick.clone();
|
||||||
|
|
||||||
// Build the module set and the command -> module route table.
|
|
||||||
let mods = modules::all();
|
let mods = modules::all();
|
||||||
let mut routes: HashMap<&'static str, usize> = HashMap::new();
|
let mut routes: HashMap<&'static str, usize> = HashMap::new();
|
||||||
for (i, m) in mods.iter().enumerate() {
|
for (i, m) in mods.iter().enumerate() {
|
||||||
|
|
@ -75,10 +56,6 @@ impl Bot {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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<()> {
|
pub fn run(&mut self) -> io::Result<()> {
|
||||||
self.register()?;
|
self.register()?;
|
||||||
while let Some(msg) = self.conn.read_message()? {
|
while let Some(msg) = self.conn.read_message()? {
|
||||||
|
|
@ -87,9 +64,6 @@ impl Bot {
|
||||||
Ok(())
|
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<()> {
|
fn register(&mut self) -> io::Result<()> {
|
||||||
if let Some(password) = self.config.password.clone() {
|
if let Some(password) = self.config.password.clone() {
|
||||||
self.conn.pass(&password)?;
|
self.conn.pass(&password)?;
|
||||||
|
|
@ -102,30 +76,23 @@ impl Bot {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch a single incoming message.
|
|
||||||
fn handle(&mut self, msg: &Message) -> io::Result<()> {
|
fn handle(&mut self, msg: &Message) -> io::Result<()> {
|
||||||
match msg.command.as_str() {
|
match msg.command.as_str() {
|
||||||
// Keep-alive: reply immediately or the server will time us out.
|
|
||||||
"PING" => {
|
"PING" => {
|
||||||
let token = msg.trailing().unwrap_or("").to_string();
|
let token = msg.trailing().unwrap_or("").to_string();
|
||||||
self.conn.pong(&token)?;
|
self.conn.pong(&token)?;
|
||||||
}
|
}
|
||||||
// IRCv3 capability negotiation and SASL (see `caps`).
|
|
||||||
"CAP" => self.handle_cap(msg)?,
|
"CAP" => self.handle_cap(msg)?,
|
||||||
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
"AUTHENTICATE" => self.handle_authenticate(msg)?,
|
||||||
"903" => self.conn.cap_end()?, // RPL_SASLSUCCESS
|
"903" => self.conn.cap_end()?,
|
||||||
// SASL failed/aborted/locked/too-long/already-authed — proceed anyway.
|
|
||||||
"902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?,
|
"902" | "904" | "905" | "906" | "907" => self.conn.cap_end()?,
|
||||||
// Track open/closed batches so we can recognise replayed history.
|
|
||||||
"BATCH" => self.handle_batch(msg),
|
"BATCH" => self.handle_batch(msg),
|
||||||
// RPL_WELCOME: registration succeeded — now it's safe to join.
|
|
||||||
"001" => {
|
"001" => {
|
||||||
self.registered = true;
|
self.registered = true;
|
||||||
for channel in self.config.channels.clone() {
|
for channel in self.config.channels.clone() {
|
||||||
self.conn.join(&channel)?;
|
self.conn.join(&channel)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ERR_NICKNAMEINUSE: append an underscore and try again.
|
|
||||||
"433" => {
|
"433" => {
|
||||||
self.current_nick.push('_');
|
self.current_nick.push('_');
|
||||||
let nick = self.current_nick.clone();
|
let nick = self.current_nick.clone();
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,32 @@
|
||||||
//! The module (plugin) framework: the [`Module`] trait plus the [`Command`]
|
|
||||||
//! context it receives and the [`Action`]s it returns.
|
|
||||||
//!
|
|
||||||
//! Modules are pure in spirit: given a [`Command`], a module returns the
|
|
||||||
//! [`Action`]s to perform without ever touching the connection. That keeps them
|
|
||||||
//! trivially unit-testable and sidesteps borrow conflicts with the [`super::Bot`]
|
|
||||||
//! that owns them (a module never needs `&mut` access to the bot).
|
|
||||||
|
|
||||||
/// Metadata describing one command a module provides. Used to route commands to
|
|
||||||
/// modules and to auto-generate `help`.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct CommandSpec {
|
pub struct CommandSpec {
|
||||||
/// The command word, lowercase, without the prefix (e.g. `"roll"`).
|
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
/// Usage shown in help, without the prefix (e.g. `"roll [NdM]"`).
|
|
||||||
pub usage: &'static str,
|
pub usage: &'static str,
|
||||||
/// One-line description shown by `help <command>`.
|
|
||||||
pub about: &'static str,
|
pub about: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The context passed to a module for one command invocation. All fields borrow
|
|
||||||
/// from the current message and configuration.
|
|
||||||
pub struct Command<'a> {
|
pub struct Command<'a> {
|
||||||
/// Nick of the sender.
|
|
||||||
pub sender: &'a str,
|
pub sender: &'a str,
|
||||||
/// Where a reply should go: the channel for channel messages, or the
|
|
||||||
/// sender's nick for private messages.
|
|
||||||
pub reply_to: &'a str,
|
pub reply_to: &'a str,
|
||||||
/// The command word (lowercased, prefix stripped).
|
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
/// Arguments after the command word (whitespace-split).
|
|
||||||
pub args: &'a [&'a str],
|
pub args: &'a [&'a str],
|
||||||
/// The configured command prefix (e.g. `">"`), for building help text.
|
|
||||||
pub prefix: &'a str,
|
pub prefix: &'a str,
|
||||||
/// The network's name (`Config::name`), for network-aware modules.
|
|
||||||
pub network: &'a str,
|
pub network: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Something a module asks the bot to do in response to a command. Modules
|
|
||||||
/// return these instead of performing I/O themselves.
|
|
||||||
pub enum Action {
|
pub enum Action {
|
||||||
/// Send a `PRIVMSG` back to [`Command::reply_to`].
|
|
||||||
Reply(String),
|
Reply(String),
|
||||||
/// Send an arbitrary raw IRC line (for power modules, e.g. an admin module).
|
|
||||||
Raw(String),
|
Raw(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Action {
|
impl Action {
|
||||||
/// Convenience constructor for a reply from anything string-like.
|
|
||||||
pub fn reply(text: impl Into<String>) -> Action {
|
pub fn reply(text: impl Into<String>) -> Action {
|
||||||
Action::Reply(text.into())
|
Action::Reply(text.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bot feature: a self-contained unit that declares the commands it handles
|
|
||||||
/// and turns each invocation into [`Action`]s.
|
|
||||||
///
|
|
||||||
/// Add one by creating a file in `bot/modules/` that implements this trait and
|
|
||||||
/// listing its constructor in [`super::modules::all`].
|
|
||||||
pub trait Module {
|
pub trait Module {
|
||||||
/// Short identifier for logs and diagnostics (e.g. `"dice"`).
|
|
||||||
fn name(&self) -> &'static str;
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
/// The commands this module provides. The registry routes matching command
|
|
||||||
/// words here and builds `help` from these specs.
|
|
||||||
fn commands(&self) -> &'static [CommandSpec];
|
fn commands(&self) -> &'static [CommandSpec];
|
||||||
|
|
||||||
/// Handle one command (only ever called for a name in [`Module::commands`]).
|
|
||||||
/// Return the actions to perform; an empty vec does nothing.
|
|
||||||
fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
|
fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
//! The built-in commands: `ping`, `echo`, `hello`. (`help` is provided by the
|
|
||||||
//! dispatcher itself, since it aggregates every module's commands.)
|
|
||||||
|
|
||||||
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
||||||
/// Core commands that ship with the bot.
|
|
||||||
pub struct Builtins;
|
pub struct Builtins;
|
||||||
|
|
||||||
const COMMANDS: &[CommandSpec] = &[
|
const COMMANDS: &[CommandSpec] = &[
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,3 @@
|
||||||
//! A dice roller: `roll 2d6`, `roll d20`, or `roll` (defaults to `1d6`).
|
|
||||||
//!
|
|
||||||
//! Demonstrates a *stateful* module — it carries its own tiny PRNG, seeded once
|
|
||||||
//! at construction — while keeping the crate dependency-free (no `rand`).
|
|
||||||
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||||
|
|
@ -13,12 +8,9 @@ const COMMANDS: &[CommandSpec] = &[CommandSpec {
|
||||||
about: "roll N dice of M sides (default 1d6), e.g. roll 2d20",
|
about: "roll N dice of M sides (default 1d6), e.g. roll 2d20",
|
||||||
}];
|
}];
|
||||||
|
|
||||||
/// Upper bounds to keep output (and the channel) sane.
|
|
||||||
const MAX_DICE: u64 = 100;
|
const MAX_DICE: u64 = 100;
|
||||||
const MAX_SIDES: u64 = 1000;
|
const MAX_SIDES: u64 = 1000;
|
||||||
|
|
||||||
/// Dice roller with a self-contained xorshift PRNG — fine for dice, not for
|
|
||||||
/// anything security-sensitive.
|
|
||||||
pub struct Dice {
|
pub struct Dice {
|
||||||
rng: u64,
|
rng: u64,
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +21,7 @@ impl Dice {
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.map(|d| d.as_nanos() as u64)
|
.map(|d| d.as_nanos() as u64)
|
||||||
.unwrap_or(0x9E37_79B9_7F4A_7C15);
|
.unwrap_or(0x9E37_79B9_7F4A_7C15);
|
||||||
Dice { rng: seed | 1 } // never seed to 0 (xorshift would stay stuck)
|
Dice { rng: seed | 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_u64(&mut self) -> u64 {
|
fn next_u64(&mut self) -> u64 {
|
||||||
|
|
@ -41,7 +33,6 @@ impl Dice {
|
||||||
x
|
x
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Roll a single die in `1..=sides`.
|
|
||||||
fn die(&mut self, sides: u64) -> u64 {
|
fn die(&mut self, sides: u64) -> u64 {
|
||||||
self.next_u64() % sides + 1
|
self.next_u64() % sides + 1
|
||||||
}
|
}
|
||||||
|
|
@ -81,8 +72,6 @@ impl Module for Dice {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `NdM` / `dM` / `M` into `(count, sides)` within sane bounds. A bare
|
|
||||||
/// number `M` means `1dM`.
|
|
||||||
fn parse_spec(s: &str) -> Option<(u64, u64)> {
|
fn parse_spec(s: &str) -> Option<(u64, u64)> {
|
||||||
let (count, sides) = match s.split_once('d') {
|
let (count, sides) = match s.split_once('d') {
|
||||||
Some((c, m)) => {
|
Some((c, m)) => {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
//! The registry of feature modules. Add a feature by creating a file here that
|
|
||||||
//! implements [`super::module::Module`] and listing its constructor in [`all`].
|
|
||||||
|
|
||||||
pub mod builtins;
|
pub mod builtins;
|
||||||
pub mod dice;
|
pub mod dice;
|
||||||
|
|
||||||
use super::module::Module;
|
use super::module::Module;
|
||||||
|
|
||||||
/// Construct the default set of modules, in priority order (earlier modules win
|
|
||||||
/// command-name collisions). Each network's [`super::Bot`] gets its own fresh
|
|
||||||
/// set, so stateful modules keep independent per-network state.
|
|
||||||
pub fn all() -> Vec<Box<dyn Module>> {
|
pub fn all() -> Vec<Box<dyn Module>> {
|
||||||
vec![
|
vec![
|
||||||
Box::new(builtins::Builtins),
|
Box::new(builtins::Builtins),
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,20 @@
|
||||||
//! 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::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crate::log;
|
use crate::log;
|
||||||
|
|
||||||
/// Everything the bot needs to know to connect to one network and behave.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Config {
|
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 name: String,
|
||||||
pub server: String,
|
pub server: String,
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Connect over TLS (typically port 6697) instead of plaintext.
|
|
||||||
pub tls: bool,
|
pub tls: bool,
|
||||||
pub nick: String,
|
pub nick: String,
|
||||||
pub user: String,
|
pub user: String,
|
||||||
pub realname: String,
|
pub realname: String,
|
||||||
pub channels: Vec<String>,
|
pub channels: Vec<String>,
|
||||||
/// Prefix that marks a chat message as a bot command, e.g. `!`.
|
|
||||||
pub command_prefix: String,
|
pub command_prefix: String,
|
||||||
/// Optional server password (`PASS`). Not the same as SASL/NickServ.
|
|
||||||
pub password: Option<String>,
|
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_user: Option<String>,
|
||||||
pub sasl_pass: Option<String>,
|
pub sasl_pass: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
@ -51,19 +38,8 @@ impl Default for Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed `key = value` pair with its 1-based source line, for diagnostics.
|
|
||||||
type Entry = (usize, String, String);
|
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> {
|
pub fn load() -> Vec<Config> {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let content = path.as_ref().and_then(|p| match fs::read_to_string(p) {
|
let content = path.as_ref().and_then(|p| match fs::read_to_string(p) {
|
||||||
|
|
@ -82,8 +58,6 @@ pub fn load() -> Vec<Config> {
|
||||||
None => (Vec::new(), Vec::new()),
|
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();
|
let mut base = Config::default();
|
||||||
for (line, key, value) in &toplevel {
|
for (line, key, value) in &toplevel {
|
||||||
apply_kv(&mut base, key, value, &loc(&path, *line));
|
apply_kv(&mut base, key, value, &loc(&path, *line));
|
||||||
|
|
@ -93,8 +67,6 @@ pub fn load() -> Vec<Config> {
|
||||||
assemble(base, sections, &path)
|
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> {
|
pub fn parse_str(content: &str) -> Vec<Config> {
|
||||||
let (toplevel, sections) = parse_sections(content);
|
let (toplevel, sections) = parse_sections(content);
|
||||||
let mut base = Config::default();
|
let mut base = Config::default();
|
||||||
|
|
@ -104,9 +76,6 @@ pub fn parse_str(content: &str) -> Vec<Config> {
|
||||||
assemble(base, sections, &None)
|
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> {
|
fn assemble(base: Config, sections: Vec<(String, Vec<Entry>)>, path: &Option<PathBuf>) -> Vec<Config> {
|
||||||
let mut configs = Vec::new();
|
let mut configs = Vec::new();
|
||||||
if sections.is_empty() {
|
if sections.is_empty() {
|
||||||
|
|
@ -127,9 +96,6 @@ fn assemble(base: Config, sections: Vec<(String, Vec<Entry>)>, path: &Option<Pat
|
||||||
configs
|
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) {
|
fn finalize(c: &mut Config) {
|
||||||
if c.name.is_empty() {
|
if c.name.is_empty() {
|
||||||
c.name = c.server.clone();
|
c.name = c.server.clone();
|
||||||
|
|
@ -139,12 +105,6 @@ fn finalize(c: &mut Config) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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>)>) {
|
fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
||||||
let mut toplevel: Vec<Entry> = Vec::new();
|
let mut toplevel: Vec<Entry> = Vec::new();
|
||||||
let mut sections: Vec<(String, Vec<Entry>)> = Vec::new();
|
let mut sections: Vec<(String, Vec<Entry>)> = Vec::new();
|
||||||
|
|
@ -155,7 +115,6 @@ fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
||||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Section header: `[name]`.
|
|
||||||
if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
|
||||||
sections.push((inner.trim().to_string(), Vec::new()));
|
sections.push((inner.trim().to_string(), Vec::new()));
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -173,8 +132,6 @@ fn parse_sections(content: &str) -> (Vec<Entry>, Vec<(String, Vec<Entry>)>) {
|
||||||
(toplevel, sections)
|
(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) {
|
fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
||||||
match key {
|
match key {
|
||||||
"name" => c.name = value.to_string(),
|
"name" => c.name = value.to_string(),
|
||||||
|
|
@ -196,7 +153,6 @@ fn apply_kv(c: &mut Config, key: &str, value: &str, where_: &str) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a source location for a line, including the file path when known.
|
|
||||||
fn loc(path: &Option<PathBuf>, line: usize) -> String {
|
fn loc(path: &Option<PathBuf>, line: usize) -> String {
|
||||||
match path {
|
match path {
|
||||||
Some(p) => format!("{}:{}", p.display(), line),
|
Some(p) => format!("{}:{}", p.display(), line),
|
||||||
|
|
@ -204,8 +160,6 @@ fn loc(path: &Option<PathBuf>, line: usize) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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> {
|
fn config_path() -> Option<PathBuf> {
|
||||||
if let Some(arg) = std::env::args().nth(1) {
|
if let Some(arg) = std::env::args().nth(1) {
|
||||||
return Some(PathBuf::from(arg));
|
return Some(PathBuf::from(arg));
|
||||||
|
|
@ -217,12 +171,6 @@ fn config_path() -> Option<PathBuf> {
|
||||||
default.exists().then_some(default)
|
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) {
|
fn apply_env(c: &mut Config) {
|
||||||
if let Ok(v) = std::env::var("IRC_SERVER") {
|
if let Ok(v) = std::env::var("IRC_SERVER") {
|
||||||
c.server = v;
|
c.server = v;
|
||||||
|
|
@ -257,7 +205,6 @@ fn apply_env(c: &mut Config) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a boolean config value. Accepts `1`, `true`, `yes`, `on` (any case).
|
|
||||||
fn parse_bool(value: &str) -> bool {
|
fn parse_bool(value: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
value.trim().to_ascii_lowercase().as_str(),
|
value.trim().to_ascii_lowercase().as_str(),
|
||||||
|
|
@ -265,7 +212,6 @@ fn parse_bool(value: &str) -> bool {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split a channel list on commas and/or whitespace, dropping empties.
|
|
||||||
fn split_list(value: &str) -> Vec<String> {
|
fn split_list(value: &str) -> Vec<String> {
|
||||||
value
|
value
|
||||||
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
.split(|ch: char| ch == ',' || ch.is_whitespace())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
//! 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::io::{self, BufRead, BufReader, Read, Write};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
|
@ -8,29 +5,15 @@ use native_tls::TlsConnector;
|
||||||
|
|
||||||
use super::Message;
|
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 {}
|
trait Stream: Read + Write {}
|
||||||
impl<T: Read + Write> Stream for T {}
|
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 {
|
pub struct Connection {
|
||||||
inner: BufReader<Box<dyn Stream>>,
|
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,
|
log_prefix: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Connection {
|
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> {
|
pub fn connect(host: &str, port: u16, use_tls: bool) -> io::Result<Connection> {
|
||||||
let tcp = TcpStream::connect((host, port))?;
|
let tcp = TcpStream::connect((host, port))?;
|
||||||
let stream: Box<dyn Stream> = if use_tls {
|
let stream: Box<dyn Stream> = if use_tls {
|
||||||
|
|
@ -46,9 +29,6 @@ impl Connection {
|
||||||
Ok(Connection { inner: BufReader::new(stream), log_prefix: String::new() })
|
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) {
|
pub fn set_label(&mut self, label: &str) {
|
||||||
self.log_prefix = if label.is_empty() {
|
self.log_prefix = if label.is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
|
|
@ -57,13 +37,11 @@ impl Connection {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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>> {
|
pub fn read_message(&mut self) -> io::Result<Option<Message>> {
|
||||||
loop {
|
loop {
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
if self.inner.read_line(&mut line)? == 0 {
|
if self.inner.read_line(&mut line)? == 0 {
|
||||||
return Ok(None); // EOF
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let trimmed = line.trim_end_matches(['\r', '\n']);
|
let trimmed = line.trim_end_matches(['\r', '\n']);
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
|
|
@ -73,12 +51,9 @@ impl Connection {
|
||||||
if let Some(msg) = Message::parse(trimmed) {
|
if let Some(msg) = Message::parse(trimmed) {
|
||||||
return Ok(Some(msg));
|
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<()> {
|
pub fn send_raw(&mut self, line: &str) -> io::Result<()> {
|
||||||
let clean = line.replace(['\r', '\n'], " ");
|
let clean = line.replace(['\r', '\n'], " ");
|
||||||
eprintln!("{}>> {clean}", self.log_prefix);
|
eprintln!("{}>> {clean}", self.log_prefix);
|
||||||
|
|
@ -87,8 +62,6 @@ impl Connection {
|
||||||
writer.flush()
|
writer.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Convenience senders -------------------------------------------------
|
|
||||||
|
|
||||||
pub fn pass(&mut self, password: &str) -> io::Result<()> {
|
pub fn pass(&mut self, password: &str) -> io::Result<()> {
|
||||||
self.send_raw(&format!("PASS {password}"))
|
self.send_raw(&format!("PASS {password}"))
|
||||||
}
|
}
|
||||||
|
|
@ -121,8 +94,6 @@ impl Connection {
|
||||||
self.send_raw(&format!("QUIT :{reason}"))
|
self.send_raw(&format!("QUIT :{reason}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- IRCv3 capability negotiation ---------------------------------------
|
|
||||||
|
|
||||||
pub fn cap_ls(&mut self) -> io::Result<()> {
|
pub fn cap_ls(&mut self) -> io::Result<()> {
|
||||||
self.send_raw("CAP LS 302")
|
self.send_raw("CAP LS 302")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,5 @@
|
||||||
//! 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};
|
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 {
|
pub fn base64_encode(input: &[u8]) -> String {
|
||||||
const ALPHABET: &[u8; 64] =
|
const ALPHABET: &[u8; 64] =
|
||||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
@ -22,7 +17,6 @@ pub fn base64_encode(input: &[u8]) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Current Unix time in whole seconds (0 if the clock predates the epoch).
|
|
||||||
pub fn now_unix() -> u64 {
|
pub fn now_unix() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
|
|
@ -30,11 +24,9 @@ pub fn now_unix() -> u64 {
|
||||||
.unwrap_or(0)
|
.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> {
|
pub fn parse_server_time(s: &str) -> Option<u64> {
|
||||||
let (date, time) = s.split_once('T')?;
|
let (date, time) = s.split_once('T')?;
|
||||||
let time = time.trim_end_matches('Z').split('.').next()?; // drop fractional secs
|
let time = time.trim_end_matches('Z').split('.').next()?;
|
||||||
let mut d = date.splitn(3, '-');
|
let mut d = date.splitn(3, '-');
|
||||||
let year: i64 = d.next()?.parse().ok()?;
|
let year: i64 = d.next()?.parse().ok()?;
|
||||||
let month: i64 = d.next()?.parse().ok()?;
|
let month: i64 = d.next()?.parse().ok()?;
|
||||||
|
|
@ -47,8 +39,6 @@ pub fn parse_server_time(s: &str) -> Option<u64> {
|
||||||
u64::try_from(secs).ok()
|
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 {
|
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||||
let y = if m <= 2 { y - 1 } else { y };
|
let y = if m <= 2 { y - 1 } else { y };
|
||||||
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
let era = (if y >= 0 { y } else { y - 399 }) / 400;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
//! Parsing of the IRC wire format into [`Message`] / [`Prefix`] values.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// The source of a message: `nick!user@host`, or a bare nick / server name.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Prefix {
|
pub struct Prefix {
|
||||||
/// The full, unparsed prefix as received.
|
|
||||||
pub raw: String,
|
pub raw: String,
|
||||||
pub nick: Option<String>,
|
pub nick: Option<String>,
|
||||||
pub user: Option<String>,
|
pub user: Option<String>,
|
||||||
|
|
@ -13,7 +9,6 @@ pub struct Prefix {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Prefix {
|
impl Prefix {
|
||||||
/// Parse a prefix per the grammar `servername / (nick [ [ "!" user ] "@" host ])`.
|
|
||||||
fn parse(raw: &str) -> Prefix {
|
fn parse(raw: &str) -> Prefix {
|
||||||
let (nick, user, host) = if let Some((n, rest)) = raw.split_once('!') {
|
let (nick, user, host) = if let Some((n, rest)) = raw.split_once('!') {
|
||||||
match rest.split_once('@') {
|
match rest.split_once('@') {
|
||||||
|
|
@ -23,7 +18,6 @@ impl Prefix {
|
||||||
} else if let Some((n, h)) = raw.split_once('@') {
|
} else if let Some((n, h)) = raw.split_once('@') {
|
||||||
(Some(n), None, Some(h))
|
(Some(n), None, Some(h))
|
||||||
} else {
|
} else {
|
||||||
// A bare nick or a server name — keep it as the nick for convenience.
|
|
||||||
(Some(raw), None, None)
|
(Some(raw), None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -35,36 +29,26 @@ impl Prefix {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best display name for the sender: the nick if we have one, else the raw prefix.
|
|
||||||
pub fn name(&self) -> &str {
|
pub fn name(&self) -> &str {
|
||||||
self.nick.as_deref().unwrap_or(&self.raw)
|
self.nick.as_deref().unwrap_or(&self.raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed IRC message.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
/// IRCv3 message tags (already unescaped). Empty when none were present.
|
|
||||||
pub tags: HashMap<String, String>,
|
pub tags: HashMap<String, String>,
|
||||||
/// The message source, if the server included one.
|
|
||||||
pub prefix: Option<Prefix>,
|
pub prefix: Option<Prefix>,
|
||||||
/// The command, upper-cased (e.g. `PRIVMSG`) or a 3-digit numeric (e.g. `001`).
|
|
||||||
pub command: String,
|
pub command: String,
|
||||||
/// Command parameters; the last one may be a "trailing" value containing spaces.
|
|
||||||
pub params: Vec<String>,
|
pub params: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Message {
|
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> {
|
pub fn parse(line: &str) -> Option<Message> {
|
||||||
let mut rest = line.trim_end_matches(['\r', '\n']).trim_start();
|
let mut rest = line.trim_end_matches(['\r', '\n']).trim_start();
|
||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Tags: `@key=value;key2 ` (optional, space-terminated).
|
|
||||||
let mut tags = HashMap::new();
|
let mut tags = HashMap::new();
|
||||||
if let Some(stripped) = rest.strip_prefix('@') {
|
if let Some(stripped) = rest.strip_prefix('@') {
|
||||||
let (tag_str, r) = stripped.split_once(' ')?;
|
let (tag_str, r) = stripped.split_once(' ')?;
|
||||||
|
|
@ -77,7 +61,6 @@ impl Message {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Source / prefix: `:nick!user@host ` (optional, space-terminated).
|
|
||||||
let mut prefix = None;
|
let mut prefix = None;
|
||||||
if let Some(stripped) = rest.strip_prefix(':') {
|
if let Some(stripped) = rest.strip_prefix(':') {
|
||||||
let (p, r) = stripped.split_once(' ')?;
|
let (p, r) = stripped.split_once(' ')?;
|
||||||
|
|
@ -85,7 +68,6 @@ impl Message {
|
||||||
rest = r.trim_start();
|
rest = r.trim_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Command (required).
|
|
||||||
let (command, mut rest) = match rest.split_once(' ') {
|
let (command, mut rest) = match rest.split_once(' ') {
|
||||||
Some((c, r)) => (c, r.trim_start()),
|
Some((c, r)) => (c, r.trim_start()),
|
||||||
None => (rest, ""),
|
None => (rest, ""),
|
||||||
|
|
@ -94,8 +76,6 @@ impl Message {
|
||||||
return None;
|
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();
|
let mut params = Vec::new();
|
||||||
while !rest.is_empty() {
|
while !rest.is_empty() {
|
||||||
if let Some(trailing) = rest.strip_prefix(':') {
|
if let Some(trailing) = rest.strip_prefix(':') {
|
||||||
|
|
@ -124,28 +104,23 @@ impl Message {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the parameter at `index`, if present.
|
|
||||||
pub fn param(&self, index: usize) -> Option<&str> {
|
pub fn param(&self, index: usize) -> Option<&str> {
|
||||||
self.params.get(index).map(String::as_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> {
|
pub fn trailing(&self) -> Option<&str> {
|
||||||
self.params.last().map(String::as_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> {
|
pub fn nick(&self) -> Option<&str> {
|
||||||
self.prefix.as_ref().and_then(|p| p.nick.as_deref())
|
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> {
|
pub fn tag(&self, key: &str) -> Option<&str> {
|
||||||
self.tags.get(key).map(String::as_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 {
|
fn unescape_tag_value(value: &str) -> String {
|
||||||
let mut out = String::with_capacity(value.len());
|
let mut out = String::with_capacity(value.len());
|
||||||
let mut chars = value.chars();
|
let mut chars = value.chars();
|
||||||
|
|
@ -160,8 +135,8 @@ fn unescape_tag_value(value: &str) -> String {
|
||||||
Some('\\') => out.push('\\'),
|
Some('\\') => out.push('\\'),
|
||||||
Some('r') => out.push('\r'),
|
Some('r') => out.push('\r'),
|
||||||
Some('n') => out.push('\n'),
|
Some('n') => out.push('\n'),
|
||||||
Some(other) => out.push(other), // unknown escape: drop the backslash
|
Some(other) => out.push(other),
|
||||||
None => {} // trailing backslash: ignore
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,4 @@
|
||||||
//! Minimal IRC protocol layer — the "IRC library".
|
#![allow(dead_code)]
|
||||||
//!
|
|
||||||
//! 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 connection;
|
||||||
mod encoding;
|
mod encoding;
|
||||||
|
|
|
||||||
16
src/lib.rs
16
src/lib.rs
|
|
@ -1,23 +1,7 @@
|
||||||
//! 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 bot;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod irc;
|
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) {
|
pub fn log(msg: &str) {
|
||||||
eprintln!("[rustbot] {msg}");
|
eprintln!("[rustbot] {msg}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
src/main.rs
13
src/main.rs
|
|
@ -1,7 +1,3 @@
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
|
@ -24,10 +20,6 @@ fn main() {
|
||||||
names.join(", ")
|
names.join(", ")
|
||||||
));
|
));
|
||||||
|
|
||||||
// One supervisor thread per network. Each keeps its own connection alive
|
|
||||||
// with independent reconnect/backoff, so a failure on one network never
|
|
||||||
// disturbs the others. Labels appear in the logs only when there is more
|
|
||||||
// than one network, keeping single-network output terse and unchanged.
|
|
||||||
let labelled = configs.len() > 1;
|
let labelled = configs.len() > 1;
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for config in configs {
|
for config in configs {
|
||||||
|
|
@ -43,9 +35,6 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keep one network's bot alive across disconnects, with capped exponential
|
|
||||||
/// backoff. Runs until the process is killed. When `labelled`, log lines are
|
|
||||||
/// tagged with the network name so multiple networks stay distinguishable.
|
|
||||||
fn supervise(config: Config, labelled: bool) {
|
fn supervise(config: Config, labelled: bool) {
|
||||||
let tag = if labelled {
|
let tag = if labelled {
|
||||||
format!("[{}] ", config.name)
|
format!("[{}] ", config.name)
|
||||||
|
|
@ -71,8 +60,6 @@ fn supervise(config: Config, labelled: bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a single connection lifecycle for one network: connect, register, loop
|
|
||||||
/// until disconnect.
|
|
||||||
fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> {
|
fn run_once(config: &Config, labelled: bool) -> std::io::Result<()> {
|
||||||
let mut conn = Connection::connect(&config.server, config.port, config.tls)?;
|
let mut conn = Connection::connect(&config.server, config.port, config.tls)?;
|
||||||
if labelled {
|
if labelled {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
//! Integration tests for config layering and `[network]` sections, exercising
|
|
||||||
//! the public `rustbot::config::parse_str` (no filesystem or environment).
|
|
||||||
|
|
||||||
use rustbot::config::parse_str;
|
use rustbot::config::parse_str;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -10,7 +7,6 @@ fn flat_config_is_a_single_network() {
|
||||||
assert_eq!(cfgs[0].server, "irc.example.org");
|
assert_eq!(cfgs[0].server, "irc.example.org");
|
||||||
assert_eq!(cfgs[0].nick, "flatbot");
|
assert_eq!(cfgs[0].nick, "flatbot");
|
||||||
assert_eq!(cfgs[0].channels, vec!["#a", "#b"]);
|
assert_eq!(cfgs[0].channels, vec!["#a", "#b"]);
|
||||||
// The label defaults to the server host.
|
|
||||||
assert_eq!(cfgs[0].name, "irc.example.org");
|
assert_eq!(cfgs[0].name, "irc.example.org");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,17 +22,17 @@ fn sections_inherit_top_level_defaults_and_override() {
|
||||||
let libera = &cfgs[0];
|
let libera = &cfgs[0];
|
||||||
assert_eq!(libera.name, "libera");
|
assert_eq!(libera.name, "libera");
|
||||||
assert_eq!(libera.server, "irc.libera.chat");
|
assert_eq!(libera.server, "irc.libera.chat");
|
||||||
assert_eq!(libera.nick, "shared"); // inherited
|
assert_eq!(libera.nick, "shared");
|
||||||
assert_eq!(libera.channels, vec!["#lobby"]); // inherited
|
assert_eq!(libera.channels, vec!["#lobby"]);
|
||||||
assert!(libera.tls);
|
assert!(libera.tls);
|
||||||
assert_eq!(libera.port, 6697); // TLS-port convention applied per network
|
assert_eq!(libera.port, 6697);
|
||||||
|
|
||||||
let oftc = &cfgs[1];
|
let oftc = &cfgs[1];
|
||||||
assert_eq!(oftc.name, "oftc");
|
assert_eq!(oftc.name, "oftc");
|
||||||
assert_eq!(oftc.server, "irc.oftc.net");
|
assert_eq!(oftc.server, "irc.oftc.net");
|
||||||
assert_eq!(oftc.nick, "other"); // overridden
|
assert_eq!(oftc.nick, "other");
|
||||||
assert_eq!(oftc.channels, vec!["#oftc"]); // overridden
|
assert_eq!(oftc.channels, vec!["#oftc"]);
|
||||||
assert!(!oftc.tls); // default
|
assert!(!oftc.tls);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -48,8 +44,7 @@ fn a_section_may_rename_itself() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn comments_and_blanks_are_ignored() {
|
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");
|
let cfgs = parse_str("# comment\n; also\n\n \n");
|
||||||
assert_eq!(cfgs.len(), 1);
|
assert_eq!(cfgs.len(), 1);
|
||||||
assert_eq!(cfgs[0].server, "irc.tchatou.fr"); // built-in default
|
assert_eq!(cfgs[0].server, "irc.tchatou.fr");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
//! Unit tests for feature modules — constructed directly and driven through the
|
|
||||||
//! public `Module` trait, with no network. This is the payoff of the module
|
|
||||||
//! system: features are testable in isolation.
|
|
||||||
|
|
||||||
use rustbot::bot::module::{Action, Command, Module};
|
use rustbot::bot::module::{Action, Command, Module};
|
||||||
use rustbot::bot::modules::{builtins::Builtins, dice::Dice};
|
use rustbot::bot::modules::{builtins::Builtins, dice::Dice};
|
||||||
|
|
||||||
|
|
@ -9,7 +5,6 @@ fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||||
Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" }
|
Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assert the module produced exactly one reply and return its text.
|
|
||||||
fn reply(actions: &[Action]) -> &str {
|
fn reply(actions: &[Action]) -> &str {
|
||||||
match actions {
|
match actions {
|
||||||
[Action::Reply(s)] => s,
|
[Action::Reply(s)] => s,
|
||||||
|
|
@ -47,7 +42,6 @@ fn dice_2d6_total_is_in_range() {
|
||||||
let mut d = Dice::new();
|
let mut d = Dice::new();
|
||||||
for _ in 0..300 {
|
for _ in 0..300 {
|
||||||
let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string();
|
let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string();
|
||||||
// Format: "2d6: a + b = total"
|
|
||||||
let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap();
|
let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap();
|
||||||
assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}");
|
assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}");
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +51,6 @@ fn dice_2d6_total_is_in_range() {
|
||||||
fn dice_defaults_to_1d6() {
|
fn dice_defaults_to_1d6() {
|
||||||
let mut d = Dice::new();
|
let mut d = Dice::new();
|
||||||
let r = reply(&d.on_command(&cmd("roll", &[]))).to_string();
|
let r = reply(&d.on_command(&cmd("roll", &[]))).to_string();
|
||||||
// Format: "1d6: N"
|
|
||||||
assert!(r.starts_with("1d6: "), "unexpected format: {r:?}");
|
assert!(r.starts_with("1d6: "), "unexpected format: {r:?}");
|
||||||
let n: u64 = r.rsplit(':').next().unwrap().trim().parse().unwrap();
|
let n: u64 = r.rsplit(':').next().unwrap().trim().parse().unwrap();
|
||||||
assert!((1..=6).contains(&n), "1d6 {n} out of range: {r:?}");
|
assert!((1..=6).contains(&n), "1d6 {n} out of range: {r:?}");
|
||||||
|
|
@ -81,7 +74,6 @@ fn dice_rejects_garbage_with_usage() {
|
||||||
#[test]
|
#[test]
|
||||||
fn dice_rejects_out_of_bounds() {
|
fn dice_rejects_out_of_bounds() {
|
||||||
let mut d = Dice::new();
|
let mut d = Dice::new();
|
||||||
// 999 dice exceeds the cap; 1-sided die is below the minimum.
|
|
||||||
assert!(reply(&d.on_command(&cmd("roll", &["999d6"]))).contains("usage"));
|
assert!(reply(&d.on_command(&cmd("roll", &["999d6"]))).contains("usage"));
|
||||||
assert!(reply(&d.on_command(&cmd("roll", &["1d1"]))).contains("usage"));
|
assert!(reply(&d.on_command(&cmd("roll", &["1d1"]))).contains("usage"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
//! 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};
|
use rustbot::irc::{base64_encode, parse_server_time, Message};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -39,7 +36,6 @@ fn parses_numeric_reply() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn full_prefix_is_split() {
|
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 m = Message::parse(":nick!user@host.example PRIVMSG #c :hi").unwrap();
|
||||||
let p = m.prefix.expect("prefix present");
|
let p = m.prefix.expect("prefix present");
|
||||||
assert_eq!(p.nick.as_deref(), Some("nick"));
|
assert_eq!(p.nick.as_deref(), Some("nick"));
|
||||||
|
|
@ -59,7 +55,6 @@ fn base64_matches_known_vectors() {
|
||||||
assert_eq!(base64_encode(b"f"), "Zg==");
|
assert_eq!(base64_encode(b"f"), "Zg==");
|
||||||
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
assert_eq!(base64_encode(b"fo"), "Zm8=");
|
||||||
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
|
||||||
// A SASL PLAIN payload: authzid="" authcid="me" passwd="pw"
|
|
||||||
assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3");
|
assert_eq!(base64_encode(b"\0me\0pw"), "AG1lAHB3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue