diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a89179..4ab04ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,8 @@ { - "files.insertFinalNewline": true, - "rust-analyzer.cargo.features": "all", - "rust-analyzer.imports.granularity.enforce": true, - "rust-analyzer.imports.granularity.group": "module", - "rust-analyzer.imports.group.enable": true, - "evenBetterToml.formatter.columnWidth": 100, + "files.insertFinalNewline": true, + "rust-analyzer.cargo.features": "all", + "rust-analyzer.imports.granularity.enforce": true, + "rust-analyzer.imports.granularity.group": "crate", + "rust-analyzer.imports.group.enable": true, + "evenBetterToml.formatter.columnWidth": 100 } diff --git a/Cargo.toml b/Cargo.toml index 0ae6832..6e5b6c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,44 +3,7 @@ name = "euphoxide" version = "0.5.1" edition = "2021" -[features] -bot = ["dep:async-trait", "dep:clap", "dep:cookie"] - [dependencies] -async-trait = { version = "0.1.83", optional = true } -caseless = "0.2.1" -cookie = { version = "0.18.1", optional = true } -futures-util = { version = "0.3.31", default-features = false, features = ["sink"] } -jiff = { version = "0.1.15", features = ["serde"] } -log = "0.4.22" -serde = { version = "1.0.215", features = ["derive"] } -serde_json = "1.0.133" -tokio = { version = "1.42.0", features = ["time", "sync", "macros", "rt"] } -tokio-stream = "0.1.16" -tokio-tungstenite = { version = "0.24.0", features = ["rustls-tls-native-roots"] } -unicode-normalization = "0.1.24" - -[dependencies.clap] -version = "4.5.22" -optional = true -default-features = false -features = ["std", "derive", "deprecated"] - -[dev-dependencies] # For example bot -rustls = "0.23.19" -tokio = { version = "1.42.0", features = ["rt-multi-thread"] } - -[[example]] -name = "testbot_instance" -required-features = ["bot"] - -[[example]] -name = "testbot_instances" -required-features = ["bot"] - -[[example]] -name = "testbot_commands" -required-features = ["bot"] [lints] rust.unsafe_code = { level = "forbid", priority = 1 } diff --git a/examples/testbot_commands.rs b/examples/testbot_commands.rs deleted file mode 100644 index c3afada..0000000 --- a/examples/testbot_commands.rs +++ /dev/null @@ -1,150 +0,0 @@ -// TODO Add description -// TODO Clean up and unify test bots - -use std::sync::Arc; - -use async_trait::async_trait; -use clap::Parser; -use euphoxide::api::Message; -use euphoxide::bot::botrulez::{FullHelp, HasDescriptions, HasStartTime, Ping, ShortHelp, Uptime}; -use euphoxide::bot::command::{Clap, ClapCommand, Context, General, Global, Hidden, Specific}; -use euphoxide::bot::commands::Commands; -use euphoxide::bot::instance::{Event, ServerConfig}; -use euphoxide::bot::instances::Instances; -use euphoxide::conn; -use jiff::Timestamp; -use log::error; -use tokio::sync::mpsc; - -const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide"; - -/// Kill this bot. -#[derive(Parser)] -struct KillArgs; - -struct Kill; - -#[async_trait] -impl ClapCommand for Kill { - type Args = KillArgs; - - async fn execute( - &self, - _args: Self::Args, - msg: &Message, - ctx: &Context, - bot: &mut Bot, - ) -> Result { - bot.stop = true; - ctx.reply(msg.id, "/me dies").await?; - Ok(true) - } -} - -/// Do some testing. -#[derive(Parser)] -struct TestArgs { - /// How much testing to do. - #[arg(default_value_t = 1)] - amount: u64, -} - -struct Test; - -#[async_trait] -impl ClapCommand for Test { - type Args = TestArgs; - - async fn execute( - &self, - args: Self::Args, - msg: &Message, - ctx: &Context, - _bot: &mut Bot, - ) -> Result { - let content = if args.amount == 1 { - format!("/me did {} test", args.amount) - } else { - format!("/me did {} tests", args.amount) - }; - ctx.reply(msg.id, content).await?; - Ok(true) - } -} - -struct Bot { - commands: Arc>, - start_time: Timestamp, - stop: bool, -} - -impl HasDescriptions for Bot { - fn descriptions(&self, ctx: &Context) -> Vec { - self.commands.descriptions(ctx) - } -} - -impl HasStartTime for Bot { - fn start_time(&self) -> Timestamp { - self.start_time - } -} - -#[tokio::main] -async fn main() { - // https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837 - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); - - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut instances = Instances::new(ServerConfig::default()); - - let mut cmds = Commands::new(); - cmds.add(Hidden(General::new("ping", Clap(Ping::default())))); - cmds.add(Specific::new("ping", Clap(Ping::default()))); - cmds.add(Hidden(General::new("help", Clap(ShortHelp::new(HELP))))); - cmds.add(Specific::new("help", Clap(FullHelp::new(HELP, "")))); - cmds.add(Specific::new("uptime", Clap(Uptime))); - cmds.add(Specific::new("kill", Clap(Kill))); - cmds.add(Global::new("test", Clap(Test))); - let cmds = Arc::new(cmds); - - let mut bot = Bot { - commands: cmds.clone(), - start_time: Timestamp::now(), - stop: false, - }; - - for room in ["test", "test2", "testing"] { - let tx_clone = tx.clone(); - let instance = instances - .server_config() - .clone() - .room(room) - .username(Some("TestBot")) - .build(move |e| { - let _ = tx_clone.send(e); - }); - instances.add(instance); - } - - while let Some(event) = rx.recv().await { - instances.purge(); - if instances.is_empty() { - break; - } - - if let Event::Packet(config, packet, snapshot) = event { - let result = cmds - .handle_packet(&config, &packet, &snapshot, &mut bot) - .await; - if let Err(err) = result { - error!("{err}"); - } - if bot.stop { - break; - } - } - } -} diff --git a/examples/testbot_instance.rs b/examples/testbot_instance.rs deleted file mode 100644 index f60f3b9..0000000 --- a/examples/testbot_instance.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! Similar to the `testbot_manual` example, but using [`Instance`] to connect -//! to the room (and to reconnect). - -use euphoxide::api::packet::ParsedPacket; -use euphoxide::api::{Data, Nick, Send}; -use euphoxide::bot::botrulez; -use euphoxide::bot::instance::{ConnSnapshot, Event, ServerConfig}; -use jiff::Timestamp; -use tokio::sync::mpsc; - -const NICK: &str = "TestBot"; -const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide"; - -async fn on_packet(packet: ParsedPacket, snapshot: ConnSnapshot) -> Result<(), ()> { - let data = match packet.content { - Ok(data) => data, - Err(err) => { - println!("Error for {}: {err}", packet.r#type); - return Err(()); - } - }; - - match data { - Data::HelloEvent(ev) => println!("Connected with id {}", ev.session.id), - Data::SnapshotEvent(ev) => { - for session in ev.listing { - println!("{:?} ({}) is already here", session.name, session.id); - } - - // Here, a new task is spawned so the main event loop can - // continue running immediately instead of waiting for a reply - // from the server. - // - // We only need to do this because we want to log the result of - // the nick command. Otherwise, we could've just called - // tx.send() synchronously and ignored the returned Future. - let conn_tx_clone = snapshot.conn_tx.clone(); - tokio::spawn(async move { - // Awaiting the future returned by the send command lets you - // (type-safely) access the server's reply. - let reply = conn_tx_clone - .send(Nick { - name: NICK.to_string(), - }) - .await; - match reply { - Ok(reply) => println!("Set nick to {:?}", reply.to), - Err(err) => println!("Failed to set nick: {err}"), - }; - }); - } - Data::BounceEvent(_) => { - println!("Received bounce event, stopping"); - return Err(()); - } - Data::DisconnectEvent(_) => { - println!("Received disconnect event, stopping"); - return Err(()); - } - Data::JoinEvent(event) => println!("{:?} ({}) joined", event.0.name, event.0.id), - Data::PartEvent(event) => println!("{:?} ({}) left", event.0.name, event.0.id), - Data::NickEvent(event) => println!( - "{:?} ({}) is now known as {:?}", - event.from, event.id, event.to - ), - Data::SendEvent(event) => { - println!("Message {} was just sent", event.0.id.0); - - let content = event.0.content.trim(); - let mut reply = None; - - if content == "!ping" || content == format!("!ping @{NICK}") { - reply = Some("Pong!".to_string()); - } else if content == format!("!help @{NICK}") { - reply = Some(HELP.to_string()); - } else if content == format!("!uptime @{NICK}") { - if let Some(joined) = snapshot.state.joined() { - let delta = Timestamp::now() - joined.since; - reply = Some(format!( - "/me has been up for {}", - botrulez::format_duration(delta) - )); - } - } else if content == "!test" { - reply = Some("Test successful!".to_string()); - } else if content == format!("!kill @{NICK}") { - println!( - "I was killed by {:?} ({})", - event.0.sender.name, event.0.sender.id - ); - // Awaiting the server reply in the main loop to ensure the - // message is sent before we exit the loop. Otherwise, there - // would be a race between sending the message and closing - // the connection as the send function can return before the - // message has actually been sent. - let _ = snapshot - .conn_tx - .send(Send { - content: "/me dies".to_string(), - parent: Some(event.0.id), - }) - .await; - return Err(()); - } - - if let Some(reply) = reply { - // If you are not interested in the result, you can just - // throw away the future returned by the send function. - println!("Sending reply..."); - snapshot.conn_tx.send_only(Send { - content: reply, - parent: Some(event.0.id), - }); - println!("Reply sent!"); - } - } - _ => {} - } - - Ok(()) -} - -#[tokio::main] -async fn main() { - // https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837 - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); - - let (tx, mut rx) = mpsc::unbounded_channel(); - - let _instance = ServerConfig::default() - .room("test") - .username(Some("TestBot")) - .build(move |e| { - let _ = tx.send(e); - }); - - while let Some(event) = rx.recv().await { - if let Event::Packet(_config, packet, snapshot) = event { - if on_packet(packet, snapshot).await.is_err() { - break; - } - } - } -} diff --git a/examples/testbot_instances.rs b/examples/testbot_instances.rs deleted file mode 100644 index 0fb612f..0000000 --- a/examples/testbot_instances.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Similar to the `testbot_manual` example, but using [`Instance`] to connect -//! to the room (and to reconnect). - -use euphoxide::api::packet::ParsedPacket; -use euphoxide::api::{Data, Nick, Send}; -use euphoxide::bot::botrulez; -use euphoxide::bot::instance::{ConnSnapshot, Event, ServerConfig}; -use euphoxide::bot::instances::Instances; -use jiff::Timestamp; -use tokio::sync::mpsc; - -const NICK: &str = "TestBot"; -const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide"; - -async fn on_packet(packet: ParsedPacket, snapshot: ConnSnapshot) -> Result<(), ()> { - let data = match packet.content { - Ok(data) => data, - Err(err) => { - println!("Error for {}: {err}", packet.r#type); - return Err(()); - } - }; - - match data { - Data::HelloEvent(ev) => println!("Connected with id {}", ev.session.id), - Data::SnapshotEvent(ev) => { - for session in ev.listing { - println!("{:?} ({}) is already here", session.name, session.id); - } - - // Here, a new task is spawned so the main event loop can - // continue running immediately instead of waiting for a reply - // from the server. - // - // We only need to do this because we want to log the result of - // the nick command. Otherwise, we could've just called - // tx.send() synchronously and ignored the returned Future. - let conn_tx_clone = snapshot.conn_tx.clone(); - tokio::spawn(async move { - // Awaiting the future returned by the send command lets you - // (type-safely) access the server's reply. - let reply = conn_tx_clone - .send(Nick { - name: NICK.to_string(), - }) - .await; - match reply { - Ok(reply) => println!("Set nick to {:?}", reply.to), - Err(err) => println!("Failed to set nick: {err}"), - }; - }); - } - Data::BounceEvent(_) => { - println!("Received bounce event, stopping"); - return Err(()); - } - Data::DisconnectEvent(_) => { - println!("Received disconnect event, stopping"); - return Err(()); - } - Data::JoinEvent(event) => println!("{:?} ({}) joined", event.0.name, event.0.id), - Data::PartEvent(event) => println!("{:?} ({}) left", event.0.name, event.0.id), - Data::NickEvent(event) => println!( - "{:?} ({}) is now known as {:?}", - event.from, event.id, event.to - ), - Data::SendEvent(event) => { - println!("Message {} was just sent", event.0.id.0); - - let content = event.0.content.trim(); - let mut reply = None; - - if content == "!ping" || content == format!("!ping @{NICK}") { - reply = Some("Pong!".to_string()); - } else if content == format!("!help @{NICK}") { - reply = Some(HELP.to_string()); - } else if content == format!("!uptime @{NICK}") { - if let Some(joined) = snapshot.state.joined() { - let delta = Timestamp::now() - joined.since; - reply = Some(format!( - "/me has been up for {}", - botrulez::format_duration(delta) - )); - } - } else if content == "!test" { - reply = Some("Test successful!".to_string()); - } else if content == format!("!kill @{NICK}") { - println!( - "I was killed by {:?} ({})", - event.0.sender.name, event.0.sender.id - ); - // Awaiting the server reply in the main loop to ensure the - // message is sent before we exit the loop. Otherwise, there - // would be a race between sending the message and closing - // the connection as the send function can return before the - // message has actually been sent. - let _ = snapshot - .conn_tx - .send(Send { - content: "/me dies".to_string(), - parent: Some(event.0.id), - }) - .await; - return Err(()); - } - - if let Some(reply) = reply { - // If you are not interested in the result, you can just - // throw away the future returned by the send function. - println!("Sending reply..."); - snapshot.conn_tx.send_only(Send { - content: reply, - parent: Some(event.0.id), - }); - println!("Reply sent!"); - } - } - _ => {} - } - - Ok(()) -} - -#[tokio::main] -async fn main() { - // https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837 - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); - - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut instances = Instances::new(ServerConfig::default()); - - for room in ["test", "test2", "testing"] { - let tx_clone = tx.clone(); - let instance = instances - .server_config() - .clone() - .room(room) - .username(Some("TestBot")) - .build(move |e| { - let _ = tx_clone.send(e); - }); - instances.add(instance); - } - - while let Some(event) = rx.recv().await { - instances.purge(); - if instances.is_empty() { - break; - } - - if let Event::Packet(_config, packet, snapshot) = event { - if on_packet(packet, snapshot).await.is_err() { - break; - } - } - } -} diff --git a/examples/testbot_manual.rs b/examples/testbot_manual.rs deleted file mode 100644 index da21db0..0000000 --- a/examples/testbot_manual.rs +++ /dev/null @@ -1,142 +0,0 @@ -//! A small bot that doesn't use the `bot` submodule. Meant to show how the main -//! parts of the API fit together. - -use std::error::Error; -use std::time::Duration; - -use euphoxide::api::packet::ParsedPacket; -use euphoxide::api::{Data, Nick, Send}; -use euphoxide::bot::botrulez; -use euphoxide::conn::{Conn, ConnTx, State}; -use jiff::Timestamp; - -const TIMEOUT: Duration = Duration::from_secs(10); -const DOMAIN: &str = "euphoria.leet.nu"; -const ROOM: &str = "test"; -const NICK: &str = "TestBot"; -const HELP: &str = "I'm an example bot for https://github.com/Garmelon/euphoxide"; - -async fn on_packet(packet: ParsedPacket, conn_tx: &ConnTx, state: &State) -> Result<(), ()> { - let data = match packet.content { - Ok(data) => data, - Err(err) => { - println!("Error for {}: {err}", packet.r#type); - return Err(()); - } - }; - - match data { - Data::HelloEvent(event) => println!("Connected with id {}", event.session.id), - Data::SnapshotEvent(event) => { - for session in event.listing { - println!("{:?} ({}) is already here", session.name, session.id); - } - - // Here, a new task is spawned so the main event loop can - // continue running immediately instead of waiting for a reply - // from the server. - // - // We only need to do this because we want to log the result of - // the nick command. Otherwise, we could've just called - // tx.send() synchronously and ignored the returned Future. - let conn_tx_clone = conn_tx.clone(); - tokio::spawn(async move { - // Awaiting the future returned by the send command lets you - // (type-safely) access the server's reply. - let reply = conn_tx_clone - .send(Nick { - name: NICK.to_string(), - }) - .await; - match reply { - Ok(reply) => println!("Set nick to {:?}", reply.to), - Err(err) => println!("Failed to set nick: {err}"), - }; - }); - } - Data::BounceEvent(_) => { - println!("Received bounce event, stopping"); - return Err(()); - } - Data::DisconnectEvent(_) => { - println!("Received disconnect event, stopping"); - return Err(()); - } - Data::JoinEvent(event) => println!("{:?} ({}) joined", event.0.name, event.0.id), - Data::PartEvent(event) => println!("{:?} ({}) left", event.0.name, event.0.id), - Data::NickEvent(event) => println!( - "{:?} ({}) is now known as {:?}", - event.from, event.id, event.to - ), - Data::SendEvent(event) => { - println!("Message {} was just sent", event.0.id.0); - - let content = event.0.content.trim(); - let mut reply = None; - - if content == "!ping" || content == format!("!ping @{NICK}") { - reply = Some("Pong!".to_string()); - } else if content == format!("!help @{NICK}") { - reply = Some(HELP.to_string()); - } else if content == format!("!uptime @{NICK}") { - if let Some(joined) = state.joined() { - let delta = Timestamp::now() - joined.since; - reply = Some(format!( - "/me has been up for {}", - botrulez::format_duration(delta) - )); - } - } else if content == "!test" { - reply = Some("Test successful!".to_string()); - } else if content == format!("!kill @{NICK}") { - println!( - "I was killed by {:?} ({})", - event.0.sender.name, event.0.sender.id - ); - // Awaiting the server reply in the main loop to ensure the - // message is sent before we exit the loop. Otherwise, there - // would be a race between sending the message and closing - // the connection as the send function can return before the - // message has actually been sent. - let _ = conn_tx - .send(Send { - content: "/me dies".to_string(), - parent: Some(event.0.id), - }) - .await; - return Err(()); - } - - if let Some(reply) = reply { - // If you are not interested in the result, you can just - // throw away the future returned by the send function. - println!("Sending reply..."); - conn_tx.send_only(Send { - content: reply, - parent: Some(event.0.id), - }); - println!("Reply sent!"); - } - } - _ => {} - } - - Ok(()) -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - // https://github.com/snapview/tokio-tungstenite/issues/353#issuecomment-2455247837 - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); - - let (mut conn, _) = Conn::connect(DOMAIN, ROOM, false, None, TIMEOUT).await?; - - while let Ok(packet) = conn.recv().await { - if on_packet(packet, conn.tx(), conn.state()).await.is_err() { - break; - } - } - Ok(()) -} diff --git a/src/bot.rs b/src/bot.rs deleted file mode 100644 index 5076211..0000000 --- a/src/bot.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Building blocks for bots. - -pub mod botrulez; -pub mod command; -pub mod commands; -pub mod instance; -pub mod instances; diff --git a/src/bot/botrulez.rs b/src/bot/botrulez.rs deleted file mode 100644 index 6dd5adb..0000000 --- a/src/bot/botrulez.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! The main [botrulez](https://github.com/jedevc/botrulez) commands. -pub mod full_help; -pub mod ping; -pub mod short_help; -pub mod uptime; - -pub use self::full_help::{FullHelp, HasDescriptions}; -pub use self::ping::Ping; -pub use self::short_help::ShortHelp; -pub use self::uptime::{format_duration, format_relative_time, format_time, HasStartTime, Uptime}; diff --git a/src/bot/botrulez/full_help.rs b/src/bot/botrulez/full_help.rs deleted file mode 100644 index 20ffcc4..0000000 --- a/src/bot/botrulez/full_help.rs +++ /dev/null @@ -1,93 +0,0 @@ -use async_trait::async_trait; -use clap::Parser; - -use crate::api::Message; -use crate::bot::command::{ClapCommand, Command, Context}; -use crate::conn; - -pub struct FullHelp { - pub before: String, - pub after: String, -} - -pub trait HasDescriptions { - fn descriptions(&self, ctx: &Context) -> Vec; -} - -impl FullHelp { - pub fn new(before: S1, after: S2) -> Self { - Self { - before: before.to_string(), - after: after.to_string(), - } - } - - fn formulate_reply(&self, ctx: &Context, bot: &B) -> String { - let mut result = String::new(); - - if !self.before.is_empty() { - result.push_str(&self.before); - result.push('\n'); - } - - for description in bot.descriptions(ctx) { - result.push_str(&description); - result.push('\n'); - } - - if !self.after.is_empty() { - result.push_str(&self.after); - result.push('\n'); - } - - result - } -} - -#[async_trait] -impl Command for FullHelp -where - B: HasDescriptions + Send, - E: From, -{ - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - if arg.trim().is_empty() { - let reply = self.formulate_reply(ctx, bot); - ctx.reply(msg.id, reply).await?; - Ok(true) - } else { - Ok(false) - } - } -} - -/// Show full bot help. -#[derive(Parser)] -pub struct Args {} - -#[async_trait] -impl ClapCommand for FullHelp -where - B: HasDescriptions + Send, - E: From, -{ - type Args = Args; - - async fn execute( - &self, - _args: Self::Args, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - let reply = self.formulate_reply(ctx, bot); - ctx.reply(msg.id, reply).await?; - Ok(true) - } -} diff --git a/src/bot/botrulez/ping.rs b/src/bot/botrulez/ping.rs deleted file mode 100644 index c7cea39..0000000 --- a/src/bot/botrulez/ping.rs +++ /dev/null @@ -1,64 +0,0 @@ -use async_trait::async_trait; -use clap::Parser; - -use crate::api::Message; -use crate::bot::command::{ClapCommand, Command, Context}; -use crate::conn; - -pub struct Ping(pub String); - -impl Ping { - pub fn new(reply: S) -> Self { - Self(reply.to_string()) - } -} - -impl Default for Ping { - fn default() -> Self { - Self::new("Pong!") - } -} - -#[async_trait] -impl Command for Ping -where - E: From, -{ - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - _bot: &mut B, - ) -> Result { - if arg.trim().is_empty() { - ctx.reply(msg.id, &self.0).await?; - Ok(true) - } else { - Ok(false) - } - } -} - -/// Trigger a short reply. -#[derive(Parser)] -pub struct Args {} - -#[async_trait] -impl ClapCommand for Ping -where - E: From, -{ - type Args = Args; - - async fn execute( - &self, - _args: Self::Args, - msg: &Message, - ctx: &Context, - _bot: &mut B, - ) -> Result { - ctx.reply(msg.id, &self.0).await?; - Ok(true) - } -} diff --git a/src/bot/botrulez/short_help.rs b/src/bot/botrulez/short_help.rs deleted file mode 100644 index 1a359be..0000000 --- a/src/bot/botrulez/short_help.rs +++ /dev/null @@ -1,58 +0,0 @@ -use async_trait::async_trait; -use clap::Parser; - -use crate::api::Message; -use crate::bot::command::{ClapCommand, Command, Context}; -use crate::conn; - -pub struct ShortHelp(pub String); - -impl ShortHelp { - pub fn new(text: S) -> Self { - Self(text.to_string()) - } -} - -#[async_trait] -impl Command for ShortHelp -where - E: From, -{ - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - _bot: &mut B, - ) -> Result { - if arg.trim().is_empty() { - ctx.reply(msg.id, &self.0).await?; - Ok(true) - } else { - Ok(false) - } - } -} - -/// Show short bot help. -#[derive(Parser)] -pub struct Args {} - -#[async_trait] -impl ClapCommand for ShortHelp -where - E: From, -{ - type Args = Args; - - async fn execute( - &self, - _args: Self::Args, - msg: &Message, - ctx: &Context, - _bot: &mut B, - ) -> Result { - ctx.reply(msg.id, &self.0).await?; - Ok(true) - } -} diff --git a/src/bot/botrulez/uptime.rs b/src/bot/botrulez/uptime.rs deleted file mode 100644 index d8b1d0d..0000000 --- a/src/bot/botrulez/uptime.rs +++ /dev/null @@ -1,133 +0,0 @@ -use async_trait::async_trait; -use clap::Parser; -use jiff::{Span, Timestamp, Unit}; - -use crate::api::Message; -use crate::bot::command::{ClapCommand, Command, Context}; -use crate::conn; - -pub fn format_time(t: Timestamp) -> String { - t.strftime("%Y-%m-%d %H:%M:%S UTC").to_string() -} - -pub fn format_relative_time(d: Span) -> String { - if d.is_positive() { - format!("in {}", format_duration(d.abs())) - } else { - format!("{} ago", format_duration(d.abs())) - } -} - -pub fn format_duration(d: Span) -> String { - let total = d.abs().total(Unit::Second).unwrap() as i64; - let secs = total % 60; - let mins = (total / 60) % 60; - let hours = (total / 60 / 60) % 24; - let days = total / 60 / 60 / 24; - - let mut segments = vec![]; - if days > 0 { - segments.push(format!("{days}d")); - } - if hours > 0 { - segments.push(format!("{hours}h")); - } - if mins > 0 { - segments.push(format!("{mins}m")); - } - if secs > 0 { - segments.push(format!("{secs}s")); - } - if segments.is_empty() { - segments.push("0s".to_string()); - } - - let segments = segments.join(" "); - if d.is_positive() { - segments - } else { - format!("-{segments}") - } -} - -pub struct Uptime; - -pub trait HasStartTime { - fn start_time(&self) -> Timestamp; -} - -impl Uptime { - fn formulate_reply(&self, ctx: &Context, bot: &B, connected: bool) -> String { - let start = bot.start_time(); - let now = Timestamp::now(); - - let mut reply = format!( - "/me has been up since {} ({})", - format_time(start), - format_relative_time(start - now), - ); - - if connected { - let since = ctx.joined.since; - reply.push_str(&format!( - ", connected since {} ({})", - format_time(since), - format_relative_time(since - now), - )); - } - - reply - } -} - -#[async_trait] -impl Command for Uptime -where - B: HasStartTime + Send, - E: From, -{ - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - if arg.trim().is_empty() { - let reply = self.formulate_reply(ctx, bot, false); - ctx.reply(msg.id, reply).await?; - Ok(true) - } else { - Ok(false) - } - } -} - -/// Show how long the bot has been online. -#[derive(Parser)] -pub struct Args { - /// Show how long the bot has been connected without interruption. - #[arg(long, short)] - pub connected: bool, -} - -#[async_trait] -impl ClapCommand for Uptime -where - B: HasStartTime + Send, - E: From, -{ - type Args = Args; - - async fn execute( - &self, - args: Self::Args, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - let reply = self.formulate_reply(ctx, bot, args.connected); - ctx.reply(msg.id, reply).await?; - Ok(true) - } -} diff --git a/src/bot/command.rs b/src/bot/command.rs deleted file mode 100644 index 226adb0..0000000 --- a/src/bot/command.rs +++ /dev/null @@ -1,64 +0,0 @@ -mod bang; -mod clap; -mod hidden; -mod prefixed; - -use std::future::Future; - -use async_trait::async_trait; - -use crate::api::{self, Message, MessageId}; -use crate::conn::{self, ConnTx, Joined}; - -pub use self::bang::*; -pub use self::clap::*; -pub use self::hidden::*; -pub use self::prefixed::*; - -use super::instance::InstanceConfig; - -pub struct Context { - pub config: InstanceConfig, - pub conn_tx: ConnTx, - pub joined: Joined, -} - -impl Context { - pub fn send(&self, content: S) -> impl Future> { - let cmd = api::Send { - content: content.to_string(), - parent: None, - }; - let reply = self.conn_tx.send(cmd); - async move { reply.await.map(|r| r.0) } - } - - pub fn reply( - &self, - parent: MessageId, - content: S, - ) -> impl Future> { - let cmd = api::Send { - content: content.to_string(), - parent: Some(parent), - }; - let reply = self.conn_tx.send(cmd); - async move { reply.await.map(|r| r.0) } - } -} - -#[allow(unused_variables)] -#[async_trait] -pub trait Command { - fn description(&self, ctx: &Context) -> Option { - None - } - - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result; -} diff --git a/src/bot/command/bang.rs b/src/bot/command/bang.rs deleted file mode 100644 index a55d99d..0000000 --- a/src/bot/command/bang.rs +++ /dev/null @@ -1,235 +0,0 @@ -use async_trait::async_trait; - -use crate::api::Message; -use crate::nick; - -use super::{Command, Context}; - -// TODO Don't ignore leading whitespace? -// I'm not entirely happy with how commands handle whitespace, and on euphoria, -// prefixing commands with whitespace is traditionally used to not trigger them. - -/// Parse leading whitespace followed by an prefix-initiated command. -/// -/// Returns the command name and the remaining text with one leading whitespace -/// removed. The remaining text may be the empty string. -pub fn parse_prefix_initiated<'a>(text: &'a str, prefix: &str) -> Option<(&'a str, &'a str)> { - let text = text.trim_start(); - let text = text.strip_prefix(prefix)?; - let (name, rest) = text.split_once(char::is_whitespace).unwrap_or((text, "")); - if name.is_empty() { - return None; - } - Some((name, rest)) -} - -pub struct Global { - prefix: String, - name: String, - inner: C, -} - -impl Global { - pub fn new(name: S, inner: C) -> Self { - Self { - prefix: "!".to_string(), - name: name.to_string(), - inner, - } - } - - pub fn prefix(mut self, prefix: S) -> Self { - self.prefix = prefix.to_string(); - self - } -} - -#[async_trait] -impl Command for Global -where - B: Send, - C: Command + Send + Sync, -{ - fn description(&self, ctx: &Context) -> Option { - let inner = self.inner.description(ctx)?; - Some(format!("{}{} - {inner}", self.prefix, self.name)) - } - - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - // TODO Replace with let-else - let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) { - Some(parsed) => parsed, - None => return Ok(false), - }; - - if name != self.name { - return Ok(false); - } - - self.inner.execute(rest, msg, ctx, bot).await - } -} - -pub struct General { - prefix: String, - name: String, - inner: C, -} - -impl General { - pub fn new(name: S, inner: C) -> Self { - Self { - prefix: "!".to_string(), - name: name.to_string(), - inner, - } - } - - pub fn prefix(mut self, prefix: S) -> Self { - self.prefix = prefix.to_string(); - self - } -} - -#[async_trait] -impl Command for General -where - B: Send, - C: Command + Send + Sync, -{ - fn description(&self, ctx: &Context) -> Option { - let inner = self.inner.description(ctx)?; - Some(format!("{}{} - {inner}", self.prefix, self.name)) - } - - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - // TODO Replace with let-else - let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) { - Some(parsed) => parsed, - None => return Ok(false), - }; - - if name != self.name { - return Ok(false); - } - - if parse_prefix_initiated(rest, "@").is_some() { - // The command looks like a specific command. If we treated it like - // a general command match, we would interpret other bots' specific - // commands as general commands. - return Ok(false); - } - - self.inner.execute(rest, msg, ctx, bot).await - } -} - -pub struct Specific { - prefix: String, - name: String, - inner: C, -} - -impl Specific { - pub fn new(name: S, inner: C) -> Self { - Self { - prefix: "!".to_string(), - name: name.to_string(), - inner, - } - } - - pub fn prefix(mut self, prefix: S) -> Self { - self.prefix = prefix.to_string(); - self - } -} - -#[async_trait] -impl Command for Specific -where - B: Send, - C: Command + Send + Sync, -{ - fn description(&self, ctx: &Context) -> Option { - let inner = self.inner.description(ctx)?; - let nick = nick::mention(&ctx.joined.session.name); - Some(format!("{}{} @{nick} - {inner}", self.prefix, self.name)) - } - - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - // TODO Replace with let-else - let (name, rest) = match parse_prefix_initiated(arg, &self.prefix) { - Some(parsed) => parsed, - None => return Ok(false), - }; - - if name != self.name { - return Ok(false); - } - - // TODO Replace with let-else - let (nick, rest) = match parse_prefix_initiated(rest, "@") { - Some(parsed) => parsed, - None => return Ok(false), - }; - - if nick::normalize(nick) != nick::normalize(&ctx.joined.session.name) { - return Ok(false); - } - - self.inner.execute(rest, msg, ctx, bot).await - } -} - -#[cfg(test)] -mod test { - use super::parse_prefix_initiated; - - #[test] - fn test_parse_prefixed() { - assert_eq!(parse_prefix_initiated("!foo", "!"), Some(("foo", ""))); - assert_eq!(parse_prefix_initiated(" !foo", "!"), Some(("foo", ""))); - assert_eq!( - parse_prefix_initiated("!foo ", "!"), - Some(("foo", " ")) - ); - assert_eq!( - parse_prefix_initiated(" !foo ", "!"), - Some(("foo", " ")) - ); - assert_eq!( - parse_prefix_initiated("!foo @bar", "!"), - Some(("foo", "@bar")) - ); - assert_eq!( - parse_prefix_initiated("!foo @bar", "!"), - Some(("foo", " @bar")) - ); - assert_eq!( - parse_prefix_initiated("!foo @bar ", "!"), - Some(("foo", "@bar ")) - ); - assert_eq!(parse_prefix_initiated("! foo @bar", "!"), None); - assert_eq!(parse_prefix_initiated("!", "!"), None); - assert_eq!(parse_prefix_initiated("?foo", "!"), None); - } -} diff --git a/src/bot/command/clap.rs b/src/bot/command/clap.rs deleted file mode 100644 index a22b49a..0000000 --- a/src/bot/command/clap.rs +++ /dev/null @@ -1,182 +0,0 @@ -use async_trait::async_trait; -use clap::{CommandFactory, Parser}; - -use crate::api::Message; -use crate::conn; - -use super::{Command, Context}; - -#[async_trait] -pub trait ClapCommand { - type Args; - - async fn execute( - &self, - args: Self::Args, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result; -} - -/// Parse bash-like quoted arguments separated by whitespace. -/// -/// Outside of quotes, the backslash either escapes the next character or forms -/// an escape sequence. \n is a newline, \r a carriage return and \t a tab. -/// TODO Escape sequences -/// -/// Special characters like the backslash and whitespace can also be quoted -/// using double quotes. Within double quotes, \" escapes a double quote and \\ -/// escapes a backslash. Other occurrences of \ have no special meaning. -fn parse_quoted_args(text: &str) -> Result, &'static str> { - let mut args = vec![]; - let mut arg = String::new(); - let mut arg_exists = false; - - let mut quoted = false; - let mut escaped = false; - for c in text.chars() { - if quoted { - match c { - '\\' if escaped => { - arg.push('\\'); - escaped = false; - } - '"' if escaped => { - arg.push('"'); - escaped = false; - } - c if escaped => { - arg.push('\\'); - arg.push(c); - escaped = false; - } - '\\' => escaped = true, - '"' => quoted = false, - c => arg.push(c), - } - } else { - match c { - c if escaped => { - arg.push(c); - arg_exists = true; - escaped = false; - } - c if c.is_whitespace() => { - if arg_exists { - args.push(arg); - arg = String::new(); - arg_exists = false; - } - } - '\\' => escaped = true, - '"' => { - quoted = true; - arg_exists = true; - } - c => { - arg.push(c); - arg_exists = true; - } - } - } - } - - if quoted { - return Err("Unclosed trailing quote"); - } - if escaped { - return Err("Unfinished trailing escape"); - } - - if arg_exists { - args.push(arg); - } - - Ok(args) -} - -pub struct Clap(pub C); - -#[async_trait] -impl Command for Clap -where - B: Send, - E: From, - C: ClapCommand + Send + Sync, - C::Args: Parser + Send, -{ - fn description(&self, _ctx: &Context) -> Option { - C::Args::command().get_about().map(|s| format!("{s}")) - } - - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - let mut args = match parse_quoted_args(arg) { - Ok(args) => args, - Err(err) => { - ctx.reply(msg.id, err).await?; - return Ok(true); - } - }; - - // Hacky, but it should work fine in most cases - let usage = msg.content.strip_suffix(arg).unwrap_or("").trim(); - args.insert(0, usage.to_string()); - - let args = match C::Args::try_parse_from(args) { - Ok(args) => args, - Err(err) => { - ctx.reply(msg.id, format!("{}", err.render())).await?; - return Ok(true); - } - }; - - self.0.execute(args, msg, ctx, bot).await - } -} - -#[cfg(test)] -mod test { - use super::parse_quoted_args; - - fn assert_quoted(raw: &str, parsed: &[&str]) { - let parsed = parsed.iter().map(|s| s.to_string()).collect(); - assert_eq!(parse_quoted_args(raw), Ok(parsed)) - } - - #[test] - fn test_parse_quoted_args() { - assert_quoted("foo bar baz", &["foo", "bar", "baz"]); - assert_quoted(" foo bar baz ", &["foo", "bar", "baz"]); - assert_quoted("foo\\ ba\"r ba\"z", &["foo bar baz"]); - assert_quoted( - "It's a nice day, isn't it?", - &["It's", "a", "nice", "day,", "isn't", "it?"], - ); - - // Trailing whitespace - assert_quoted("a ", &["a"]); - assert_quoted("a\\ ", &["a "]); - assert_quoted("a\\ ", &["a "]); - - // Zero-length arguments - assert_quoted("a \"\" b \"\"", &["a", "", "b", ""]); - assert_quoted("a \"\" b \"\" ", &["a", "", "b", ""]); - - // Backslashes in quotes - assert_quoted("\"a \\b \\\" \\\\\"", &["a \\b \" \\"]); - - // Unclosed quotes and unfinished escapes - assert!(parse_quoted_args("foo 'bar \"baz").is_err()); - assert!(parse_quoted_args("foo \"bar baz").is_err()); - assert!(parse_quoted_args("foo \"bar 'baz").is_err()); - assert!(parse_quoted_args("foo \\").is_err()); - assert!(parse_quoted_args("foo 'bar\\").is_err()); - } -} diff --git a/src/bot/command/hidden.rs b/src/bot/command/hidden.rs deleted file mode 100644 index 0aad60d..0000000 --- a/src/bot/command/hidden.rs +++ /dev/null @@ -1,29 +0,0 @@ -use async_trait::async_trait; - -use crate::api::Message; - -use super::{Command, Context}; - -pub struct Hidden(pub C); - -#[async_trait] -impl Command for Hidden -where - B: Send, - C: Command + Send + Sync, -{ - fn description(&self, _ctx: &Context) -> Option { - // Default implementation, repeated here for emphasis. - None - } - - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - self.0.execute(arg, msg, ctx, bot).await - } -} diff --git a/src/bot/command/prefixed.rs b/src/bot/command/prefixed.rs deleted file mode 100644 index f572732..0000000 --- a/src/bot/command/prefixed.rs +++ /dev/null @@ -1,45 +0,0 @@ -use async_trait::async_trait; - -use crate::api::Message; - -use super::{Command, Context}; - -pub struct Prefixed { - prefix: String, - inner: C, -} - -impl Prefixed { - pub fn new(prefix: S, inner: C) -> Self { - Self { - prefix: prefix.to_string(), - inner, - } - } -} - -#[async_trait] -impl Command for Prefixed -where - B: Send, - C: Command + Send + Sync, -{ - fn description(&self, ctx: &Context) -> Option { - let inner = self.inner.description(ctx)?; - Some(format!("{} - {inner}", self.prefix)) - } - - async fn execute( - &self, - arg: &str, - msg: &Message, - ctx: &Context, - bot: &mut B, - ) -> Result { - if let Some(rest) = arg.trim_start().strip_prefix(&self.prefix) { - self.inner.execute(rest, msg, ctx, bot).await - } else { - Ok(false) - } - } -} diff --git a/src/bot/commands.rs b/src/bot/commands.rs deleted file mode 100644 index a37cb5d..0000000 --- a/src/bot/commands.rs +++ /dev/null @@ -1,93 +0,0 @@ -use crate::api::packet::ParsedPacket; -use crate::api::{Data, SendEvent}; -use crate::conn; - -use super::command::{Command, Context}; -use super::instance::{ConnSnapshot, InstanceConfig}; - -pub struct Commands { - commands: Vec + Send + Sync>>, - fallthrough: bool, -} - -impl Commands { - pub fn new() -> Self { - Self { - commands: vec![], - fallthrough: false, - } - } - - /// Whether further commands should be executed after a command returns - /// `true`. - /// - /// If disabled, commands are run until the first command that returns - /// `true`. If enabled, all commands are run irrespective of their return - /// values. - pub fn fallthrough(&self) -> bool { - self.fallthrough - } - - /// Set whether fallthrough is active. - /// - /// See [`Self::fallthrough`] for more details. - pub fn set_fallthrough(&mut self, active: bool) { - self.fallthrough = active; - } - - pub fn add(&mut self, command: C) - where - C: Command + Send + Sync + 'static, - { - self.commands.push(Box::new(command)); - } - - pub fn descriptions(&self, ctx: &Context) -> Vec { - self.commands - .iter() - .filter_map(|c| c.description(ctx)) - .collect::>() - } - - /// Returns `true` if one or more commands returned `true`, `false` - /// otherwise. - pub async fn handle_packet( - &self, - config: &InstanceConfig, - packet: &ParsedPacket, - snapshot: &ConnSnapshot, - bot: &mut B, - ) -> Result { - let msg = match &packet.content { - Ok(Data::SendEvent(SendEvent(msg))) => msg, - _ => return Ok(false), - }; - - let joined = match &snapshot.state { - conn::State::Joining(_) => return Ok(false), - conn::State::Joined(joined) => joined.clone(), - }; - - let ctx = Context { - config: config.clone(), - conn_tx: snapshot.conn_tx.clone(), - joined, - }; - - let mut handled = false; - for command in &self.commands { - handled = handled || command.execute(&msg.content, msg, &ctx, bot).await?; - if !self.fallthrough && handled { - break; - } - } - - Ok(handled) - } -} - -impl Default for Commands { - fn default() -> Self { - Self::new() - } -} diff --git a/src/bot/instance.rs b/src/bot/instance.rs deleted file mode 100644 index 0d00d4d..0000000 --- a/src/bot/instance.rs +++ /dev/null @@ -1,534 +0,0 @@ -//! A single instance of a bot in a single room. -//! -//! See [`Instance`] for more details. - -use std::convert::Infallible; -use std::fmt; -use std::str::FromStr; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use cookie::{Cookie, CookieJar}; -use tokio::select; -use tokio::sync::{mpsc, oneshot}; -use tokio_tungstenite::tungstenite; -use tokio_tungstenite::tungstenite::http::{HeaderValue, StatusCode}; - -use crate::api::packet::ParsedPacket; -use crate::api::{Auth, AuthOption, Data, Nick}; -use crate::conn::{self, Conn, ConnTx, State}; - -macro_rules! ilog { - ( $conf:expr, $target:expr, $($arg:tt)+ ) => { - ::log::log!( - target: &format!("euphoxide::live::{}", $conf.name), - $target, - $($arg)+ - ); - }; -} - -macro_rules! idebug { - ( $conf:expr, $($arg:tt)+ ) => { - ilog!($conf, ::log::Level::Debug, $($arg)+); - }; -} - -macro_rules! iinfo { - ( $conf:expr, $($arg:tt)+ ) => { - ilog!($conf, ::log::Level::Info, $($arg)+); - }; -} - -macro_rules! iwarn { - ( $conf:expr, $($arg:tt)+ ) => { - ilog!($conf, ::log::Level::Warn, $($arg)+); - }; -} - -/// Settings that are usually shared between all instances connecting to a -/// specific server. -#[derive(Clone)] -pub struct ServerConfig { - /// How long to wait for the server until an operation is considered timed - /// out. - /// - /// This timeout applies to waiting for reply packets to command packets - /// sent by the client, as well as operations like connecting or closing a - /// connection. - pub timeout: Duration, - /// How long to wait until reconnecting after an unsuccessful attempt to - /// connect. - pub reconnect_delay: Duration, - /// Domain name, to be used with [`Conn::connect`]. - pub domain: String, - /// Cookies to use when connecting. They are updated with the server's reply - /// after successful connection attempts. - pub cookies: Arc>, -} - -impl ServerConfig { - pub fn timeout(mut self, timeout: Duration) -> Self { - self.timeout = timeout; - self - } - - pub fn reconnect_delay(mut self, reconnect_delay: Duration) -> Self { - self.reconnect_delay = reconnect_delay; - self - } - - pub fn domain(mut self, domain: S) -> Self { - self.domain = domain.to_string(); - self - } - - pub fn cookies(mut self, cookies: Arc>) -> Self { - self.cookies = cookies; - self - } - - pub fn room(self, room: S) -> InstanceConfig { - InstanceConfig::new(self, room) - } -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - timeout: Duration::from_secs(30), - reconnect_delay: Duration::from_secs(30), - domain: "euphoria.leet.nu".to_string(), - cookies: Arc::new(Mutex::new(CookieJar::new())), - } - } -} - -struct Hidden; - -impl fmt::Debug for Hidden { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "") - } -} - -impl fmt::Debug for ServerConfig { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ServerConfig") - .field("timeout", &self.timeout) - .field("reconnect_delay", &self.reconnect_delay) - .field("domain", &self.domain) - .field("cookies", &Hidden) - .finish() - } -} - -/// Settings that are usually specific to a single instance. -#[derive(Debug, Clone)] -pub struct InstanceConfig { - pub server: ServerConfig, - /// Unique name of this instance. - pub name: String, - /// Room name, to be used with [`Conn::connect`]. - pub room: String, - /// Whether the instance should connect as human or bot. - pub human: bool, - /// Username to set upon connecting. - pub username: Option, - /// Whether to set the username even if the server reports that the session - /// already has a username set. - pub force_username: bool, - /// Password to use if room requires authentication. - pub password: Option, -} - -impl InstanceConfig { - pub fn new(server: ServerConfig, room: S) -> Self { - Self { - server, - name: room.to_string(), - room: room.to_string(), - human: false, - username: None, - force_username: false, - password: None, - } - } - - pub fn name(mut self, name: S) -> Self { - self.name = name.to_string(); - self - } - - pub fn human(mut self, human: bool) -> Self { - self.human = human; - self - } - - pub fn username(mut self, username: Option) -> Self { - self.username = username.map(|s| s.to_string()); - self - } - - pub fn force_username(mut self, force_username: bool) -> Self { - self.force_username = force_username; - self - } - - pub fn password(mut self, password: Option) -> Self { - self.password = password.map(|s| s.to_string()); - self - } - - /// Create a new instance using this config. - /// - /// See [`Instance::new`] for more details. - pub fn build(self, on_event: F) -> Instance - where - F: Fn(Event) + Send + Sync + 'static, - { - Instance::new(self, on_event) - } -} - -/// Snapshot of a [`Conn`]'s state immediately after receiving a packet. -#[derive(Debug, Clone)] -pub struct ConnSnapshot { - pub conn_tx: ConnTx, - pub state: State, -} - -impl ConnSnapshot { - fn from_conn(conn: &Conn) -> Self { - Self { - conn_tx: conn.tx().clone(), - state: conn.state().clone(), - } - } -} - -// Most of the time, the largest variant (`Packet`) is sent. The size of this -// enum is not critical anyways since it's not constructed that often. -#[allow(clippy::large_enum_variant)] -/// An event emitted by an [`Instance`]. -/// -/// Events are emitted by a single instance following this schema, written in -/// pseudo-regex syntax: -/// ```text -/// (Connecting (Connected Packet*)? Disconnected)* Stopped -/// ``` -/// -/// In particular, this means that every [`Self::Connecting`] is always followed -/// by exactly one [`Self::Disconnected`], and that [`Self::Stopped`] is always -/// the last event and is always sent exactly once per instance. -#[derive(Debug)] -pub enum Event { - Connecting(InstanceConfig), - Connected(InstanceConfig, ConnSnapshot), - Packet(InstanceConfig, ParsedPacket, ConnSnapshot), - Disconnected(InstanceConfig), - Stopped(InstanceConfig), -} - -impl Event { - pub fn config(&self) -> &InstanceConfig { - match self { - Self::Connecting(config) => config, - Self::Connected(config, _) => config, - Self::Packet(config, _, _) => config, - Self::Disconnected(config) => config, - Self::Stopped(config) => config, - } - } -} - -enum Request { - GetConnTx(oneshot::Sender), - Stop, -} - -/// An error that occurred inside an [`Instance`] while it was running. -enum RunError { - StoppedManually, - InstanceDropped, - CouldNotConnect(conn::Error), - Conn(conn::Error), -} - -/// A single instance of a bot in a single room. -/// -/// The instance automatically connects to its room once it is created, and it -/// reconnects when it loses connection. If the room requires authentication and -/// a password is given, the instance automatically authenticates. If a nick is -/// given, the instance sets its nick upon joining the room. -/// -/// An instance has a unique name used for logging and identifying the instance. -/// The room name can be used as the instance name if there is never more than -/// one instance per room. -/// -/// An instance can be created using [`Instance::new`] or using -/// [`InstanceConfig::build`]. -/// -/// An instance can be stopped using [`Instance::stop`] or by dropping it. In -/// either case, the last event the instance sends will be an -/// [`Event::Stopped`]. If it is not stopped using one of these two ways, it -/// will continue to run and reconnect indefinitely. -#[derive(Debug, Clone)] -pub struct Instance { - config: InstanceConfig, - request_tx: mpsc::UnboundedSender, - // In theory, request_tx should be sufficient as canary, but I'm not sure - // exactly how to check it during the reconnect timeout. - _canary_tx: mpsc::UnboundedSender, -} - -impl Instance { - // Previously, the event callback was asynchronous and would return a result. It - // was called in-line to calling Conn::recv. The idea was that the instance - // would stop if the event handler returned Err. This was, however, not even - // implemented correctly and the instance would just reconnect. - // - // The new event handler is synchronous. This way, it becomes harder to - // accidentally block Conn::recv, for example by waiting for a channel with - // limited capacity. If async code must be executed upon receiving a command, - // the user can start a task from inside the handler. - // - // The new event handler does not return anything. This makes the code nicer. In - // the use cases I'm thinking of, it should not be a problem: If the event - // handler encounters errors, there's usually other ways to tell the same. Make - // the event handler ignore the errors and stop the instance in that other way. - - /// Create a new instance based on an [`InstanceConfig`]. - /// - /// The `on_event` parameter is called whenever the instance wants to emit - /// an [`Event`]. It must not block for long. See [`Event`] for more details - /// on the events and the order in which they are emitted. - /// - /// [`InstanceConfig::build`] can be used in place of this function. - pub fn new(config: InstanceConfig, on_event: F) -> Self - where - F: Fn(Event) + Send + Sync + 'static, - { - idebug!(config, "Created with config {config:?}"); - - let (request_tx, request_rx) = mpsc::unbounded_channel(); - let (canary_tx, canary_rx) = mpsc::unbounded_channel(); - - tokio::spawn(Self::run::( - config.clone(), - on_event, - request_rx, - canary_rx, - )); - - Self { - config, - request_tx, - _canary_tx: canary_tx, - } - } - - pub fn config(&self) -> &InstanceConfig { - &self.config - } - - /// Retrieve the instance's current connection. - /// - /// Returns `None` if the instance is currently not connected, or has - /// stopped running. - pub async fn conn_tx(&self) -> Option { - let (tx, rx) = oneshot::channel(); - let _ = self.request_tx.send(Request::GetConnTx(tx)); - rx.await.ok() - } - - /// Stop the instance. - /// - /// For more info on stopping instances, see [`Instance`]. - pub fn stop(&self) { - let _ = self.request_tx.send(Request::Stop); - } - - /// Whether this instance is stopped. - /// - /// For more info on stopping instances, see [`Instance`]. - pub fn stopped(&self) -> bool { - self.request_tx.is_closed() - } - - async fn run( - config: InstanceConfig, - on_event: F, - request_rx: mpsc::UnboundedReceiver, - mut canary_rx: mpsc::UnboundedReceiver, - ) { - select! { - _ = Self::stay_connected(&config, &on_event, request_rx) => (), - _ = canary_rx.recv() => { idebug!(config, "Instance dropped"); }, - } - on_event(Event::Stopped(config)) - } - - async fn stay_connected( - config: &InstanceConfig, - on_event: &F, - mut request_rx: mpsc::UnboundedReceiver, - ) { - loop { - idebug!(config, "Connecting..."); - - on_event(Event::Connecting(config.clone())); - let result = Self::run_once::(config, on_event, &mut request_rx).await; - on_event(Event::Disconnected(config.clone())); - - let connected = match result { - Ok(()) => { - idebug!(config, "Connection closed normally"); - true - } - Err(RunError::StoppedManually) => { - idebug!(config, "Instance stopped manually"); - break; - } - Err(RunError::InstanceDropped) => { - idebug!(config, "Instance dropped"); - break; - } - Err(RunError::CouldNotConnect(conn::Error::Tungstenite( - tungstenite::Error::Http(response), - ))) if response.status() == StatusCode::NOT_FOUND => { - iwarn!(config, "Failed to connect: room does not exist"); - break; - } - Err(RunError::CouldNotConnect(err)) => { - iwarn!(config, "Failed to connect: {err}"); - false - } - Err(RunError::Conn(err)) => { - iwarn!(config, "An error occurred: {err}"); - true - } - }; - - if !connected { - let s = config.server.reconnect_delay.as_secs(); - idebug!(config, "Waiting {s} seconds before reconnecting"); - tokio::time::sleep(config.server.reconnect_delay).await; - } - } - } - - fn get_cookies(config: &InstanceConfig) -> HeaderValue { - let guard = config.server.cookies.lock().unwrap(); - let cookies = guard - .iter() - .map(|c| format!("{}", c.stripped())) - .collect::>() - .join("; "); - drop(guard); - cookies.try_into().unwrap() - } - - fn set_cookies(config: &InstanceConfig, cookies: Vec) { - idebug!(config, "Updating cookies"); - let mut guard = config.server.cookies.lock().unwrap(); - - for cookie in cookies { - if let Ok(cookie) = cookie.to_str() { - if let Ok(cookie) = Cookie::from_str(cookie) { - guard.add(cookie); - } - } - } - } - - async fn run_once( - config: &InstanceConfig, - on_event: &F, - request_rx: &mut mpsc::UnboundedReceiver, - ) -> Result<(), RunError> { - let (mut conn, cookies) = Conn::connect( - &config.server.domain, - &config.room, - config.human, - Some(Self::get_cookies(config)), - config.server.timeout, - ) - .await - .map_err(RunError::CouldNotConnect)?; - - Self::set_cookies(config, cookies); - on_event(Event::Connected( - config.clone(), - ConnSnapshot::from_conn(&conn), - )); - - let conn_tx = conn.tx().clone(); - select! { - r = Self::receive::(config, &mut conn, on_event) => r, - r = Self::handle_requests(request_rx, &conn_tx) => Err(r), - } - } - - async fn receive( - config: &InstanceConfig, - conn: &mut Conn, - on_event: &F, - ) -> Result<(), RunError> { - loop { - let packet = conn.recv().await.map_err(RunError::Conn)?; - let snapshot = ConnSnapshot::from_conn(conn); - - match &packet.content { - Ok(Data::SnapshotEvent(snapshot)) => { - if let Some(username) = &config.username { - if config.force_username || snapshot.nick.is_none() { - idebug!(config, "Setting nick to username {username}"); - let name = username.to_string(); - conn.tx().send_only(Nick { name }); - } else if let Some(nick) = &snapshot.nick { - idebug!(config, "Not setting nick, already set to {nick}"); - } - } - } - Ok(Data::BounceEvent(_)) => { - if let Some(password) = &config.password { - idebug!(config, "Authenticating with password"); - let cmd = Auth { - r#type: AuthOption::Passcode, - passcode: Some(password.to_string()), - }; - conn.tx().send_only(cmd); - } else { - iwarn!(config, "Auth required but no password configured"); - } - } - Ok(Data::DisconnectEvent(ev)) => { - if ev.reason == "authentication changed" { - iinfo!(config, "Disconnected because {}", ev.reason); - } else { - iwarn!(config, "Disconnected because {}", ev.reason); - } - } - _ => {} - } - - on_event(Event::Packet(config.clone(), packet, snapshot)); - } - } - - async fn handle_requests( - request_rx: &mut mpsc::UnboundedReceiver, - conn_tx: &ConnTx, - ) -> RunError { - while let Some(request) = request_rx.recv().await { - match request { - Request::GetConnTx(tx) => { - let _ = tx.send(conn_tx.clone()); - } - Request::Stop => return RunError::StoppedManually, - } - } - RunError::InstanceDropped - } -} diff --git a/src/bot/instances.rs b/src/bot/instances.rs deleted file mode 100644 index 6607a70..0000000 --- a/src/bot/instances.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! A convenient way to keep a [`ServerConfig`] and some [`Instance`]s. - -use std::collections::HashMap; - -use super::instance::{self, Instance, ServerConfig}; - -/// A convenient way to keep a [`ServerConfig`] and some [`Instance`]s. -pub struct Instances { - server_config: ServerConfig, - instances: HashMap, -} - -impl Instances { - pub fn new(server_config: ServerConfig) -> Self { - Self { - server_config, - instances: HashMap::new(), - } - } - - pub fn server_config(&self) -> &ServerConfig { - &self.server_config - } - - pub fn instances(&self) -> impl Iterator { - self.instances.values() - } - - /// Check if an event comes from an instance whose name is known. - /// - /// Assuming every instance has a unique name, events from unknown instances - /// should be discarded. This helps prevent "ghost instances" that were - /// stopped but haven't yet disconnected properly from influencing your - /// bot's state. - /// - /// The user is responsible for ensuring that instances' names are unique. - pub fn is_from_known_instance(&self, event: &instance::Event) -> bool { - self.instances.contains_key(&event.config().name) - } - - pub fn is_empty(&self) -> bool { - self.instances.is_empty() - } - - /// Get an instance by its name. - pub fn get(&self, name: &str) -> Option<&Instance> { - self.instances.get(name) - } - - /// Add a new instance. - /// - /// If an instance with the same name exists already, it will be replaced by - /// the new instance. - pub fn add(&mut self, instance: Instance) { - self.instances - .insert(instance.config().name.clone(), instance); - } - - /// Remove an instance by its name. - pub fn remove(&mut self, name: &str) -> Option { - self.instances.remove(name) - } - - /// Remove all stopped instances. - /// - /// This function should be called regularly. - pub fn purge(&mut self) { - self.instances.retain(|_, i| !i.stopped()); - } -} diff --git a/src/lib.rs b/src/lib.rs index 380b321..8b13789 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1 @@ -pub mod api; -#[cfg(feature = "bot")] -pub mod bot; -pub mod conn; -mod emoji; -pub mod nick; -mod replies; -pub use emoji::Emoji;