diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ab04ef..7a89179 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": "crate", - "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": "module", + "rust-analyzer.imports.group.enable": true, + "evenBetterToml.formatter.columnWidth": 100, } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dfca90..524e877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ Procedure when bumping the version number: ## Unreleased +## v0.6.1 - 2025-02-23 + +### Changed + +- Updated set of emoji names + +### Fixed + +- Nick hue hashing algorithm in some edge cases + +## v0.6.0 - 2025-02-21 + ### Added - `api::Time::from_timestamp` diff --git a/Cargo.toml b/Cargo.toml index 8487a9c..cf65579 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,34 +1,48 @@ -[workspace] -resolver = "2" -members = ["euphoxide", "euphoxide-bot", "euphoxide-client"] - -[workspace.package] -version = "0.5.1" +[package] +name = "euphoxide" +version = "0.6.1" edition = "2021" -rust-version = "1.82" -[workspace.dependencies] -async-trait = "0.1.83" -caseless = "0.2.1" -clap = { version = "4.5.23", default-features = false, features = ["std"] } -cookie = "0.18.1" -futures-util = "0.3.31" -jiff = { version = "0.1.15", default-features = false, features = ["std"] } -log = "0.4.22" -serde = "1.0.215" -serde_json = "1.0.133" -tokio = "1.42.0" -tokio-tungstenite = "0.24.0" +[features] +bot = ["dep:async-trait", "dep:clap", "dep:cookie"] + +[dependencies] +async-trait = { version = "0.1.86", optional = true } +caseless = "0.2.2" +cookie = { version = "0.18.1", optional = true } +futures-util = { version = "0.3.31", default-features = false, features = ["sink"] } +jiff = { version = "0.2.1", features = ["serde"] } +log = "0.4.25" +serde = { version = "1.0.218", features = ["derive"] } +serde_json = "1.0.139" +tokio = { version = "1.43.0", features = ["time", "sync", "macros", "rt"] } +tokio-stream = "0.1.17" +tokio-tungstenite = { version = "0.26.2", features = ["rustls-tls-native-roots"] } unicode-normalization = "0.1.24" -# For examples -anyhow = "1.0.94" -rustls = "0.23.19" -# In this workspace -euphoxide = { path = "./euphoxide" } -euphoxide-bot = { path = "./euphoxide-bot" } -euphoxide-client = { path = "./euphoxide-client" } -[workspace.lints] +[dependencies.clap] +version = "4.5.30" +optional = true +default-features = false +features = ["std", "derive", "deprecated"] + +[dev-dependencies] # For example bot +rustls = "0.23.23" +tokio = { version = "1.43.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 } # Lint groups rust.deprecated_safe = "warn" diff --git a/euphoxide-bot/Cargo.toml b/euphoxide-bot/Cargo.toml deleted file mode 100644 index 4ae61bf..0000000 --- a/euphoxide-bot/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "euphoxide-bot" -version = { workspace = true } -edition = { workspace = true } -rust-version = { workspace = true } - -[features] -clap = ["dep:clap"] - -[dependencies] -async-trait = { workspace = true } -clap = { workspace = true, optional = true, features = ["derive"] } -cookie = { workspace = true } -euphoxide = { workspace = true } -euphoxide-client = { workspace = true } -jiff = { workspace = true } -log = { workspace = true } -tokio = { workspace = true, features = ["rt"] } -tokio-tungstenite = { workspace = true } - -[dev-dependencies] -anyhow = { workspace = true } -rustls = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] } - -[lints] -workspace = true diff --git a/euphoxide-bot/examples/examplebot.rs b/euphoxide-bot/examples/examplebot.rs deleted file mode 100644 index 4f6d02c..0000000 --- a/euphoxide-bot/examples/examplebot.rs +++ /dev/null @@ -1,93 +0,0 @@ -use std::time::Duration; - -use euphoxide::api::Message; -use euphoxide_bot::{ - basic::FromHandler, - botrulez::{FullHelp, Ping, ShortHelp}, - clap::FromClapHandler, - CommandExt, Commands, Context, Propagate, -}; -use euphoxide_client::MultiClient; -use log::error; -use tokio::sync::mpsc; - -async fn pyramid(_arg: &str, msg: &Message, ctx: &Context) -> euphoxide::Result { - let mut parent = msg.id; - - for _ in 0..3 { - let first = ctx.reply(parent, "brick").await?; - ctx.reply_only(parent, "brick").await?; - parent = first.await?.0.id; - tokio::time::sleep(Duration::from_secs(1)).await; - } - - ctx.reply_only(parent, "brick").await?; - Ok(Propagate::No) -} - -#[derive(clap::Parser)] -struct AddArgs { - lhs: i64, - rhs: i64, -} - -async fn add(args: AddArgs, msg: &Message, ctx: &Context) -> euphoxide::Result { - let result = args.lhs + args.rhs; - - ctx.reply_only(msg.id, format!("{} + {} = {result}", args.lhs, args.rhs)) - .await?; - - Ok(Propagate::No) -} - -#[tokio::main] -async fn main() { - let (event_tx, mut event_rx) = mpsc::channel(10); - - let commands = Commands::::new() - .then(Ping::default().general("ping").with_info_hidden()) - .then(Ping::default().specific("ping").with_info_hidden()) - .then( - ShortHelp::new("/me demonstrates how to use euphoxide") - .general("help") - .with_info_hidden(), - ) - .then( - FullHelp::new() - .with_after("Created using euphoxide.") - .specific("help") - .with_info_hidden(), - ) - .then( - FromHandler::new(pyramid) - .with_info() - .with_description("build a pyramid") - .general("pyramid"), - ) - .then( - FromClapHandler::new(add) - .clap() - .with_info() - .with_description("add two numbers") - .general("add"), - ) - .build(); - - let clients = MultiClient::new(event_tx); - - clients - .client_builder("test") - .with_username("examplebot") - .build_and_add() - .await; - - while let Some(event) = event_rx.recv().await { - let commands = commands.clone(); - let clients = clients.clone(); - tokio::task::spawn(async move { - if let Err(err) = commands.handle_event(clients, event).await { - error!("Oops: {err}") - } - }); - } -} diff --git a/euphoxide-bot/src/command.rs b/euphoxide-bot/src/command.rs deleted file mode 100644 index c2a04dd..0000000 --- a/euphoxide-bot/src/command.rs +++ /dev/null @@ -1,257 +0,0 @@ -pub mod bang; -pub mod basic; -pub mod botrulez; -#[cfg(feature = "clap")] -pub mod clap; - -use std::{future::Future, sync::Arc}; - -use async_trait::async_trait; -use euphoxide::{ - api::{self, Data, Message, MessageId, SendEvent, SendReply}, - client::{ - conn::ClientConnHandle, - state::{Joined, State}, - }, -}; -use euphoxide_client::{Client, MultiClient, MultiClientEvent}; - -use self::{ - bang::{General, Global, Specific}, - basic::{Prefixed, WithInfo}, -}; - -#[non_exhaustive] -pub struct Context { - pub commands: Arc>, - pub clients: MultiClient, - pub client: Client, - pub conn: ClientConnHandle, - pub joined: Joined, -} - -impl Context { - pub async fn send( - &self, - content: impl ToString, - ) -> euphoxide::Result>> { - self.conn - .send(api::Send { - content: content.to_string(), - parent: None, - }) - .await - } - - pub async fn send_only(&self, content: impl ToString) -> euphoxide::Result<()> { - let _ignore = self.send(content).await?; - Ok(()) - } - - pub async fn reply( - &self, - parent: MessageId, - content: impl ToString, - ) -> euphoxide::Result>> { - self.conn - .send(api::Send { - content: content.to_string(), - parent: Some(parent), - }) - .await - } - - pub async fn reply_only( - &self, - parent: MessageId, - content: impl ToString, - ) -> euphoxide::Result<()> { - let _ignore = self.reply(parent, content).await?; - Ok(()) - } -} - -#[derive(Default)] -pub struct Info { - pub trigger: Option, - pub description: Option, -} - -impl Info { - pub fn new() -> Self { - Self::default() - } - - pub fn with_trigger(mut self, trigger: impl ToString) -> Self { - self.trigger = Some(trigger.to_string()); - self - } - - pub fn with_description(mut self, description: impl ToString) -> Self { - self.description = Some(description.to_string()); - self - } - - pub fn prepend_trigger(&mut self, trigger: impl ToString) { - // TODO Use get_or_instert_default when updating MSRV - let cur_trigger = self.trigger.get_or_insert_with(String::new); - if !cur_trigger.is_empty() { - cur_trigger.insert(0, ' '); - } - cur_trigger.insert_str(0, &trigger.to_string()); - } - - pub fn with_prepended_trigger(mut self, trigger: impl ToString) -> Self { - self.prepend_trigger(trigger); - self - } -} - -/// Whether a message should propagate to subsequent commands. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Propagate { - No, - Yes, -} - -#[allow(unused_variables)] -#[async_trait] -pub trait Command { - fn info(&self, ctx: &Context) -> Info { - Info::default() - } - - async fn execute(&self, arg: &str, msg: &Message, ctx: &Context) -> Result; -} - -pub trait CommandExt: Sized { - fn with_info(self) -> WithInfo { - WithInfo::new(self) - } - - fn with_info_hidden(self) -> WithInfo { - WithInfo::hidden(self) - } - - fn prefixed(self, prefix: impl ToString) -> Prefixed { - Prefixed::new(prefix, self) - } - - fn global(self, name: impl ToString) -> Global { - Global::new(name, self) - } - - fn general(self, name: impl ToString) -> General { - General::new(name, self) - } - - fn specific(self, name: impl ToString) -> Specific { - Specific::new(name, self) - } - - #[cfg(feature = "clap")] - fn clap(self) -> clap::Clap { - clap::Clap(self) - } - - fn add_to(self, commands: &mut Commands) - where - Self: Command + Send + Sync + 'static, - { - commands.add(self); - } -} - -// Sadly this doesn't work: `impl> CommandExt for C {}` -// It leaves E unconstrained. Instead, we just implement CommandExt for all -// types. This is fine since it'll crash and burn once we try to use the created -// commands as actual commands. It also follows the spirit of adding trait -// constraints only where they are necessary. -impl CommandExt for C {} - -pub struct Commands { - commands: Vec + Sync + Send>>, -} - -impl Commands { - pub fn new() -> Self { - Self { commands: vec![] } - } - - pub fn add(&mut self, command: impl Command + Sync + Send + 'static) { - self.commands.push(Box::new(command)); - } - - pub fn then(mut self, command: impl Command + Sync + Send + 'static) -> Self { - self.add(command); - self - } - - pub fn build(self) -> Arc { - Arc::new(self) - } - - pub fn infos(&self, ctx: &Context) -> Vec { - self.commands.iter().map(|c| c.info(ctx)).collect() - } - - pub async fn handle_message( - self: Arc, - clients: MultiClient, - client: Client, - conn: ClientConnHandle, - joined: Joined, - msg: &Message, - ) -> Result { - let ctx = Context { - commands: self.clone(), - clients, - client, - conn, - joined, - }; - - for command in &self.commands { - let propagate = command.execute(&msg.content, msg, &ctx).await?; - if propagate == Propagate::No { - return Ok(Propagate::No); - } - } - - Ok(Propagate::Yes) - } - - pub async fn handle_event( - self: Arc, - clients: MultiClient, - event: MultiClientEvent, - ) -> Result { - let MultiClientEvent::Packet { - client, - conn, - state, - packet, - } = event - else { - return Ok(Propagate::Yes); - }; - - let Ok(Data::SendEvent(SendEvent(msg))) = &packet.content else { - return Ok(Propagate::Yes); - }; - - let State::Joined(joined) = state else { - return Ok(Propagate::Yes); - }; - - self.handle_message(clients, client, conn, joined, msg) - .await - } -} - -// Has fewer restrictions on generic types than #[derive(Default)]. -impl Default for Commands { - fn default() -> Self { - Self::new() - } -} diff --git a/euphoxide-bot/src/command/basic.rs b/euphoxide-bot/src/command/basic.rs deleted file mode 100644 index c60f2cf..0000000 --- a/euphoxide-bot/src/command/basic.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Basic command wrappers. - -use std::future::Future; - -use async_trait::async_trait; -use euphoxide::api::Message; - -use super::{Command, Context, Info, Propagate}; - -/// Rewrite or hide command info. -pub struct WithInfo { - pub inner: C, - pub trigger: Option>, - pub description: Option>, -} - -impl WithInfo { - pub fn new(inner: C) -> Self { - Self { - inner, - trigger: None, - description: None, - } - } - - pub fn hidden(inner: C) -> Self { - Self::new(inner) - .with_trigger_hidden() - .with_description_hidden() - } - - pub fn with_trigger(mut self, trigger: impl ToString) -> Self { - self.trigger = Some(Some(trigger.to_string())); - self - } - - pub fn with_trigger_hidden(mut self) -> Self { - self.trigger = Some(None); - self - } - - pub fn with_description(mut self, description: impl ToString) -> Self { - self.description = Some(Some(description.to_string())); - self - } - - pub fn with_description_hidden(mut self) -> Self { - self.description = Some(None); - self - } -} - -#[async_trait] -impl Command for WithInfo -where - C: Command + Sync, -{ - fn info(&self, ctx: &Context) -> Info { - let info = self.inner.info(ctx); - Info { - trigger: self.trigger.clone().unwrap_or(info.trigger), - description: self.description.clone().unwrap_or(info.description), - } - } - - async fn execute(&self, arg: &str, msg: &Message, ctx: &Context) -> Result { - self.inner.execute(arg, msg, ctx).await - } -} - -pub struct Prefixed { - pub prefix: String, - pub 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 - C: Command + Sync, -{ - fn info(&self, ctx: &Context) -> Info { - self.inner.info(ctx).with_prepended_trigger(&self.prefix) - } - - async fn execute(&self, arg: &str, msg: &Message, ctx: &Context) -> Result { - if let Some(rest) = arg.trim_start().strip_prefix(&self.prefix) { - self.inner.execute(rest, msg, ctx).await - } else { - Ok(Propagate::Yes) - } - } -} - -// Black type magic, thanks a lot to https://github.com/kpreid and the -// async_fn_traits crate! - -// TODO Simplify all this once AsyncFn becomes stable - -pub trait HandlerFn<'a0, 'a1, 'a2, E>: - Fn(&'a0 str, &'a1 Message, &'a2 Context) -> Self::Future -where - E: 'a2, -{ - type Future: Future> + Send; -} - -impl<'a0, 'a1, 'a2, E, F, Fut> HandlerFn<'a0, 'a1, 'a2, E> for F -where - E: 'a2, - F: Fn(&'a0 str, &'a1 Message, &'a2 Context) -> Fut + ?Sized, - Fut: Future> + Send, -{ - type Future = Fut; -} - -pub struct FromHandler(pub F); - -impl FromHandler { - pub fn new(f: F) -> Self { - Self(f) - } -} - -#[async_trait] -impl Command for FromHandler -where - F: for<'a0, 'a1, 'a2> HandlerFn<'a0, 'a1, 'a2, E> + Sync, -{ - async fn execute(&self, arg: &str, msg: &Message, ctx: &Context) -> Result { - (self.0)(arg, msg, ctx).await - } -} diff --git a/euphoxide-bot/src/command/botrulez.rs b/euphoxide-bot/src/command/botrulez.rs deleted file mode 100644 index 94e7875..0000000 --- a/euphoxide-bot/src/command/botrulez.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! The main [botrulez](https://github.com/jedevc/botrulez) commands. - -mod full_help; -mod ping; -mod short_help; -mod uptime; - -pub use self::{full_help::*, ping::*, short_help::*, uptime::*}; diff --git a/euphoxide-bot/src/command/botrulez/full_help.rs b/euphoxide-bot/src/command/botrulez/full_help.rs deleted file mode 100644 index aee9ac4..0000000 --- a/euphoxide-bot/src/command/botrulez/full_help.rs +++ /dev/null @@ -1,98 +0,0 @@ -use async_trait::async_trait; -#[cfg(feature = "clap")] -use clap::Parser; -use euphoxide::api::Message; - -#[cfg(feature = "clap")] -use crate::command::clap::ClapCommand; -use crate::command::{Command, Context, Propagate}; - -#[derive(Default)] -pub struct FullHelp { - pub before: String, - pub after: String, -} - -impl FullHelp { - pub fn new() -> Self { - Self::default() - } - - pub fn with_before(mut self, before: impl ToString) -> Self { - self.before = before.to_string(); - self - } - - pub fn with_after(mut self, after: impl ToString) -> Self { - self.after = after.to_string(); - self - } - - fn formulate_reply(&self, ctx: &Context) -> String { - let mut result = String::new(); - - if !self.before.is_empty() { - result.push_str(&self.before); - result.push('\n'); - } - - for info in ctx.commands.infos(ctx) { - if let Some(trigger) = &info.trigger { - result.push_str(trigger); - if let Some(description) = &info.description { - result.push_str(" - "); - 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 - E: From, -{ - async fn execute(&self, arg: &str, msg: &Message, ctx: &Context) -> Result { - if arg.trim().is_empty() { - let reply = self.formulate_reply(ctx); - ctx.reply_only(msg.id, reply).await?; - Ok(Propagate::No) - } else { - Ok(Propagate::Yes) - } - } -} - -/// Show full bot help. -#[cfg(feature = "clap")] -#[derive(Parser)] -pub struct FullHelpArgs {} - -#[cfg(feature = "clap")] -#[async_trait] -impl ClapCommand for FullHelp -where - E: From, -{ - type Args = FullHelpArgs; - - async fn execute( - &self, - _args: Self::Args, - msg: &Message, - ctx: &Context, - ) -> Result { - let reply = self.formulate_reply(ctx); - ctx.reply_only(msg.id, reply).await?; - Ok(Propagate::No) - } -} diff --git a/euphoxide-bot/src/command/botrulez/ping.rs b/euphoxide-bot/src/command/botrulez/ping.rs deleted file mode 100644 index 6511565..0000000 --- a/euphoxide-bot/src/command/botrulez/ping.rs +++ /dev/null @@ -1,61 +0,0 @@ -use async_trait::async_trait; -#[cfg(feature = "clap")] -use clap::Parser; -use euphoxide::api::Message; - -#[cfg(feature = "clap")] -use crate::command::clap::ClapCommand; -use crate::command::{Command, Context, Propagate}; - -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) -> Result { - if arg.trim().is_empty() { - ctx.reply_only(msg.id, &self.0).await?; - Ok(Propagate::No) - } else { - Ok(Propagate::Yes) - } - } -} - -/// Trigger a short reply. -#[cfg(feature = "clap")] -#[derive(Parser)] -pub struct PingArgs {} - -#[cfg(feature = "clap")] -#[async_trait] -impl ClapCommand for Ping -where - E: From, -{ - type Args = PingArgs; - - async fn execute( - &self, - _args: Self::Args, - msg: &Message, - ctx: &Context, - ) -> Result { - ctx.reply_only(msg.id, &self.0).await?; - Ok(Propagate::No) - } -} diff --git a/euphoxide-bot/src/command/botrulez/short_help.rs b/euphoxide-bot/src/command/botrulez/short_help.rs deleted file mode 100644 index 8b1c9b8..0000000 --- a/euphoxide-bot/src/command/botrulez/short_help.rs +++ /dev/null @@ -1,55 +0,0 @@ -use async_trait::async_trait; -#[cfg(feature = "clap")] -use clap::Parser; -use euphoxide::api::Message; - -#[cfg(feature = "clap")] -use crate::command::clap::ClapCommand; -use crate::command::{Command, Context, Propagate}; - -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) -> Result { - if arg.trim().is_empty() { - ctx.reply_only(msg.id, &self.0).await?; - Ok(Propagate::No) - } else { - Ok(Propagate::Yes) - } - } -} - -/// Show short bot help. -#[cfg(feature = "clap")] -#[derive(Parser)] -pub struct ShortHelpArgs {} - -#[cfg(feature = "clap")] -#[async_trait] -impl ClapCommand for ShortHelp -where - E: From, -{ - type Args = ShortHelpArgs; - - async fn execute( - &self, - _args: Self::Args, - msg: &Message, - ctx: &Context, - ) -> Result { - ctx.reply_only(msg.id, &self.0).await?; - Ok(Propagate::No) - } -} diff --git a/euphoxide-bot/src/lib.rs b/euphoxide-bot/src/lib.rs deleted file mode 100644 index b76141f..0000000 --- a/euphoxide-bot/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod command; - -pub use self::command::*; diff --git a/euphoxide-client/Cargo.toml b/euphoxide-client/Cargo.toml deleted file mode 100644 index f476e1d..0000000 --- a/euphoxide-client/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "euphoxide-client" -version = { workspace = true } -edition = { workspace = true } -rust-version = { workspace = true } - -[dependencies] -cookie = { workspace = true } -euphoxide = { workspace = true } -jiff = { workspace = true } -log = { workspace = true } -tokio = { workspace = true, features = ["rt"] } -tokio-tungstenite = { workspace = true } - -[dev-dependencies] -anyhow = { workspace = true } -rustls = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] } - -[lints] -workspace = true diff --git a/euphoxide-client/examples/examplebot_multi.rs b/euphoxide-client/examples/examplebot_multi.rs deleted file mode 100644 index a382333..0000000 --- a/euphoxide-client/examples/examplebot_multi.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::time::Duration; - -use euphoxide::{ - api::{Data, Message, Nick, Send}, - client::conn::ClientConnHandle, -}; -use euphoxide_client::{MultiClient, MultiClientEvent}; -use tokio::sync::mpsc; - -async fn set_nick(conn: &ClientConnHandle) -> anyhow::Result<()> { - conn.send_only(Nick { - name: "examplebot".to_string(), - }) - .await?; - - Ok(()) -} - -async fn send_pong(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> { - conn.send_only(Send { - content: "Pong!".to_string(), - parent: Some(msg.id), - }) - .await?; - - Ok(()) -} - -async fn send_pyramid(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> { - let mut parent = msg.id; - - for _ in 0..3 { - let first = conn - .send(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - conn.send_only(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - parent = first.await?.0.id; - tokio::time::sleep(Duration::from_secs(1)).await; - } - - conn.send_only(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - Ok(()) -} - -async fn on_data(conn: ClientConnHandle, data: Data) { - let result = match data { - Data::SnapshotEvent(_) => set_nick(&conn).await, - Data::SendEvent(event) if event.0.content == "!ping" => send_pong(&conn, event.0).await, - Data::SendEvent(event) if event.0.content == "!pyramid" => { - send_pyramid(&conn, event.0).await - } - _ => Ok(()), - }; - - if let Err(err) = result { - println!("Error while responding: {err}"); - } -} - -async fn run() -> anyhow::Result<()> { - let (event_tx, mut event_rx) = mpsc::channel(10); - - // Don't drop the client or it will stop running. - let clients = MultiClient::new(event_tx); - - clients - .client_builder("test") - .with_username("examplebot") - .build_and_add() - .await; - - while let Some(event) = event_rx.recv().await { - if let MultiClientEvent::Packet { conn, packet, .. } = event { - let data = packet.into_data()?; - tokio::task::spawn(on_data(conn, data)); - } - } - - Ok(()) -} - -#[tokio::main] -async fn main() { - loop { - if let Err(err) = run().await { - println!("Error while running: {err}"); - } - } -} diff --git a/euphoxide-client/examples/examplebot_single.rs b/euphoxide-client/examples/examplebot_single.rs deleted file mode 100644 index cccb701..0000000 --- a/euphoxide-client/examples/examplebot_single.rs +++ /dev/null @@ -1,99 +0,0 @@ -use std::time::Duration; - -use euphoxide::{ - api::{Data, Message, Nick, Send}, - client::conn::ClientConnHandle, -}; -use euphoxide_client::{Client, ClientEvent}; -use tokio::sync::mpsc; - -async fn set_nick(conn: &ClientConnHandle) -> anyhow::Result<()> { - conn.send_only(Nick { - name: "examplebot".to_string(), - }) - .await?; - - Ok(()) -} - -async fn send_pong(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> { - conn.send_only(Send { - content: "Pong!".to_string(), - parent: Some(msg.id), - }) - .await?; - - Ok(()) -} - -async fn send_pyramid(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> { - let mut parent = msg.id; - - for _ in 0..3 { - let first = conn - .send(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - conn.send_only(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - parent = first.await?.0.id; - tokio::time::sleep(Duration::from_secs(1)).await; - } - - conn.send_only(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - Ok(()) -} - -async fn on_data(conn: ClientConnHandle, data: Data) { - let result = match data { - Data::SnapshotEvent(_) => set_nick(&conn).await, - Data::SendEvent(event) if event.0.content == "!ping" => send_pong(&conn, event.0).await, - Data::SendEvent(event) if event.0.content == "!pyramid" => { - send_pyramid(&conn, event.0).await - } - _ => Ok(()), - }; - - if let Err(err) = result { - println!("Error while responding: {err}"); - } -} - -async fn run() -> anyhow::Result<()> { - let (event_tx, mut event_rx) = mpsc::channel(10); - - // Don't drop the client or it will stop running. - let _client = Client::builder("test") - .with_username("examplebot") - .build(0, event_tx); - - while let Some(event) = event_rx.recv().await { - if let ClientEvent::Packet { conn, packet, .. } = event { - let data = packet.into_data()?; - tokio::task::spawn(on_data(conn, data)); - } - } - - Ok(()) -} - -#[tokio::main] -async fn main() { - loop { - if let Err(err) = run().await { - println!("Error while running: {err}"); - } - } -} diff --git a/euphoxide-client/src/builder.rs b/euphoxide-client/src/builder.rs deleted file mode 100644 index 5146f83..0000000 --- a/euphoxide-client/src/builder.rs +++ /dev/null @@ -1,40 +0,0 @@ -use crate::ClientConfig; - -pub trait ClientBuilderBase<'a> { - type Base; -} - -pub struct ClientBuilder<'a, B: ClientBuilderBase<'a>> { - pub(crate) base: B::Base, - pub(crate) config: ClientConfig, -} - -impl<'a, B: ClientBuilderBase<'a>> ClientBuilder<'a, B> { - pub fn config(&self) -> &ClientConfig { - &self.config - } - - pub fn config_mut(&mut self) -> &mut ClientConfig { - &mut self.config - } - - pub fn with_human(mut self, human: bool) -> Self { - self.config.human = human; - self - } - - pub fn with_username(mut self, username: impl ToString) -> Self { - self.config.username = Some(username.to_string()); - self - } - - pub fn with_force_username(mut self, force_username: bool) -> Self { - self.config.force_username = force_username; - self - } - - pub fn with_password(mut self, password: impl ToString) -> Self { - self.config.password = Some(password.to_string()); - self - } -} diff --git a/euphoxide-client/src/config.rs b/euphoxide-client/src/config.rs deleted file mode 100644 index ca1baea..0000000 --- a/euphoxide-client/src/config.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; - -use cookie::CookieJar; -use euphoxide::client::conn::ClientConnConfig; - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct ServerConfig { - pub client: ClientConnConfig, - pub cookies: Arc>, - pub join_attempts: usize, - pub reconnect_delay: Duration, - pub cmd_channel_bufsize: usize, -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - client: ClientConnConfig::default(), - cookies: Arc::new(Mutex::new(CookieJar::new())), - join_attempts: 5, - reconnect_delay: Duration::from_secs(30), - cmd_channel_bufsize: 1, - } - } -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct ClientConfig { - pub server: ServerConfig, - pub room: String, - pub human: bool, - pub username: Option, - pub force_username: bool, - pub password: Option, -} - -impl ClientConfig { - pub fn new(server: ServerConfig, room: String) -> Self { - Self { - server, - room, - human: false, - username: None, - force_username: false, - password: None, - } - } -} - -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct MultiClientConfig { - pub server: ServerConfig, - pub cmd_channel_bufsize: usize, - pub event_channel_bufsize: usize, -} - -impl Default for MultiClientConfig { - fn default() -> Self { - Self { - server: ServerConfig::default(), - cmd_channel_bufsize: 1, - event_channel_bufsize: 10, - } - } -} diff --git a/euphoxide-client/src/lib.rs b/euphoxide-client/src/lib.rs deleted file mode 100644 index 375d6cc..0000000 --- a/euphoxide-client/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -mod builder; -mod config; -mod multi; -mod single; - -pub use self::{builder::*, config::*, multi::*, single::*}; diff --git a/euphoxide-client/src/multi.rs b/euphoxide-client/src/multi.rs deleted file mode 100644 index dbdbc6c..0000000 --- a/euphoxide-client/src/multi.rs +++ /dev/null @@ -1,244 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use euphoxide::{ - api::ParsedPacket, - client::{conn::ClientConnHandle, state::State}, -}; -use jiff::Timestamp; -use tokio::{ - select, - sync::{mpsc, oneshot}, -}; - -use crate::{ - Client, ClientBuilder, ClientBuilderBase, ClientConfig, ClientEvent, MultiClientConfig, -}; - -#[derive(Debug)] -pub enum MultiClientEvent { - Started { - client: Client, - }, - Connecting { - client: Client, - }, - Connected { - client: Client, - conn: ClientConnHandle, - state: State, - }, - Joined { - client: Client, - conn: ClientConnHandle, - state: State, - }, - Packet { - client: Client, - conn: ClientConnHandle, - state: State, - packet: ParsedPacket, - }, - Disconnected { - client: Client, - }, - Stopped { - client: Client, - }, -} - -impl MultiClientEvent { - fn from_client_event(client: Client, event: ClientEvent) -> Self { - match event { - ClientEvent::Started { id: _ } => Self::Started { client }, - ClientEvent::Connecting { id: _ } => Self::Connecting { client }, - ClientEvent::Connected { id: _, conn, state } => Self::Connected { - client, - conn, - state, - }, - ClientEvent::Joined { id: _, conn, state } => Self::Joined { - client, - conn, - state, - }, - ClientEvent::Packet { - id: _, - conn, - state, - packet, - } => Self::Packet { - client, - conn, - state, - packet, - }, - ClientEvent::Disconnected { id: _ } => Self::Disconnected { client }, - ClientEvent::Stopped { id: _ } => Self::Stopped { client }, - } - } - - pub fn client(&self) -> &Client { - match self { - Self::Started { client } => client, - Self::Connecting { client, .. } => client, - Self::Connected { client, .. } => client, - Self::Joined { client, .. } => client, - Self::Packet { client, .. } => client, - Self::Disconnected { client } => client, - Self::Stopped { client } => client, - } - } -} - -#[allow(clippy::large_enum_variant)] -enum Command { - GetClients(oneshot::Sender>), - AddClient(ClientConfig, oneshot::Sender), -} - -struct MultiClientTask { - next_id: usize, - clients: HashMap, - - cmd_rx: mpsc::Receiver, - event_rx: mpsc::Receiver, - event_tx: mpsc::Sender, - out_tx: mpsc::Sender, -} - -impl MultiClientTask { - fn purge_clients(&mut self) { - self.clients.retain(|_, v| !v.stopped()); - } - - async fn on_event(&self, event: ClientEvent) { - if let Some(client) = self.clients.get(&event.id()) { - let event = MultiClientEvent::from_client_event(client.clone(), event); - let _ = self.out_tx.send(event).await; - } - } - - async fn on_cmd(&mut self, cmd: Command) { - match cmd { - Command::GetClients(tx) => { - self.purge_clients(); // Not necessary for correctness - let _ = tx.send(self.clients.values().cloned().collect()); - } - Command::AddClient(config, tx) => { - let id = self.next_id; - assert!(!self.clients.contains_key(&id)); - self.next_id += 1; - - let client = Client::new(id, config, self.event_tx.clone()); - self.clients.insert(id, client.clone()); - - let _ = tx.send(client); - } - } - } - - async fn run(mut self) { - loop { - // Prevent potential memory leak - self.purge_clients(); - - let received = select! { - r = self.event_rx.recv() => Ok(r), - r = self.cmd_rx.recv() => Err(r), - }; - - match received { - Ok(None) => break, - Ok(Some(event)) => self.on_event(event).await, - Err(None) => break, - Err(Some(cmd)) => self.on_cmd(cmd).await, - } - } - } -} - -#[derive(Clone)] -pub struct MultiClient { - config: Arc, - cmd_tx: mpsc::Sender, - start_time: Timestamp, -} - -impl MultiClient { - pub fn new(event_tx: mpsc::Sender) -> Self { - Self::new_with_config(MultiClientConfig::default(), event_tx) - } - - pub fn new_with_config( - config: MultiClientConfig, - event_tx: mpsc::Sender, - ) -> Self { - let start_time = Timestamp::now(); - - let config = Arc::new(config); - let out_tx = event_tx; - - let (cmd_tx, cmd_rx) = mpsc::channel(config.cmd_channel_bufsize); - let (event_tx, event_rx) = mpsc::channel(config.event_channel_bufsize); - - let task = MultiClientTask { - next_id: 0, - clients: HashMap::new(), - cmd_rx, - event_rx, - event_tx, - out_tx, - }; - - tokio::task::spawn(task.run()); - - Self { - config, - cmd_tx, - start_time, - } - } - - pub fn config(&self) -> &MultiClientConfig { - &self.config - } - - pub fn start_time(&self) -> Timestamp { - self.start_time - } - - pub async fn get_clients(&self) -> Vec { - let (tx, rx) = oneshot::channel(); - let _ = self.cmd_tx.send(Command::GetClients(tx)).await; - rx.await.expect("task should still be running") - } - - pub async fn add_client(&self, config: ClientConfig) -> Client { - let (tx, rx) = oneshot::channel(); - let _ = self.cmd_tx.send(Command::AddClient(config, tx)).await; - rx.await.expect("task should still be running") - } -} - -///////////// -// Builder // -///////////// - -impl<'a> ClientBuilderBase<'a> for MultiClient { - type Base = &'a Self; -} - -impl MultiClient { - pub fn client_builder(&self, room: impl ToString) -> ClientBuilder<'_, Self> { - ClientBuilder { - base: self, - config: ClientConfig::new(self.config.server.clone(), room.to_string()), - } - } -} - -impl ClientBuilder<'_, MultiClient> { - pub async fn build_and_add(self) -> Client { - self.base.add_client(self.config).await - } -} diff --git a/euphoxide-client/src/single.rs b/euphoxide-client/src/single.rs deleted file mode 100644 index c2a3cc4..0000000 --- a/euphoxide-client/src/single.rs +++ /dev/null @@ -1,435 +0,0 @@ -use std::{fmt, result, str::FromStr, sync::Arc}; - -use cookie::Cookie; -use euphoxide::{ - api::{Auth, AuthOption, BounceEvent, Data, Nick, ParsedPacket}, - client::{ - conn::{ClientConn, ClientConnHandle}, - state::State, - }, -}; -use jiff::Timestamp; -use log::warn; -use tokio::{ - select, - sync::{mpsc, oneshot}, -}; -use tokio_tungstenite::tungstenite::{ - self, - http::{HeaderValue, StatusCode}, -}; - -use crate::{ClientBuilder, ClientBuilderBase, ClientConfig, ServerConfig}; - -enum Error { - Stopped, - NoReferences, - AuthRequired, - InvalidPassword, - OutOfJoinAttempts, - Euphoxide(euphoxide::Error), -} - -impl Error { - fn is_fatal(&self) -> bool { - match self { - Self::Stopped => true, - Self::NoReferences => true, - Self::AuthRequired => true, - Self::InvalidPassword => true, - Self::OutOfJoinAttempts => true, - Self::Euphoxide(euphoxide::Error::Tungstenite(tungstenite::Error::Http(response))) => { - response.status() == StatusCode::NOT_FOUND - } - _ => false, - } - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Stopped => write!(f, "the instance was stopped manually"), - Self::NoReferences => write!(f, "all references to the instance were dropped"), - Self::AuthRequired => write!(f, "authentication required but no credentials found"), - Self::InvalidPassword => write!(f, "authentication required but password is invalid"), - Self::OutOfJoinAttempts => write!(f, "failed to join within attempt limit"), - Self::Euphoxide(error) => write!(f, "{error}"), - } - } -} - -impl From for Error { - fn from(value: euphoxide::Error) -> Self { - Self::Euphoxide(value) - } -} - -type Result = result::Result; - -enum Command { - GetConn(oneshot::Sender), - Stop, -} - -#[derive(Debug)] -pub enum ClientEvent { - Started { - id: usize, - }, - Connecting { - id: usize, - }, - Connected { - id: usize, - conn: ClientConnHandle, - state: State, - }, - Joined { - id: usize, - conn: ClientConnHandle, - state: State, - }, - Packet { - id: usize, - conn: ClientConnHandle, - state: State, - packet: ParsedPacket, - }, - Disconnected { - id: usize, - }, - Stopped { - id: usize, - }, -} - -impl ClientEvent { - pub fn id(&self) -> usize { - match self { - Self::Started { id } => *id, - Self::Connecting { id } => *id, - Self::Connected { id, .. } => *id, - Self::Joined { id, .. } => *id, - Self::Packet { id, .. } => *id, - Self::Disconnected { id } => *id, - Self::Stopped { id } => *id, - } - } -} - -struct ClientTask { - id: usize, - config: Arc, - - cmd_rx: mpsc::Receiver, - event_tx: mpsc::Sender, - - attempts: usize, - never_joined: bool, -} - -impl ClientTask { - fn get_cookies(&self) -> Option { - self.config - .server - .cookies - .lock() - .unwrap() - .iter() - .map(|c| c.stripped().to_string()) - .collect::>() - .join("; ") - .try_into() - .ok() - } - - fn set_cookies(&mut self, cookies: &[HeaderValue]) { - let mut guard = self.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 connect(&mut self) -> Result { - let (conn, cookies) = ClientConn::connect_with_config( - &self.config.room, - self.get_cookies(), - &self.config.server.client, - ) - .await?; - - self.set_cookies(&cookies); - - Ok(conn) - } - - async fn on_joined(&mut self, conn: &ClientConn) { - self.never_joined = false; - - let _ = self - .event_tx - .send(ClientEvent::Joined { - id: self.id, - conn: conn.handle(), - state: conn.state().clone(), - }) - .await; - } - - async fn on_packet(&mut self, conn: &mut ClientConn, packet: ParsedPacket) -> Result<()> { - let _ = self - .event_tx - .send(ClientEvent::Packet { - id: self.id, - conn: conn.handle(), - state: conn.state().clone(), - packet: packet.clone(), - }) - .await; - - match packet.into_data()? { - // Attempting to authenticate - Data::BounceEvent(BounceEvent { - auth_options: Some(auth_options), - .. - }) if auth_options.contains(&AuthOption::Passcode) => { - if let Some(password) = &self.config.password { - conn.send(Auth { - r#type: AuthOption::Passcode, - passcode: Some(password.clone()), - }) - .await?; - } else { - return Err(Error::AuthRequired); - } - } - - // Auth attempt failed :( - Data::AuthReply(ev) if !ev.success => return Err(Error::InvalidPassword), - - // Just joined - Data::SnapshotEvent(ev) => { - if let Some(username) = &self.config.username { - if ev.nick.is_none() || self.config.force_username { - conn.send(Nick { - name: username.clone(), - }) - .await?; - } - } - - // Maybe we should only count this as joining if we successfully - // updated the nick instead of just sending a Nick command? And - // maybe we should ensure that we're in the State::Joined state? - // Both of these would probably complicate the code a lot. On - // the other hand, InstanceEvent::Joined::state would contain - // the actual nick after joining, which feels like the right - // thing to do™. Probably not worth the increase in code - // complexity though. - - self.on_joined(conn).await; - } - - _ => {} - } - - Ok(()) - } - - async fn on_cmd(&mut self, conn: &ClientConn, cmd: Command) -> Result<()> { - match cmd { - Command::GetConn(sender) => { - let _ = sender.send(conn.handle()); - Ok(()) - } - Command::Stop => Err(Error::Stopped), - } - } - - async fn run_once(&mut self) -> Result<()> { - // If we try to connect too many times without managing to join at least - // once, the room is probably not accessible for one reason or another - // and the instance should stop. - self.attempts += 1; - if self.never_joined && self.attempts > self.config.server.join_attempts { - return Err(Error::OutOfJoinAttempts); - } - - let _ = self - .event_tx - .send(ClientEvent::Connecting { id: self.id }) - .await; - - let mut conn = match self.connect().await { - Ok(conn) => conn, - Err(err) => { - // When we fail to connect, we want to wait a bit before - // reconnecting in order not to spam the server. However, when - // we are connected successfully and then disconnect for - // whatever reason, we want to try to reconnect immediately. We - // might, for example, be disconnected from the server because - // we just logged in. - tokio::time::sleep(self.config.server.reconnect_delay).await; - Err(err)? - } - }; - - let _ = self - .event_tx - .send(ClientEvent::Connected { - id: self.id, - conn: conn.handle(), - state: conn.state().clone(), - }) - .await; - - let result = loop { - let received = select! { - r = conn.recv() => Ok(r?), - r = self.cmd_rx.recv() => Err(r), - }; - - match received { - // We received a packet - Ok(None) => break Ok(()), // Connection closed - Ok(Some(packet)) => self.on_packet(&mut conn, packet).await?, - // We received a command - Err(None) => break Err(Error::NoReferences), - Err(Some(cmd)) => self.on_cmd(&conn, cmd).await?, - }; - }; - - let _ = self - .event_tx - .send(ClientEvent::Disconnected { id: self.id }) - .await; - - result - } - - async fn run(mut self) { - let _ = self - .event_tx - .send(ClientEvent::Started { id: self.id }) - .await; - - loop { - if let Err(err) = self.run_once().await { - warn!("instance {:?}: {err}", self.id); - if err.is_fatal() { - break; - } - } - } - - let _ = self - .event_tx - .send(ClientEvent::Stopped { id: self.id }) - .await; - } -} - -#[derive(Clone)] -pub struct Client { - id: usize, - config: Arc, - cmd_tx: mpsc::Sender, - start_time: Timestamp, -} - -impl fmt::Debug for Client { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Instance") - .field("id", &self.id) - .finish_non_exhaustive() - } -} - -impl Client { - pub fn new(id: usize, config: ClientConfig, event_tx: mpsc::Sender) -> Self { - let start_time = Timestamp::now(); - - let config = Arc::new(config); - - let (cmd_tx, cmd_rx) = mpsc::channel(config.server.cmd_channel_bufsize); - - let task = ClientTask { - id, - config: config.clone(), - attempts: 0, - never_joined: false, - cmd_rx, - event_tx, - }; - - tokio::task::spawn(task.run()); - - Self { - id, - config, - cmd_tx, - start_time, - } - } - - pub fn id(&self) -> usize { - self.id - } - - pub fn config(&self) -> &ClientConfig { - &self.config - } - - pub fn start_time(&self) -> Timestamp { - self.start_time - } - - pub fn stopped(&self) -> bool { - self.cmd_tx.is_closed() - } - - pub async fn stop(&self) { - let _ = self.cmd_tx.send(Command::Stop).await; - } - - pub async fn handle(&self) -> Option { - let (tx, rx) = oneshot::channel(); - let _ = self.cmd_tx.send(Command::GetConn(tx)).await; - rx.await.ok() - } -} - -///////////// -// Builder // -///////////// - -impl ClientBuilderBase<'static> for Client { - type Base = (); -} - -impl Client { - pub fn builder(room: impl ToString) -> ClientBuilder<'static, Self> { - Self::builder_for_server(ServerConfig::default(), room) - } - - pub fn builder_for_server( - server: ServerConfig, - room: impl ToString, - ) -> ClientBuilder<'static, Self> { - ClientBuilder { - base: (), - config: ClientConfig::new(server, room.to_string()), - } - } -} - -impl ClientBuilder<'static, Client> { - pub fn build(self, id: usize, event_tx: mpsc::Sender) -> Client { - Client::new(id, self.config, event_tx) - } -} diff --git a/euphoxide/Cargo.toml b/euphoxide/Cargo.toml deleted file mode 100644 index bd66d50..0000000 --- a/euphoxide/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "euphoxide" -edition = { workspace = true } -version = { workspace = true } -rust-version = { workspace = true } - -[dependencies] -caseless = { workspace = true } -futures-util = { workspace = true } -jiff = { workspace = true } -log = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -tokio = { workspace = true, features = ["macros", "sync", "time"] } -tokio-tungstenite = { workspace = true } -unicode-normalization = { workspace = true } - -[dev-dependencies] -anyhow = { workspace = true } -rustls = { workspace = true } -tokio = { workspace = true, features = ["full"] } -tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] } - -[lints] -workspace = true diff --git a/euphoxide/examples/examplebot.rs b/euphoxide/examples/examplebot.rs deleted file mode 100644 index 19fe95e..0000000 --- a/euphoxide/examples/examplebot.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::time::Duration; - -use euphoxide::{ - api::{Data, Message, Nick, Send}, - client::conn::{ClientConn, ClientConnHandle}, -}; - -async fn set_nick(conn: &ClientConnHandle) -> anyhow::Result<()> { - conn.send_only(Nick { - name: "examplebot".to_string(), - }) - .await?; - - Ok(()) -} - -async fn send_pong(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> { - conn.send_only(Send { - content: "Pong!".to_string(), - parent: Some(msg.id), - }) - .await?; - - Ok(()) -} - -async fn send_pyramid(conn: &ClientConnHandle, msg: Message) -> anyhow::Result<()> { - let mut parent = msg.id; - - for _ in 0..3 { - let first = conn - .send(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - conn.send_only(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - parent = first.await?.0.id; - tokio::time::sleep(Duration::from_secs(1)).await; - } - - conn.send_only(Send { - content: "brick".to_string(), - parent: Some(parent), - }) - .await?; - - Ok(()) -} - -async fn on_data(conn: ClientConnHandle, data: Data) { - let result = match data { - Data::SnapshotEvent(_) => set_nick(&conn).await, - Data::SendEvent(event) if event.0.content == "!ping" => send_pong(&conn, event.0).await, - Data::SendEvent(event) if event.0.content == "!pyramid" => { - send_pyramid(&conn, event.0).await - } - _ => Ok(()), - }; - - if let Err(err) = result { - println!("Error while responding: {err}"); - } -} - -async fn run() -> anyhow::Result<()> { - let (mut conn, _) = ClientConn::connect("test", None).await?; - - while let Some(packet) = conn.recv().await? { - let data = packet.into_data()?; - tokio::task::spawn(on_data(conn.handle(), data)); - } - - Ok(()) -} - -#[tokio::main] -async fn main() { - loop { - if let Err(err) = run().await { - println!("Error while running: {err}"); - } - } -} diff --git a/euphoxide/src/api.rs b/euphoxide/src/api.rs deleted file mode 100644 index 23617f3..0000000 --- a/euphoxide/src/api.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Models the [euphoria.leet.nu API][0]. -//! -//! [0]: https://euphoria.leet.nu/heim/api - -pub mod account_cmds; -pub mod events; -pub mod packets; -pub mod room_cmds; -pub mod session_cmds; -pub mod types; - -pub use self::{account_cmds::*, events::*, packets::*, room_cmds::*, session_cmds::*, types::*}; diff --git a/euphoxide/src/api/packets.rs b/euphoxide/src/api/packets.rs deleted file mode 100644 index 6326a4c..0000000 --- a/euphoxide/src/api/packets.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Models the [packets][0] sent between the server and client. -//! -//! [0]: https://euphoria.leet.nu/heim/api#packets - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::Error; - -use super::PacketType; - -/// A "raw" packet. -/// -/// This packet closely matches the [packet representation defined in the -/// API][0]. It can contain arbitrary data in the form of a JSON [`Value`]. It -/// can also contain both data and an error at the same time. -/// -/// In order to interpret this packet, you probably want to convert it to a -/// [`ParsedPacket`] using [`ParsedPacket::from_packet`]. -/// -/// [0]: https://euphoria.leet.nu/heim/api#packets -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Packet { - /// Client-generated id for associating replies with commands. - pub id: Option, - /// The type of the command, reply, or event. - pub r#type: PacketType, - /// The payload of the command, reply, or event. - pub data: Option, - /// This field appears in replies if a command fails. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - /// This field appears in replies to warn the client that it may be - /// flooding. - /// - /// The client should slow down its command rate. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub throttled: bool, - /// If throttled is true, this field describes why. - #[serde(skip_serializing_if = "Option::is_none")] - pub throttled_reason: Option, -} - -/// Models the relationship between command and reply types. -/// -/// This trait is useful for type-safe command-reply APIs. -pub trait Command { - /// The type of reply one can expect from the server when sending this - /// command. - type Reply; -} - -macro_rules! packets { - ( $( $mod:ident::$name:ident, )*) => { - /// A big enum containing most types of packet data. - #[derive(Debug, Clone)] - #[non_exhaustive] - pub enum Data { - $( $name(super::$mod::$name), )* - /// A valid type of packet data that this library does not model as - /// a struct. - Unimplemented(PacketType, Value), - } - - impl Data { - /// Interpret a JSON [`Value`] as packet data of a specific [`PacketType`]. - /// - /// This method may fail if the data is invalid. - pub fn from_value(ptype: PacketType, value: Value) -> serde_json::Result { - Ok(match ptype { - $( PacketType::$name => Self::$name(serde_json::from_value(value)?), )* - _ => Self::Unimplemented(ptype, value), - }) - } - - /// Convert the packet data into a JSON [`Value`]. - /// - /// This method may fail if the data fails to serialize. - pub fn into_value(self) -> serde_json::Result { - Ok(match self { - $( Self::$name(p) => serde_json::to_value(p)?, )* - Self::Unimplemented(_, value) => value, - }) - } - - /// The [`PacketType`] of this packet data. - pub fn packet_type(&self) -> PacketType { - match self { - $( Self::$name(_) => PacketType::$name, )* - Self::Unimplemented(ptype, _) => *ptype, - } - } - } - - $( - impl From for Data { - fn from(p: super::$mod::$name) -> Self { - Self::$name(p) - } - } - - impl TryFrom for super::$mod::$name{ - type Error = (); - - fn try_from(value: Data) -> Result { - match value { - Data::$name(p) => Ok(p), - _ => Err(()) - } - } - } - )* - }; -} - -macro_rules! commands { - ( $( $cmd:ident => $rpl:ident, )* ) => { - $( - impl Command for super::$cmd { - type Reply = super::$rpl; - } - )* - }; -} - -packets! { - // Events - events::BounceEvent, - events::DisconnectEvent, - events::EditMessageEvent, - events::HelloEvent, - events::JoinEvent, - events::LoginEvent, - events::LogoutEvent, - events::NetworkEvent, - events::NickEvent, - events::PartEvent, - events::PingEvent, - events::PmInitiateEvent, - events::SendEvent, - events::SnapshotEvent, - // Session commands - session_cmds::Auth, - session_cmds::AuthReply, - session_cmds::Ping, - session_cmds::PingReply, - // Chat room commands - room_cmds::GetMessage, - room_cmds::GetMessageReply, - room_cmds::Log, - room_cmds::LogReply, - room_cmds::Nick, - room_cmds::NickReply, - room_cmds::PmInitiate, - room_cmds::PmInitiateReply, - room_cmds::Send, - room_cmds::SendReply, - room_cmds::Who, - room_cmds::WhoReply, - // Account commands - account_cmds::ChangeEmail, - account_cmds::ChangeEmailReply, - account_cmds::ChangeName, - account_cmds::ChangeNameReply, - account_cmds::ChangePassword, - account_cmds::ChangePasswordReply, - account_cmds::Login, - account_cmds::LoginReply, - account_cmds::Logout, - account_cmds::LogoutReply, - account_cmds::RegisterAccount, - account_cmds::RegisterAccountReply, - account_cmds::ResendVerificationEmail, - account_cmds::ResendVerificationEmailReply, - account_cmds::ResetPassword, - account_cmds::ResetPasswordReply, -} - -commands! { - // Session commands - Auth => AuthReply, - Ping => PingReply, - // Chat room commands - GetMessage => GetMessageReply, - Log => LogReply, - Nick => NickReply, - PmInitiate => PmInitiateReply, - Send => SendReply, - Who => WhoReply, - // Account commands - ChangeEmail => ChangeEmailReply, - ChangeName => ChangeNameReply, - ChangePassword => ChangePasswordReply, - Login => LoginReply, - Logout => LogoutReply, - RegisterAccount => RegisterAccountReply, - ResendVerificationEmail => ResendVerificationEmailReply, - ResetPassword => ResetPasswordReply, -} - -/// A fully parsed and interpreted packet. -/// -/// Compared to [`Packet`], this packet's representation more closely matches -/// the actual use of packets. -#[derive(Debug, Clone)] -pub struct ParsedPacket { - /// Client-generated id for associating replies with commands. - pub id: Option, - /// The type of the command, reply, or event. - pub r#type: PacketType, - /// The payload of the command, reply, or event, or an error message if the - /// command failed. - pub content: Result, - /// A warning to the client that it may be flooding. - /// - /// The client should slow down its command rate. - pub throttled: Option, -} - -impl ParsedPacket { - /// Convert a [`Data`]-compatible value into a [`ParsedPacket`]. - pub fn from_data(id: Option, data: impl Into) -> Self { - let data = data.into(); - Self { - id, - r#type: data.packet_type(), - content: Ok(data), - throttled: None, - } - } - - pub fn into_data(self) -> crate::Result { - self.content.map_err(Error::Euph) - } - - /// Convert a [`Packet`] into a [`ParsedPacket`]. - /// - /// This method may fail if the packet data is invalid. - pub fn from_packet(packet: Packet) -> serde_json::Result { - let id = packet.id; - let r#type = packet.r#type; - - let content = if let Some(error) = packet.error { - Err(error) - } else { - let data = packet.data.unwrap_or_default(); - Ok(Data::from_value(r#type, data)?) - }; - - let throttled = if packet.throttled { - let reason = packet - .throttled_reason - .unwrap_or_else(|| "no reason given".to_string()); - Some(reason) - } else { - None - }; - - Ok(Self { - id, - r#type, - content, - throttled, - }) - } - - /// Convert a [`ParsedPacket`] into a [`Packet`]. - /// - /// This method may fail if the packet data fails to serialize. - pub fn into_packet(self) -> serde_json::Result { - let id = self.id; - let r#type = self.r#type; - let throttled = self.throttled.is_some(); - let throttled_reason = self.throttled; - - Ok(match self.content { - Ok(data) => Packet { - id, - r#type, - data: Some(data.into_value()?), - error: None, - throttled, - throttled_reason, - }, - Err(error) => Packet { - id, - r#type, - data: None, - error: Some(error), - throttled, - throttled_reason, - }, - }) - } -} - -impl TryFrom for ParsedPacket { - type Error = serde_json::Error; - - fn try_from(value: Packet) -> Result { - Self::from_packet(value) - } -} - -impl TryFrom for Packet { - type Error = serde_json::Error; - - fn try_from(value: ParsedPacket) -> Result { - value.into_packet() - } -} - -impl TryFrom for Data { - type Error = Error; - - fn try_from(value: ParsedPacket) -> Result { - value.into_data() - } -} diff --git a/euphoxide/src/client.rs b/euphoxide/src/client.rs deleted file mode 100644 index 7ed3db4..0000000 --- a/euphoxide/src/client.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! A connection from a client's perspective. - -pub mod conn; -pub mod state; diff --git a/euphoxide/src/client/conn.rs b/euphoxide/src/client/conn.rs deleted file mode 100644 index 2ee0905..0000000 --- a/euphoxide/src/client/conn.rs +++ /dev/null @@ -1,343 +0,0 @@ -//! Client-specific connection with a more expressive API. - -use std::{future::Future, time::Duration}; - -use log::debug; -use tokio::{ - select, - sync::{mpsc, oneshot}, -}; -use tokio_tungstenite::tungstenite::{ - client::IntoClientRequest, - http::{header, HeaderValue}, -}; - -use crate::{ - api::{Command, Data, LoginReply, ParsedPacket}, - conn::{Conn, ConnConfig, Side}, - replies::{self, PendingReply, Replies}, - Error, Result, -}; - -use super::state::State; - -enum ConnCommand { - SendCmd(Data, oneshot::Sender>>), - GetState(oneshot::Sender), -} - -/// Configuration options for a [`ClientConn`]. -#[derive(Debug, Clone)] -pub struct ClientConnConfig { - /// The domain where the server is hosted. - pub domain: String, - /// Whether the client should present itself as a human to the server. - /// - /// This should only be set if the client is directly acting on behalf of a - /// human, similar to the web client. - pub human: bool, - /// The size of the [`mpsc::channel`] for communication between - /// [`ClientConn`] and [`ClientConnHandle`]. - pub channel_bufsize: usize, - /// Timeout for opening a websocket connection. - pub connect_timeout: Duration, - /// Timeout for server replies when sending euphoria commands, i.e. packets - /// implementing [`Command`]. - pub command_timeout: Duration, - /// How long to wait in-between sending pings. - /// - /// See also [`ConnConfig::ping_interval`]. - pub ping_interval: Duration, -} - -impl Default for ClientConnConfig { - fn default() -> Self { - Self { - domain: "euphoria.leet.nu".to_string(), - human: false, - channel_bufsize: 10, - connect_timeout: Duration::from_secs(10), - command_timeout: Duration::from_secs(30), - ping_interval: Duration::from_secs(30), - } - } -} - -/// A client connection to an euphoria server. -/// -/// This struct is a wrapper around [`Conn`] with a more client-centric API. It -/// tracks the connection state, including room information sent by the server. -/// It also provides [`ClientConnHandle`], which can be used to asynchronously -/// send commands and await their replies. -pub struct ClientConn { - rx: mpsc::Receiver, - tx: mpsc::Sender, - - conn: Conn, - state: State, - - last_id: usize, - replies: Replies, -} - -impl ClientConn { - /// Retrieve the current [`State`] of the connection. - pub fn state(&self) -> &State { - &self.state - } - - /// Create a new handle for this connection. - pub fn handle(&self) -> ClientConnHandle { - ClientConnHandle { - tx: self.tx.clone(), - } - } - - /// Start closing the connection. - /// - /// To finish closing the connection gracefully, continue calling - /// [`Self::recv`] until it returns [`None`]. - pub async fn close(&mut self) -> Result<()> { - self.conn.close().await - } - - /// Receive a [`ParsedPacket`] over the connection. - /// - /// This method also maintains the connection by listening and responding to - /// pings as well as managing [`ClientConnHandle`]s. Thus, it must be called - /// regularly. - /// - /// Returns [`None`] if the connection is closed. - pub async fn recv(&mut self) -> Result> { - loop { - self.replies.purge(); - - // There's always at least one tx end (self.tx), so self.rx.recv() - // should never return None. - let packet = select! { - packet = self.conn.recv() => packet?, - Some(cmd) = self.rx.recv() => { - self.on_cmd(cmd).await; - continue; - }, - }; - - if let Some(packet) = &packet { - self.on_packet(packet).await?; - } - - break Ok(packet); - } - } - - /// Send a packet over the connection. - /// - /// A packet id is automatically generated and returned. When the server - /// replies to the packet, it will use this id as its [`ParsedPacket::id`]. - pub async fn send(&mut self, data: impl Into) -> Result { - // Overkill of universe-heat-death-like proportions - self.last_id = self.last_id.wrapping_add(1); - let id = self.last_id.to_string(); - - self.conn - .send(ParsedPacket::from_data(Some(id.clone()), data.into())) - .await?; - - Ok(id) - } - - async fn on_packet(&mut self, packet: &ParsedPacket) -> Result<()> { - if let Ok(data) = &packet.content { - self.state.on_data(data); - - // The euphoria server doesn't always disconnect the client when it - // would make sense to do so or when the API specifies it should. - // This ensures we always disconnect when it makes sense to do so. - if matches!( - data, - Data::DisconnectEvent(_) - | Data::LoginEvent(_) - | Data::LogoutEvent(_) - | Data::LoginReply(LoginReply { success: true, .. }) - | Data::LogoutReply(_) - ) { - self.close().await?; - } - } - - if let Some(id) = &packet.id { - let id = id.clone(); - self.replies.complete(&id, packet.clone()); - } - - Ok(()) - } - - async fn on_cmd(&mut self, cmd: ConnCommand) { - match cmd { - ConnCommand::SendCmd(data, sender) => { - let result = self.send(data).await.map(|id| self.replies.wait_for(id)); - let _ = sender.send(result); - } - ConnCommand::GetState(sender) => { - let _ = sender.send(self.state.clone()); - } - } - } - - /// Connect to a room. - /// - /// See [`Self::connect_with_config`] for more details. - pub async fn connect( - room: &str, - cookies: Option, - ) -> Result<(Self, Vec)> { - Self::connect_with_config(room, cookies, &ClientConnConfig::default()).await - } - - /// Connect to a room with a specific configuration. - /// - /// Cookies to be sent to the server can be specified as a [`HeaderValue`] - /// in the format of a [`Cookie` request header][0]. If the connection - /// attempt was successful, cookies set by the server will be returned - /// alongside the connection itself as one [`HeaderValue`] per [`Set-Cookie` - /// response header][1]. - /// - /// The tasks of cookie parsing and storage are not handled by this library. - /// - /// [0]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie - /// [1]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie - pub async fn connect_with_config( - room: &str, - cookies: Option, - config: &ClientConnConfig, - ) -> Result<(Self, Vec)> { - // Prepare URL - let human = if config.human { "?h=1" } else { "" }; - let uri = format!("wss://{}/room/{room}/ws{human}", config.domain); - debug!("Connecting to {uri} with cookies: {cookies:?}"); - - // Prepare request - let mut request = uri.into_client_request().expect("valid request"); - if let Some(cookies) = cookies { - request.headers_mut().append(header::COOKIE, cookies); - } - - // Connect to server - let (ws, response) = tokio::time::timeout( - config.connect_timeout, - tokio_tungstenite::connect_async(request), - ) - .await - .map_err(|_| Error::ConnectionTimeout)??; - - // Extract response cookies - let (mut parts, _) = response.into_parts(); - let cookies_set = match parts.headers.entry(header::SET_COOKIE) { - header::Entry::Occupied(entry) => entry.remove_entry_mult().1.collect(), - header::Entry::Vacant(_) => vec![], - }; - debug!("Received cookies {cookies_set:?}"); - - // Prepare EuphConn - let conn_config = ConnConfig { - ping_interval: config.ping_interval, - }; - let conn = Conn::wrap_with_config(ws, Side::Client, conn_config); - - // Prepare client - let (tx, rx) = mpsc::channel(config.channel_bufsize); - let client = Self { - rx, - tx, - conn, - state: State::new(), - last_id: 0, - replies: Replies::new(config.command_timeout), - }; - - Ok((client, cookies_set)) - } -} - -/// Asynchronous access to a [`ClientConn`]. -/// -/// Handle methods are only processed while [`ClientConn::recv`] is being -/// called. They may return before they were processed by the associated -/// [`ClientConn`], or they may block until processed. Methods are processed in -/// the order they are called. -/// -/// The handle is cheap to clone. -#[derive(Debug, Clone)] -pub struct ClientConnHandle { - tx: mpsc::Sender, -} - -impl ClientConnHandle { - /// Send a command to the server. - /// - /// When awaited, returns either an error if something went wrong while - /// sending the command, or a second future with the server's reply (the - /// *reply future*). - /// - /// When awaited, the *reply future* returns either an error if something - /// was wrong with the reply, or the data returned by the server. The *reply - /// future* can be safely ignored and doesn't have to be awaited. - pub async fn send(&self, cmd: C) -> Result>> - where - C: Command + Into, - C::Reply: TryFrom, - { - let (tx, rx) = oneshot::channel(); - - self.tx - .send(ConnCommand::SendCmd(cmd.into(), tx)) - .await - .map_err(|_| Error::ConnectionClosed)?; - - Ok(async { - let data = rx - .await - .map_err(|_| Error::ConnectionClosed)?? - .get() - .await - .map_err(|err| match err { - replies::Error::TimedOut => Error::CommandTimeout, - replies::Error::Canceled => Error::ConnectionClosed, - })? - .content - .map_err(Error::Euph)?; - - let ptype = data.packet_type(); - data.try_into() - .map_err(|_| Error::ReceivedUnexpectedPacket(ptype)) - }) - } - - /// Send a command to the server without waiting for a reply. - /// - /// This method is equivalent to calling and awaiting [`Self::send`] but - /// ignoring the *reply future*. The reason it exists is that clippy gets - /// really annoying when you try to ignore a future (which is usually the - /// right call). - pub async fn send_only(&self, cmd: C) -> Result<()> - where - C: Command + Into, - C::Reply: TryFrom, - { - let _ignore = self.send(cmd).await?; - Ok(()) - } - - /// Retrieve the current connection [`State`]. - pub async fn state(&self) -> Result { - let (tx, rx) = oneshot::channel(); - - self.tx - .send(ConnCommand::GetState(tx)) - .await - .map_err(|_| Error::ConnectionClosed)?; - - rx.await.map_err(|_| Error::ConnectionClosed) - } -} diff --git a/euphoxide/src/client/state.rs b/euphoxide/src/client/state.rs deleted file mode 100644 index a90edde..0000000 --- a/euphoxide/src/client/state.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! Models the client's connection state. - -use std::collections::HashMap; - -use jiff::Timestamp; -use log::debug; - -use crate::api::{ - BounceEvent, Data, HelloEvent, NickEvent, PersonalAccountView, SessionId, SessionView, - SnapshotEvent, UserId, -}; - -/// Information about a session in the room. -/// -/// For quite a while before finally going down altogether, the euphoria.io -/// instance had an unreliable nick list: Listings returned by the server were -/// usually incomplete. Because of this, the bot library uses any observable -/// action by a session (including nick changes) to update the listing. Since -/// nick events don't include full session info though, the [`SessionInfo`] enum -/// can contain partial information. -/// -/// This level of paranioa probably isn't required any more now that the only -/// euphoria instance is working correctly. However, the code already works and -/// users who don't want to worry about it can just ignore partial session -/// infos. -#[derive(Debug, Clone)] -pub enum SessionInfo { - Full(SessionView), - Partial(NickEvent), -} - -impl SessionInfo { - /// Retrieve the user id of the session. - pub fn id(&self) -> &UserId { - match self { - Self::Full(sess) => &sess.id, - Self::Partial(nick) => &nick.id, - } - } - - /// Retrieve the session id of the session. - pub fn session_id(&self) -> &SessionId { - match self { - Self::Full(sess) => &sess.session_id, - Self::Partial(nick) => &nick.session_id, - } - } - - /// Retrieve the user name of the session. - pub fn name(&self) -> &str { - match self { - Self::Full(sess) => &sess.name, - Self::Partial(nick) => &nick.to, - } - } -} - -impl From for SessionInfo { - fn from(value: SessionView) -> Self { - Self::Full(value) - } -} - -impl From for SessionInfo { - fn from(value: NickEvent) -> Self { - Self::Partial(value) - } -} - -/// The state of the connection before the client has joined the room. -/// -/// Depending on the room, the client may need to authenticate or log in in -/// order to join. -#[derive(Debug, Clone)] -pub struct Joining { - /// Since when the connection has been in this state. - pub since: Timestamp, - /// A [`HelloEvent`], if one has been received. - /// - /// Contains information about the client's own session. - pub hello: Option, - /// A [`SnapshotEvent`], if one has been received. - pub snapshot: Option, - /// A [`BounceEvent`], if one has been received. - pub bounce: Option, -} - -impl Joining { - fn new() -> Self { - Self { - since: Timestamp::now(), - hello: None, - snapshot: None, - bounce: None, - } - } - - fn on_data(&mut self, data: &Data) { - match data { - Data::BounceEvent(p) => self.bounce = Some(p.clone()), - Data::HelloEvent(p) => self.hello = Some(p.clone()), - Data::SnapshotEvent(p) => self.snapshot = Some(p.clone()), - _ => {} - } - } - - fn to_joined(&self) -> Option { - let hello = self.hello.as_ref()?; - let snapshot = self.snapshot.as_ref()?; - - let mut session = hello.session.clone(); - - if let Some(nick) = &snapshot.nick { - session.name = nick.clone(); - } - - let listing = snapshot - .listing - .iter() - .cloned() - .map(|s| (s.session_id.clone(), SessionInfo::Full(s))) - .collect::>(); - - Some(Joined { - since: Timestamp::now(), - session, - account: hello.account.clone(), - listing, - }) - } -} - -/// The state of the connection after the client has successfully joined the -/// room. -/// -/// The client may need to set a nick in order to be able to send messages. -/// However, it can access the room history without nick. -#[derive(Debug, Clone)] -pub struct Joined { - /// Since when the connection has been in this state. - pub since: Timestamp, - /// The client's own session. - pub session: SessionView, - /// Account information, if the client is logged in. - pub account: Option, - /// All sessions currently connected to the room (except the client's own - /// session). - pub listing: HashMap, -} - -impl Joined { - fn on_data(&mut self, data: &Data) { - match data { - Data::JoinEvent(p) => { - debug!("Updating listing after join-event"); - self.listing - .insert(p.0.session_id.clone(), SessionInfo::Full(p.0.clone())); - } - Data::PartEvent(p) => { - debug!("Updating listing after part-event"); - self.listing.remove(&p.0.session_id); - } - Data::NetworkEvent(p) => { - if p.r#type == "partition" { - debug!("Updating listing after network-event with type partition"); - self.listing.retain(|_, s| match s { - SessionInfo::Full(s) => { - s.server_id != p.server_id && s.server_era != p.server_era - } - // We can't know if the session was disconnected by the - // partition or not, so we're erring on the side of - // caution and assuming they were kicked. If we're - // wrong, we'll re-add the session as soon as it - // performs another visible action. - // - // If we always kept such sessions, we might keep - // disconnected ones indefinitely, thereby keeping them - // from moving on, instead forever tethering them to the - // digital realm. - SessionInfo::Partial(_) => false, - }); - } - } - Data::SendEvent(p) => { - debug!("Updating listing after send-event"); - self.listing.insert( - p.0.sender.session_id.clone(), - SessionInfo::Full(p.0.sender.clone()), - ); - } - Data::NickEvent(p) => { - debug!("Updating listing after nick-event"); - self.listing - .entry(p.session_id.clone()) - .and_modify(|s| match s { - SessionInfo::Full(session) => session.name = p.to.clone(), - SessionInfo::Partial(_) => *s = SessionInfo::Partial(p.clone()), - }) - .or_insert_with(|| SessionInfo::Partial(p.clone())); - } - Data::NickReply(p) => { - debug!("Updating own session after nick-reply"); - assert_eq!(self.session.id, p.id); - self.session.name = p.to.clone(); - } - Data::WhoReply(p) => { - debug!("Updating listing after who-reply"); - self.listing.clear(); - for session in p.listing.clone() { - if session.session_id == self.session.session_id { - self.session = session; - } else { - self.listing - .insert(session.session_id.clone(), session.into()); - } - } - } - _ => {} - } - } -} - -/// The state of a connection to the server, from a client's perspective. -#[derive(Debug, Clone)] -#[allow(clippy::large_enum_variant)] -pub enum State { - /// The client has not joined the room yet. - Joining(Joining), - /// The client has successfully joined the room. - Joined(Joined), -} - -impl State { - /// Create a new state for a fresh connection. - /// - /// Assumes that no packets have been received yet. See also - /// [`Self::on_data`]. - pub fn new() -> Self { - Joining::new().into() - } - - /// If the state consists of a [`Joining`], return a reference to it. - pub fn as_joining(&self) -> Option<&Joining> { - match self { - Self::Joining(joining) => Some(joining), - Self::Joined(_) => None, - } - } - - /// If the state consists of a [`Joined`], return a reference to it. - pub fn as_joined(&self) -> Option<&Joined> { - match self { - Self::Joining(_) => None, - Self::Joined(joined) => Some(joined), - } - } - - /// If the state consists of a [`Joining`], return it. - pub fn into_joining(self) -> Option { - match self { - Self::Joining(joining) => Some(joining), - Self::Joined(_) => None, - } - } - - /// If the state consists of a [`Joined`], return it. - pub fn into_joined(self) -> Option { - match self { - Self::Joining(_) => None, - Self::Joined(joined) => Some(joined), - } - } - - /// Update the state with a packet received from the server. - /// - /// This method should be called whenever any packet is received from the - /// server. Skipping packets may cause the state to become inconsistent. - pub fn on_data(&mut self, data: &Data) { - match self { - Self::Joining(joining) => { - joining.on_data(data); - if let Some(joined) = joining.to_joined() { - *self = joined.into(); - } - } - Self::Joined(joined) => joined.on_data(data), - } - } -} - -impl Default for State { - fn default() -> Self { - Self::new() - } -} - -impl From for State { - fn from(value: Joining) -> Self { - Self::Joining(value) - } -} - -impl From for State { - fn from(value: Joined) -> Self { - Self::Joined(value) - } -} diff --git a/euphoxide/src/conn.rs b/euphoxide/src/conn.rs deleted file mode 100644 index 513711a..0000000 --- a/euphoxide/src/conn.rs +++ /dev/null @@ -1,312 +0,0 @@ -//! Basic connection between client and server. - -use std::{fmt, time::Duration}; - -use futures_util::{SinkExt, StreamExt}; -use jiff::Timestamp; -use log::debug; -use tokio::{ - net::TcpStream, - select, - time::{self, Instant}, -}; -use tokio_tungstenite::{ - tungstenite::{client::IntoClientRequest, handshake::client::Response, Message}, - MaybeTlsStream, WebSocketStream, -}; - -use crate::{ - api::{Data, Packet, PacketType, ParsedPacket, Ping, PingEvent, PingReply, Time}, - Error, Result, -}; - -/// Which side of the connection we're on. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Side { - /// We're the client and are talking to a server. - Client, - /// We're the server and are talking to a client. - Server, -} - -/// Configuration options for a [`Conn`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ConnConfig { - /// How long to wait in-between sending pings. - /// - /// This includes both websocket and euphoria pings ([`Ping`] or - /// [`PingEvent`]). - pub ping_interval: Duration, -} - -impl Default for ConnConfig { - fn default() -> Self { - Self { - ping_interval: Duration::from_secs(30), - } - } -} - -/// A basic connection between a client and a server. -/// -/// The connection can be used both from a server's and from a client's -/// perspective. In both cases, it performs regular websocket *and* euphoria -/// pings and terminates the connection if the other side does not reply before -/// the next ping is sent. -pub struct Conn { - ws: WebSocketStream>, - side: Side, - config: ConnConfig, - - // The websocket server may send a pong frame with arbitrary payload - // unprompted at any time (see RFC 6455 5.5.3). Because of this, we can't - // just remember the last pong payload. - last_ping: Instant, - last_ws_ping_payload: Option>, - last_ws_ping_replied_to: bool, - last_euph_ping_payload: Option