Remove code comments

This commit is contained in:
Jean Chevronnet 2026-07-29 16:24:30 +00:00
parent a5e58493d8
commit 52d7c13013
17 changed files with 18 additions and 335 deletions

View file

@ -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 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",
@ -27,16 +20,11 @@ const WANTED_CAPS: &[&str] = &[
];
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());
}
@ -51,14 +39,12 @@ impl Bot {
}
self.after_cap_ack()?;
}
Some("NAK") => self.conn.cap_end()?, // requested caps refused; carry on
_ => {} // NEW/DEL: ignored for now
Some("NAK") => self.conn.cap_end()?,
_ => {}
}
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()
@ -75,24 +61,21 @@ impl Bot {
}
}
/// 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 +"
self.conn.authenticate("PLAIN")
} 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)?;
}

View file

@ -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 super::module::{Action, Command};
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
@ -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 {
if let Some(reference) = msg.tag("batch") {
if let Some(btype) = self.batches.get(reference) {
@ -47,10 +33,7 @@ impl Bot {
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(());
}
@ -59,21 +42,16 @@ impl Bot {
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(());
};
@ -84,8 +62,6 @@ impl Bot {
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(
&mut self,
reply_to: &str,
@ -99,7 +75,7 @@ impl Bot {
}
let Some(&idx) = self.routes.get(command) else {
return Ok(()); // Unknown command: stay silent rather than spam channels.
return Ok(());
};
let cmd = Command {
@ -120,8 +96,6 @@ impl Bot {
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 {
let p = &self.config.command_prefix;
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 {
target.starts_with(['#', '&', '+', '!'])
}

View file

@ -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::io;
@ -19,26 +11,16 @@ pub mod modules;
pub use module::{Action, Command, CommandSpec, Module};
/// The bot: owns its configuration, connection, and feature modules, 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,
/// Registered feature modules (a fresh set per network/session).
modules: Vec<Box<dyn Module>>,
/// Command word -> index into `modules`, built from each module's specs.
routes: HashMap<&'static str, usize>,
}
@ -46,7 +28,6 @@ impl Bot {
pub fn new(config: Config, conn: Connection) -> Bot {
let current_nick = config.nick.clone();
// Build the module set and the command -> module route table.
let mods = modules::all();
let mut routes: HashMap<&'static str, usize> = HashMap::new();
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<()> {
self.register()?;
while let Some(msg) = self.conn.read_message()? {
@ -87,9 +64,6 @@ impl Bot {
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)?;
@ -102,30 +76,23 @@ impl Bot {
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.
"903" => 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),
// 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();

View file

@ -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)]
pub struct CommandSpec {
/// The command word, lowercase, without the prefix (e.g. `"roll"`).
pub name: &'static str,
/// Usage shown in help, without the prefix (e.g. `"roll [NdM]"`).
pub usage: &'static str,
/// One-line description shown by `help <command>`.
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> {
/// Nick of the sender.
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,
/// The command word (lowercased, prefix stripped).
pub name: &'a str,
/// Arguments after the command word (whitespace-split).
pub args: &'a [&'a str],
/// The configured command prefix (e.g. `">"`), for building help text.
pub prefix: &'a str,
/// The network's name (`Config::name`), for network-aware modules.
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 {
/// Send a `PRIVMSG` back to [`Command::reply_to`].
Reply(String),
/// Send an arbitrary raw IRC line (for power modules, e.g. an admin module).
Raw(String),
}
impl Action {
/// Convenience constructor for a reply from anything string-like.
pub fn reply(text: impl Into<String>) -> Action {
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 {
/// Short identifier for logs and diagnostics (e.g. `"dice"`).
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];
/// 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>;
}

View file

@ -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};
/// Core commands that ship with the bot.
pub struct Builtins;
const COMMANDS: &[CommandSpec] = &[

View file

@ -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 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",
}];
/// Upper bounds to keep output (and the channel) sane.
const MAX_DICE: u64 = 100;
const MAX_SIDES: u64 = 1000;
/// Dice roller with a self-contained xorshift PRNG — fine for dice, not for
/// anything security-sensitive.
pub struct Dice {
rng: u64,
}
@ -29,7 +21,7 @@ impl Dice {
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.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 {
@ -41,7 +33,6 @@ impl Dice {
x
}
/// Roll a single die in `1..=sides`.
fn die(&mut self, sides: u64) -> u64 {
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)> {
let (count, sides) = match s.split_once('d') {
Some((c, m)) => {

View file

@ -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 dice;
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>> {
vec![
Box::new(builtins::Builtins),