From b46a03c13eeb1b8caa54c935a73b76307c0e4bde Mon Sep 17 00:00:00 2001 From: Micheal Smith Date: Sat, 22 Nov 2025 06:20:56 -0600 Subject: [PATCH] Added documentation. --- Cargo.lock | 24 ++++++------- src/chat.rs | 82 +++++++++++++++++++++++++------------------ src/command.rs | 12 ++++++- src/event.rs | 6 ++++ src/event_manager.rs | 50 ++++++++++++++++---------- src/lib.rs | 14 ++++++-- src/plugin.rs | 25 +++++++++++-- src/qna.rs | 7 ++++ src/setup.rs | 12 +++++++ tests/command_test.rs | 8 ++--- 10 files changed, 163 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b8895a..de6d8d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,9 +180,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cargo-husky" @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -236,9 +236,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -824,9 +824,9 @@ dependencies = [ [[package]] name = "genai" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48317c8c4a7011ffb748502f9c45408a351103ad225f26825d84f2ff0ac49b25" +checksum = "814c33e79506556ecba6b5f8e39a2fe423262fd3903856377ad2ae6a857c6032" dependencies = [ "bytes", "derive_more 2.0.1", @@ -2440,9 +2440,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.15.1" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" +checksum = "10574371d41b0d9b2cff89418eda27da52bcaff2cc8741db26382a77c29131f1" dependencies = [ "base64", "chrono", @@ -2459,9 +2459,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.15.1" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +checksum = "08a72d8216842fdd57820dc78d840bef99248e35fb2554ff923319e60f2d686b" dependencies = [ "darling", "proc-macro2", diff --git a/src/chat.rs b/src/chat.rs index f79ec75..ac23693 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1,3 +1,8 @@ +//! Handles interaction with IRC. +//! +//! Each instance of [`Chat`] handles a single connection to an IRC +//! server. + use std::sync::Arc; use color_eyre::{Result, eyre::WrapErr}; @@ -9,48 +14,55 @@ use tracing::{Level, event, instrument}; use crate::{Event, EventManager, LLMHandle, plugin}; +/// Chat struct that is used to interact with IRC chat. #[derive(Debug)] pub struct Chat { + /// The actual IRC [`irc::client`](client). client: Client, + /// Event manager for handling plugin interaction. event_manager: Arc, + /// Handle for whichever LLM is being used. llm_handle: LLMHandle, // FIXME: This needs to be thread safe, and shared, etc. } -// Need: owners, channels, username, nick, server, password -#[instrument] -pub async fn new( - settings: &MainConfig, - handle: &LLMHandle, - manager: Arc, -) -> Result { - // Going to just assign and let the irc library handle errors for now, and - // add my own checking if necessary. - let port: u16 = settings.get("port")?; - let channels: Vec = settings.get("channels").wrap_err("No channels provided.")?; - - event!(Level::INFO, "Channels = {:?}", channels); - - let config = IRCConfig { - server: settings.get_string("server").ok(), - nickname: settings.get_string("nickname").ok(), - port: Some(port), - username: settings.get_string("username").ok(), - use_tls: settings.get_bool("use_tls").ok(), - channels, - ..IRCConfig::default() - }; - - event!(Level::INFO, "IRC connection starting..."); - - Ok(Chat { - client: Client::from_config(config).await?, - llm_handle: handle.clone(), - event_manager: manager, - }) -} - impl Chat { - pub async fn run(&mut self, mut command_in: mpsc::Receiver) -> Result<()> { + // Need: owners, channels, username, nick, server, password rather than reading + // the config values directly. + /// Creates a new [`Chat`]. + #[instrument] + pub async fn new( + settings: &MainConfig, + handle: &LLMHandle, + manager: Arc, + ) -> Result { + // Going to just assign and let the irc library handle errors for now, and + // add my own checking if necessary. + let port: u16 = settings.get("port")?; + let channels: Vec = settings.get("channels").wrap_err("No channels provided.")?; + + event!(Level::INFO, "Channels = {:?}", channels); + + let config = IRCConfig { + server: settings.get_string("server").ok(), + nickname: settings.get_string("nickname").ok(), + port: Some(port), + username: settings.get_string("username").ok(), + use_tls: settings.get_bool("use_tls").ok(), + channels, + ..IRCConfig::default() + }; + + event!(Level::INFO, "IRC connection starting..."); + + Ok(Chat { + client: Client::from_config(config).await?, + llm_handle: handle.clone(), + event_manager: manager, + }) + } + + /// Drives the event loop for the chat. + pub async fn run(&mut self, mut command_in: mpsc::Receiver) -> Result<()> { self.client.identify()?; let mut stream = self.client.stream()?; @@ -69,7 +81,7 @@ impl Chat { command = command_in.recv() => { event!(Level::INFO, "Received command {:#?}", command); match command { - Some(plugin::Plugin::SendMessage {channel, message} ) => { + Some(plugin::PluginMsg::SendMessage {channel, message} ) => { // Now to pass on the message. event!(Level::INFO, "Trying to send to channel."); self.client.send_privmsg(&channel, &message).wrap_err("Couldn't send to channel")?; diff --git a/src/command.rs b/src/command.rs index b441757..0abb65e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,4 +1,8 @@ -// Commands that are associated with external processes (commands). +//! Commands that are associated with external processes (commands). +//! +//! Process based plugins are just an assortment of executable files in +//! a provided directory. They are given arguments, and the response from +//! them is expected on stdout. use std::{ path::{Path, PathBuf}, @@ -10,12 +14,14 @@ use color_eyre::{Result, eyre::eyre}; use tokio::{fs::try_exists, process::Command, time::timeout}; use tracing::{Level, event}; +/// Handle containing information about the directory containing commands. #[derive(Debug)] pub struct CommandDir { command_path: PathBuf, } impl CommandDir { + /// Register a path containing commands. pub fn new(command_path: impl AsRef) -> Self { event!( Level::INFO, @@ -27,6 +33,7 @@ impl CommandDir { } } + /// Look for a command. If it exists Ok(path) is returned. async fn find_command(&self, name: impl AsRef) -> Result { let path = self.command_path.join(name.as_ref()); @@ -43,6 +50,8 @@ impl CommandDir { } } + /// Run the given [`command_name`]. It should exist in the directory provided as + /// the command_path. pub async fn run_command( &self, command_name: impl AsRef, @@ -65,6 +74,7 @@ impl CommandDir { } } + /// [`run_command`] but with a timeout. pub async fn run_command_with_timeout( &self, command_name: impl AsRef, diff --git a/src/event.rs b/src/event.rs index e62b0db..72da7af 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,13 +1,19 @@ +//! Internal representations of incoming events. + use irc::proto::{Command, Message}; use serde::{Deserialize, Serialize}; +/// Represents an event. Probably from IRC. #[derive(Deserialize, Serialize)] pub struct Event { + /// Who is the message from? from: String, + /// What is the message? message: String, } impl Event { + /// Creates a new message. pub fn new(from: impl Into, msg: impl Into) -> Self { Self { from: from.into(), diff --git a/src/event_manager.rs b/src/event_manager.rs index 09afb0b..c350443 100644 --- a/src/event_manager.rs +++ b/src/event_manager.rs @@ -1,3 +1,5 @@ +//! Handler for events to and from IPC, and process plugins. + use std::{collections::VecDeque, path::Path, sync::Arc}; use color_eyre::Result; @@ -9,12 +11,14 @@ use tokio::{ }; use tracing::{error, info}; -use crate::{event::Event, plugin::Plugin}; +use crate::{event::Event, plugin::PluginMsg}; // Hard coding for now. Maybe make this a parameter to new. const EVENT_BUF_MAX: usize = 1000; -// Manager for communication with plugins. +/// Manager for communication with plugins. +/// +/// Keeps events in a ring buffer to track a certain amount of history. #[derive(Debug)] pub struct EventManager { announce: broadcast::Sender, // Everything broadcasts here. @@ -22,6 +26,7 @@ pub struct EventManager { } impl EventManager { + /// Create a new [`EventManager``]. pub fn new() -> Result { let (announce, _) = broadcast::channel(100); @@ -31,6 +36,7 @@ impl EventManager { }) } + /// Broadcast an event to every subscribed listener. pub async fn broadcast(&self, event: &Event) -> Result<()> { let msg = serde_json::to_string(event)? + "\n"; @@ -49,7 +55,10 @@ impl EventManager { } // NB: This assumes it has exclusive control of the FIFO. - pub async fn start_fifo

(path: &P, command_tx: mpsc::Sender) -> Result<()> + /// Opens a fifo at [`path`]. This is where some plugins can send response events + /// to. The messages MUST be formatted in JSON and match one of the possible + /// [`PluginMsg`](plugin messages). + pub async fn start_fifo

(path: &P, command_tx: mpsc::Sender) -> Result<()> where P: AsRef + NixPath + ?Sized, { @@ -65,7 +74,7 @@ impl EventManager { while reader.read_line(&mut line).await? > 0 { // Now handle the command. - let cmd: Plugin = serde_json::from_str(&line)?; + let cmd: PluginMsg = serde_json::from_str(&line)?; info!("Command received: {:?}.", cmd); command_tx.send(cmd).await?; line.clear(); @@ -73,6 +82,8 @@ impl EventManager { } } + /// Start a UNIX socket that will provide broadcast messages to any client that opens + /// the socket for listening. pub async fn start_listening(self: Arc, broadcast_path: impl AsRef) { let listener = UnixListener::bind(broadcast_path).unwrap(); @@ -93,6 +104,7 @@ impl EventManager { } } + /// Send any events queued up to the [`stream`]. async fn send_events(&self, stream: UnixStream) -> Result<()> { let mut writer = stream; @@ -316,7 +328,7 @@ mod tests { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; // Write a command to the FIFO - let cmd = Plugin::SendMessage { + let cmd = PluginMsg::SendMessage { channel: "#test".to_string(), message: "hello".to_string(), }; @@ -338,7 +350,7 @@ mod tests { .expect("channel closed"); match received { - Plugin::SendMessage { channel, message } => { + PluginMsg::SendMessage { channel, message } => { assert_eq!(channel, "#test"); assert_eq!(message, "hello"); } @@ -362,15 +374,15 @@ mod tests { // Write multiple commands let commands = vec![ - Plugin::SendMessage { + PluginMsg::SendMessage { channel: "#chan1".to_string(), message: "first".to_string(), }, - Plugin::SendMessage { + PluginMsg::SendMessage { channel: "#chan2".to_string(), message: "second".to_string(), }, - Plugin::SendMessage { + PluginMsg::SendMessage { channel: "#chan3".to_string(), message: "third".to_string(), }, @@ -395,7 +407,7 @@ mod tests { .expect("channel closed"); match first { - Plugin::SendMessage { channel, message } => { + PluginMsg::SendMessage { channel, message } => { assert_eq!(channel, "#chan1"); assert_eq!(message, "first"); } @@ -407,7 +419,7 @@ mod tests { .expect("channel closed"); match second { - Plugin::SendMessage { channel, message } => { + PluginMsg::SendMessage { channel, message } => { assert_eq!(channel, "#chan2"); assert_eq!(message, "second"); } @@ -419,7 +431,7 @@ mod tests { .expect("channel closed"); match third { - Plugin::SendMessage { channel, message } => { + PluginMsg::SendMessage { channel, message } => { assert_eq!(channel, "#chan3"); assert_eq!(message, "third"); } @@ -449,7 +461,7 @@ mod tests { let tx = pipe::OpenOptions::new().open_sender(&path).unwrap(); let mut tx = tokio::io::BufWriter::new(tx); - let cmd = Plugin::SendMessage { + let cmd = PluginMsg::SendMessage { channel: "#first".to_string(), message: "batch1".to_string(), }; @@ -465,7 +477,7 @@ mod tests { .expect("channel closed"); match first { - Plugin::SendMessage { channel, message } => { + PluginMsg::SendMessage { channel, message } => { assert_eq!(channel, "#first"); assert_eq!(message, "batch1"); } @@ -482,7 +494,7 @@ mod tests { let tx = pipe::OpenOptions::new().open_sender(&fifo_path).unwrap(); let mut tx = tokio::io::BufWriter::new(tx); - let cmd = Plugin::SendMessage { + let cmd = PluginMsg::SendMessage { channel: "#second".to_string(), message: "batch2".to_string(), }; @@ -497,7 +509,7 @@ mod tests { .expect("channel closed"); match second { - Plugin::SendMessage { channel, message } => { + PluginMsg::SendMessage { channel, message } => { assert_eq!(channel, "#second"); assert_eq!(message, "batch2"); } @@ -524,7 +536,7 @@ mod tests { let tx = pipe::OpenOptions::new().open_sender(&fifo_path).unwrap(); let mut tx = tokio::io::BufWriter::new(tx); - let cmd1 = Plugin::SendMessage { + let cmd1 = PluginMsg::SendMessage { channel: "#test".to_string(), message: "first".to_string(), }; @@ -537,7 +549,7 @@ mod tests { // Write whitespace line tx.write_all(b" \n").await.unwrap(); - let cmd2 = Plugin::SendMessage { + let cmd2 = PluginMsg::SendMessage { channel: "#test".to_string(), message: "second".to_string(), }; @@ -553,7 +565,7 @@ mod tests { .expect("channel closed"); match first { - Plugin::SendMessage { channel, message } => { + PluginMsg::SendMessage { channel, message } => { assert_eq!(channel, "#test"); assert_eq!(message, "first"); } diff --git a/src/lib.rs b/src/lib.rs index 7e7f1e6..1a6492a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ -// Robotnik libraries +#![warn(missing_docs)] +#![doc = include_str!("../README.md")] use std::{os::unix::fs, sync::Arc}; @@ -16,6 +17,7 @@ pub mod plugin; pub mod qna; pub mod setup; +pub use chat::Chat; pub use event::Event; pub use event_manager::EventManager; pub use qna::LLMHandle; @@ -25,7 +27,9 @@ const DEFAULT_INSTRUCT: &str = be sent in a single IRC response according to the specification. Keep answers to 500 characters or less."; -// NB: Everything should fail if logging doesn't start properly. +/// Initialize all logging facilities. +/// +/// This should cause a panic if there's a failure. async fn init_logging() { better_panic::install(); setup_panic!(); @@ -37,6 +41,10 @@ async fn init_logging() { tracing::subscriber::set_global_default(subscriber).unwrap(); } +/// Sets up and runs the main event loop. +/// +/// Should return an error if it's recoverable, but could panic if something +/// is particularly bad. pub async fn run() -> Result<()> { init_logging().await; info!("Starting up."); @@ -69,7 +77,7 @@ pub async fn run() -> Result<()> { let ev_manager = Arc::new(EventManager::new()?); let ev_manager_clone = Arc::clone(&ev_manager); - let mut c = chat::new(&config, &handle, Arc::clone(&ev_manager)).await?; + let mut c = Chat::new(&config, &handle, Arc::clone(&ev_manager)).await?; let (from_plugins, to_chat) = mpsc::channel(100); diff --git a/src/plugin.rs b/src/plugin.rs index 59e402a..ec15ffc 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -1,13 +1,32 @@ +//! Plugin command definitions. + +// Dear future me: If you forget the JSON translations in the future you'll +// thank me for the comment overkill. + use std::fmt::Display; use serde::{Deserialize, Serialize}; +/// Message types accepted from plugins. #[derive(Debug, Deserialize, Serialize)] -pub enum Plugin { - SendMessage { channel: String, message: String }, +pub enum PluginMsg { + /// Plugin message indicating the bot should send a [`message`] to [`channel`]. + /// { + /// "SendMessage": { + /// "channel": "channel_name", + /// "message": "your message here" + /// } + /// + /// } + SendMessage { + /// The IRC channel to send the [`message`] to. + channel: String, + /// The [`message`] to send. + message: String, + }, } -impl Display for Plugin { +impl Display for PluginMsg { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::SendMessage { channel, message } => { diff --git a/src/qna.rs b/src/qna.rs index a5658eb..538ee82 100644 --- a/src/qna.rs +++ b/src/qna.rs @@ -1,3 +1,5 @@ +//! Handles communication with a genai compatible LLM. + use color_eyre::Result; use futures::StreamExt; use genai::{ @@ -8,8 +10,11 @@ use genai::{ }; use tracing::info; +// NB: Docs are quick and dirty as this might move into a plugin. + // Represents an LLM completion source. // FIXME: Clone is probably temporary. +/// Struct containing information about the LLM. #[derive(Clone, Debug)] pub struct LLMHandle { chat_request: ChatRequest, @@ -18,6 +23,7 @@ pub struct LLMHandle { } impl LLMHandle { + /// Create a new handle. pub fn new( api_key: String, _base_url: impl AsRef, @@ -44,6 +50,7 @@ impl LLMHandle { }) } + /// Send a chat message to the LLM with the response being returned as a [`String`]. pub async fn send_request(&mut self, message: impl Into) -> Result { let mut req = self.chat_request.clone(); let client = self.client.clone(); diff --git a/src/setup.rs b/src/setup.rs index 7fb4205..040bbc3 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,3 +1,7 @@ +//! Handles configuration for the bot. +//! +//! Both command line, and configuration file options are handled here. + use clap::Parser; use color_eyre::{Result, eyre::WrapErr}; use config::Config; @@ -6,6 +10,7 @@ use std::path::PathBuf; use tracing::{info, instrument}; // TODO: use [clap(long, short, help_heading = Some(section))] +/// Struct of potential arguments. #[derive(Clone, Debug, Parser)] #[command(about, version)] pub struct Args { @@ -30,6 +35,7 @@ pub struct Args { pub instruct: Option, #[arg(long)] + /// Name of the model to use. E.g. 'deepseek-chat' pub model: Option, #[arg(long)] @@ -65,11 +71,17 @@ pub struct Args { pub use_tls: Option, } +/// Handle for interacting with the bot configuration. pub struct Setup { + /// Handle for the configuration file options. pub config: Config, } #[instrument] +/// Initialize a new [`Setup`] instance. +/// +/// This reads the settings file which becomes the bot's default configuration. +/// These settings shall be overridden by any command line options. pub async fn init() -> Result { // Get arguments. These overrule configuration file, and environment // variables if applicable. diff --git a/tests/command_test.rs b/tests/command_test.rs index 56f40d2..7e4b574 100644 --- a/tests/command_test.rs +++ b/tests/command_test.rs @@ -41,12 +41,12 @@ echo "Weather for $1: Sunny, 72°F" ); let cmd_dir = CommandDir::new(temp.path()); - let message = "!weather 73135"; + let message = "!weather 10096"; // Parse the message let (command_name, arg) = parse_bot_message(message).unwrap(); assert_eq!(command_name, "weather"); - assert_eq!(arg, "73135"); + assert_eq!(arg, "10096"); // Find and run the command let result = cmd_dir.run_command(command_name, arg).await; @@ -54,7 +54,7 @@ echo "Weather for $1: Sunny, 72°F" assert!(result.is_ok()); let bytes = result.unwrap(); let output = String::from_utf8_lossy(&bytes); - assert!(output.contains("Weather for 73135")); + assert!(output.contains("Weather for 10096")); assert!(output.contains("Sunny")); } @@ -253,7 +253,7 @@ echo "Why did the robot go on vacation? To recharge!" #[tokio::test] async fn test_non_bot_message_ignored() { // Messages not starting with ! should be ignored - let messages = ["hello world", "weather 73135", "?help", "/command", ""]; + let messages = ["hello world", "weather 10096", "?help", "/command", ""]; for message in messages { assert!(