From 904dda1af06555cba7eb7d5985fd703dc14864f9 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 5 Dec 2024 11:30:32 +0100 Subject: [PATCH 01/32] Prepare rewrite --- .vscode/settings.json | 12 +- Cargo.toml | 37 --- examples/testbot_commands.rs | 150 --------- examples/testbot_instance.rs | 146 --------- examples/testbot_instances.rs | 159 ---------- examples/testbot_manual.rs | 142 --------- src/bot.rs | 7 - src/bot/botrulez.rs | 10 - src/bot/botrulez/full_help.rs | 93 ------ src/bot/botrulez/ping.rs | 64 ---- src/bot/botrulez/short_help.rs | 58 ---- src/bot/botrulez/uptime.rs | 133 -------- src/bot/command.rs | 64 ---- src/bot/command/bang.rs | 235 --------------- src/bot/command/clap.rs | 182 ----------- src/bot/command/hidden.rs | 29 -- src/bot/command/prefixed.rs | 45 --- src/bot/commands.rs | 93 ------ src/bot/instance.rs | 534 --------------------------------- src/bot/instances.rs | 70 ----- src/lib.rs | 8 - 21 files changed, 6 insertions(+), 2265 deletions(-) delete mode 100644 examples/testbot_commands.rs delete mode 100644 examples/testbot_instance.rs delete mode 100644 examples/testbot_instances.rs delete mode 100644 examples/testbot_manual.rs delete mode 100644 src/bot.rs delete mode 100644 src/bot/botrulez.rs delete mode 100644 src/bot/botrulez/full_help.rs delete mode 100644 src/bot/botrulez/ping.rs delete mode 100644 src/bot/botrulez/short_help.rs delete mode 100644 src/bot/botrulez/uptime.rs delete mode 100644 src/bot/command.rs delete mode 100644 src/bot/command/bang.rs delete mode 100644 src/bot/command/clap.rs delete mode 100644 src/bot/command/hidden.rs delete mode 100644 src/bot/command/prefixed.rs delete mode 100644 src/bot/commands.rs delete mode 100644 src/bot/instance.rs delete mode 100644 src/bot/instances.rs 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; From a661449b6f021b74bc254daea1e831b975ac3cc7 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 5 Dec 2024 11:32:43 +0100 Subject: [PATCH 02/32] Refactor and document emoji module --- Cargo.toml | 1 + src/emoji.rs | 165 +++++++++++++++++++++++++++++++++++++++++++-------- src/lib.rs | 2 + 3 files changed, 144 insertions(+), 24 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6e5b6c4..e37a809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.5.1" edition = "2021" [dependencies] +serde_json = "1.0.133" [lints] rust.unsafe_code = { level = "forbid", priority = 1 } diff --git a/src/emoji.rs b/src/emoji.rs index f7a9e75..c867d0d 100644 --- a/src/emoji.rs +++ b/src/emoji.rs @@ -1,10 +1,6 @@ -//! All emoji the euphoria.leet.nu client knows. +use std::{borrow::Cow, collections::HashMap, ops::Range}; -use std::borrow::Cow; -use std::collections::HashMap; -use std::ops::RangeInclusive; - -/// Euphoria.leet.nu emoji list, obtainable via shell command: +/// Emoji list from euphoria.leet.nu, obtainable via shell command: /// /// ```bash /// curl 'https://euphoria.leet.nu/static/emoji.json' \ @@ -13,9 +9,12 @@ use std::ops::RangeInclusive; /// ``` const EMOJI_JSON: &str = include_str!("emoji.json"); -/// A map from emoji names to their unicode representation. Not all emojis have -/// such a representation. -pub struct Emoji(pub HashMap>); +/// A database of emoji names and their unicode representation. +/// +/// Some emoji are rendered with custom icons in the web client and don't +/// correspond to an emoji in the unicode standard. These emoji don't have an +/// unicode representation. +pub struct Emoji(HashMap>); fn parse_hex_to_char(hex: &str) -> Option { u32::from_str_radix(hex, 16).ok()?.try_into().ok() @@ -29,7 +28,16 @@ fn parse_code_points(code_points: &str) -> Option { } impl Emoji { - /// Load a list of emoji compiled into the library. + /// Load the list of emoji compiled into the library. + /// + /// # Example + /// + /// ``` + /// use euphoxide::Emoji; + /// let emoji = Emoji::load(); + /// + /// assert_eq!(emoji.get("robot"), Some(Some("🤖"))); + /// ``` pub fn load() -> Self { Self::load_from_json(EMOJI_JSON).unwrap() } @@ -38,9 +46,26 @@ impl Emoji { /// /// The object keys are the emoji names (without colons `:`). The object /// values are the emoji code points encoded as hexadecimal numbers and - /// separated by a dash `-` (e.g. `"34-fe0f-20e3"`). Emojis whose values - /// don't match this schema are interpreted as emojis without unicode + /// separated by a dash `-` (e.g. `"34-fe0f-20e3"`). Emoji whose values + /// don't match this schema are interpreted as emoji without unicode /// representation. + /// + /// This is the format used by the [euphoria.leet.nu emoji listing][0]. + /// + /// [0]: https://euphoria.leet.nu/static/emoji.json + /// + /// # Example + /// + /// ``` + /// use euphoxide::Emoji; + /// + /// const EMOJI: &str = r#" {"Roboter": "1f916", "foo": "~bar"} "#; + /// let emoji = Emoji::load_from_json(EMOJI).unwrap(); + /// + /// assert_eq!(emoji.get("Roboter"), Some(Some("🤖"))); + /// assert_eq!(emoji.get("foo"), Some(None)); + /// assert_eq!(emoji.get("robot"), None); + /// ``` pub fn load_from_json(json: &str) -> Option { let map = serde_json::from_str::>(json) .ok()? @@ -51,6 +76,25 @@ impl Emoji { Some(Self(map)) } + /// Retrieve an emoji's unicode representation by name. + /// + /// Returns `None` if the emoji could not be found. Returns `Some(None)` if + /// the emoji could be found but does not have a unicode representation. + /// + /// The name is not colon-delimited. + /// + /// # Example + /// + /// ``` + /// use euphoxide::Emoji; + /// let emoji = Emoji::load(); + /// + /// assert_eq!(emoji.get("robot"), Some(Some("🤖"))); + /// assert_eq!(emoji.get("+1"), Some(None)); + /// assert_eq!(emoji.get("foobar"), None); + /// + /// assert_eq!(emoji.get(":robot:"), None); + /// ``` pub fn get(&self, name: &str) -> Option> { match self.0.get(name) { Some(Some(replace)) => Some(Some(replace)), @@ -59,7 +103,50 @@ impl Emoji { } } - pub fn find(&self, text: &str) -> Vec<(RangeInclusive, Option<&str>)> { + /// All known emoji and their unicode representation. + /// + /// The emoji are not in any particular order. + /// + /// # Example + /// + /// ``` + /// use euphoxide::Emoji; + /// let emoji = Emoji::load(); + /// + /// // List all emoji that don't have a unicode representation + /// let custom_emoji = emoji + /// .all() + /// .filter(|(_, unicode)| unicode.is_none()) + /// .map(|(name, _)| name) + /// .collect::>(); + /// + /// assert!(!custom_emoji.is_empty()); + /// ``` + pub fn all(&self) -> impl Iterator)> { + self.0 + .iter() + .map(|(k, v)| (k as &str, v.as_ref().map(|v| v as &str))) + } + + /// Find all colon-delimited emoji in a string. + /// + /// Returns a list of emoji locations (colons are included in the range) and + /// corresponding unicode representations. + /// + /// # Example + /// + /// ``` + /// use euphoxide::Emoji; + /// let emoji = Emoji::load(); + /// + /// let found = emoji.find("Hello :globe_with_meridians:!"); + /// assert_eq!(found, vec![(6..28, Some("🌐"))]); + /// + /// // Ignores nonexistent emoji + /// let found = emoji.find("Hello :sparkly_wizard:!"); + /// assert!(found.is_empty()); + /// ``` + pub fn find(&self, text: &str) -> Vec<(Range, Option<&str>)> { let mut result = vec![]; let mut prev_colon_idx = None; @@ -67,7 +154,7 @@ impl Emoji { if let Some(prev_idx) = prev_colon_idx { let name = &text[prev_idx + 1..colon_idx]; if let Some(replace) = self.get(name) { - let range = prev_idx..=colon_idx; + let range = prev_idx..colon_idx + 1; result.push((range, replace)); prev_colon_idx = None; continue; @@ -79,6 +166,21 @@ impl Emoji { result } + /// Replace all colon-delimited emoji in a string. + /// + /// # Example + /// + /// ``` + /// use euphoxide::Emoji; + /// let emoji = Emoji::load(); + /// + /// let replaced = emoji.replace("Hello :globe_with_meridians:!"); + /// assert_eq!(replaced, "Hello 🌐!"); + /// + /// // Ignores nonexistent emoji + /// let replaced = emoji.replace("Hello :sparkly_wizard:!"); + /// assert_eq!(replaced, "Hello :sparkly_wizard:!"); + /// ``` pub fn replace<'a>(&self, text: &'a str) -> Cow<'a, str> { let emoji = self.find(text); if emoji.is_empty() { @@ -91,13 +193,13 @@ impl Emoji { for (range, replace) in emoji { // Only replace emoji with a replacement if let Some(replace) = replace { - if *range.start() > after_last_emoji { + if range.start > after_last_emoji { // There were non-emoji characters between the last and the // current emoji. - result.push_str(&text[after_last_emoji..*range.start()]); + result.push_str(&text[after_last_emoji..range.start]); } result.push_str(replace); - after_last_emoji = range.end() + 1; + after_last_emoji = range.end; } } @@ -108,6 +210,21 @@ impl Emoji { Cow::Owned(result) } + /// Remove all colon-delimited emoji in a string. + /// + /// # Example + /// + /// ``` + /// use euphoxide::Emoji; + /// let emoji = Emoji::load(); + /// + /// let removed = emoji.remove("Hello :globe_with_meridians:!"); + /// assert_eq!(removed, "Hello !"); + /// + /// // Ignores nonexistent emoji + /// let removed = emoji.replace("Hello :sparkly_wizard:!"); + /// assert_eq!(removed, "Hello :sparkly_wizard:!"); + /// ``` pub fn remove<'a>(&self, text: &'a str) -> Cow<'a, str> { let emoji = self.find(text); if emoji.is_empty() { @@ -118,12 +235,12 @@ impl Emoji { let mut after_last_emoji = 0; for (range, _) in emoji { - if *range.start() > after_last_emoji { + if range.start > after_last_emoji { // There were non-emoji characters between the last and the // current emoji. - result.push_str(&text[after_last_emoji..*range.start()]); + result.push_str(&text[after_last_emoji..range.start]); } - after_last_emoji = range.end() + 1; + after_last_emoji = range.end; } if after_last_emoji < text.len() { @@ -149,15 +266,15 @@ mod test { // :bad: does not exist, while :x: and :o: do. - assert_eq!(emoji.find(":bad:x:o:"), vec![(4..=6, Some("❌"))]); + assert_eq!(emoji.find(":bad:x:o:"), vec![(4..7, Some("❌"))]); assert_eq!( emoji.find(":x:bad:o:"), - vec![(0..=2, Some("❌")), (6..=8, Some("⭕"))] + vec![(0..3, Some("❌")), (6..9, Some("⭕"))] ); - assert_eq!(emoji.find("ab:bad:x:o:cd"), vec![(6..=8, Some("❌"))]); + assert_eq!(emoji.find("ab:bad:x:o:cd"), vec![(6..9, Some("❌"))]); assert_eq!( emoji.find("ab:x:bad:o:cd"), - vec![(2..=4, Some("❌")), (8..=10, Some("⭕"))] + vec![(2..5, Some("❌")), (8..11, Some("⭕"))] ); } diff --git a/src/lib.rs b/src/lib.rs index 8b13789..adb9a54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1 +1,3 @@ +mod emoji; +pub use crate::emoji::Emoji; From 31a6d33267bf1a76f0be2ec572e4a01cc01f0173 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 5 Dec 2024 12:37:24 +0100 Subject: [PATCH 03/32] Refactor and document api module --- Cargo.toml | 2 + src/api.rs | 21 ++- src/api/account_cmds.rs | 20 ++- src/api/events.rs | 62 ++++++--- src/api/packet.rs | 223 ------------------------------ src/api/packets.rs | 294 ++++++++++++++++++++++++++++++++++++++++ src/api/room_cmds.rs | 16 ++- src/api/session_cmds.rs | 8 +- src/api/types.rs | 53 +++++--- src/lib.rs | 1 + 10 files changed, 428 insertions(+), 272 deletions(-) delete mode 100644 src/api/packet.rs create mode 100644 src/api/packets.rs diff --git a/Cargo.toml b/Cargo.toml index e37a809..9a948f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.5.1" edition = "2021" [dependencies] +jiff = { version = "0.1.15", default-features = false, features = ["std"] } +serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" [lints] diff --git a/src/api.rs b/src/api.rs index e24ca1d..23617f3 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,17 +1,12 @@ -//! Models the [euphoria API][0]. +//! Models the [euphoria.leet.nu API][0]. //! //! [0]: https://euphoria.leet.nu/heim/api -mod account_cmds; -mod events; -pub mod packet; -mod room_cmds; -mod session_cmds; -mod types; +pub mod account_cmds; +pub mod events; +pub mod packets; +pub mod room_cmds; +pub mod session_cmds; +pub mod types; -pub use account_cmds::*; -pub use events::*; -pub use packet::Data; -pub use room_cmds::*; -pub use session_cmds::*; -pub use types::*; +pub use self::{account_cmds::*, events::*, packets::*, room_cmds::*, session_cmds::*, types::*}; diff --git a/src/api/account_cmds.rs b/src/api/account_cmds.rs index 01ef7f0..b9ed570 100644 --- a/src/api/account_cmds.rs +++ b/src/api/account_cmds.rs @@ -1,8 +1,10 @@ -//! Account commands. +//! Models [account commands][0] and their replies. //! //! These commands enable a client to register, associate, and dissociate with //! an account. An account allows an identity to be shared across browsers and //! devices, and is a prerequisite for room management +//! +//! [0]: https://euphoria.leet.nu/heim/api#account-commands use serde::{Deserialize, Serialize}; @@ -11,6 +13,8 @@ use super::AccountId; /// Change the primary email address associated with the signed in account. /// /// The email address may need to be verified before the change is fully applied. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChangeEmail { /// The new primary email address for the account. @@ -32,6 +36,8 @@ pub struct ChangeEmailReply { } /// Change the name associated with the signed in account. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChangeName { /// The name to associate with the account. @@ -46,6 +52,8 @@ pub struct ChangeNameReply { } /// Change the password of the signed in account. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChangePassword { /// The current (and soon-to-be former) password. @@ -65,6 +73,8 @@ pub struct ChangePasswordReply {} /// If the login succeeds, the client should expect to receive a /// [`DisconnectEvent`](super::DisconnectEvent) shortly after. The next /// connection the client makes will be a logged in session. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Login { /// The namespace of a personal identifier. @@ -98,6 +108,8 @@ pub struct LoginReply { /// If the logout is successful, the client should expect to receive a /// [`DisconnectEvent`](super::DisconnectEvent) shortly after. The next /// connection the client makes will be a logged out session. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Logout {} @@ -113,6 +125,8 @@ pub struct LogoutReply {} /// [`DisconnectEvent`](super::DisconnectEvent) shortly after. The next /// connection the client makes will be a logged in session using the new /// account. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegisterAccount { /// The namespace of a personal identifier. @@ -145,6 +159,8 @@ pub struct RegisterAccountReply { /// /// An error will be returned if the account has no unverified email addresses /// associated with it. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResendVerificationEmail {} @@ -156,6 +172,8 @@ pub struct ResendVerificationEmailReply {} /// /// An email will be sent to the owner of the given personal identifier, with /// instructions and a confirmation code for resetting the password. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResetPassword { pub namespace: String, diff --git a/src/api/events.rs b/src/api/events.rs index 8abe04d..9729e93 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -1,4 +1,6 @@ -//! Asynchronous events. +//! Models [asynchronous events][0]. +//! +//! [0]: https://euphoria.leet.nu/heim/api#asynchronous-events use serde::{Deserialize, Serialize}; @@ -8,6 +10,8 @@ use super::{ }; /// Indicates that access to a room is denied. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BounceEvent { /// The reason why access was denied. @@ -25,16 +29,37 @@ pub struct BounceEvent { /// /// If the disconnect reason is `authentication changed`, the client should /// immediately reconnect. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DisconnectEvent { /// The reason for disconnection. pub reason: String, } +/// Indicates that a message in the room has been modified or deleted. +/// +/// If the client offers a user interface and the indicated message is currently +/// displayed, it should update its display accordingly. +/// +/// The event packet includes a snapshot of the message post-edit. +/// +/// +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EditMessageEvent { + /// The id of the edit. + pub edit_id: Snowflake, + /// The snapshot of the message post-edit. + #[serde(flatten)] + pub message: Message, +} + /// Sent by the server to the client when a session is started. /// /// It includes information about the client's authentication and associated /// identity. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HelloEvent { /// The id of the agent or account logged into this session. @@ -55,11 +80,15 @@ pub struct HelloEvent { } /// Indicates a session just joined the room. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct JoinEvent(pub SessionView); /// Sent to all sessions of an agent when that agent is logged in (except for /// the session that issued the login command). +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginEvent { pub account_id: AccountId, @@ -67,6 +96,8 @@ pub struct LoginEvent { /// Sent to all sessions of an agent when that agent is logged out (except for /// the session that issued the logout command). +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LogoutEvent {} @@ -75,6 +106,8 @@ pub struct LogoutEvent {} /// /// If the network event type is `partition`, then this should be treated as a /// [`PartEvent`] for all sessions connected to the same server id/era combo. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkEvent { /// The type of network event; for now, always `partition`. @@ -86,6 +119,8 @@ pub struct NetworkEvent { } /// Announces a nick change by another session in the room. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NickEvent { /// The id of the session this name applies to. @@ -98,22 +133,9 @@ pub struct NickEvent { pub to: String, } -/// Indicates that a message in the room has been modified or deleted. -/// -/// If the client offers a user interface and the indicated message is currently -/// displayed, it should update its display accordingly. -/// -/// The event packet includes a snapshot of the message post-edit. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EditMessageEvent { - /// The id of the edit. - pub edit_id: Snowflake, - /// The snapshot of the message post-edit. - #[serde(flatten)] - pub message: Message, -} - /// Indicates a session just disconnected from the room. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PartEvent(pub SessionView); @@ -121,6 +143,8 @@ pub struct PartEvent(pub SessionView); /// /// The client should send back a ping-reply with the same value for the time /// field as soon as possible (or risk disconnection). +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PingEvent { /// A unix timestamp according to the server's clock. @@ -131,6 +155,8 @@ pub struct PingEvent { } /// Informs the client that another user wants to chat with them privately. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PmInitiateEvent { /// The id of the user inviting the client to chat privately. @@ -144,12 +170,16 @@ pub struct PmInitiateEvent { } /// Indicates a message received by the room from another session. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SendEvent(pub Message); /// Indicates that a session has successfully joined a room. /// /// It also offers a snapshot of the room’s state and recent history. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SnapshotEvent { /// The id of the agent or account logged into this session. diff --git a/src/api/packet.rs b/src/api/packet.rs deleted file mode 100644 index 7752a53..0000000 --- a/src/api/packet.rs +++ /dev/null @@ -1,223 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use super::PacketType; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Packet { - pub id: Option, - pub r#type: PacketType, - pub data: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub throttled: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub throttled_reason: Option, -} - -pub trait Command { - type Reply; -} - -macro_rules! packets { - ( $( $name:ident, )*) => { - #[derive(Debug, Clone)] - #[non_exhaustive] - pub enum Data { - $( $name(super::$name), )* - Unimplemented, - } - - impl Data { - 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, - }) - } - - pub fn into_value(self) -> serde_json::Result { - Ok(match self{ - $( Self::$name(p) => serde_json::to_value(p)?, )* - Self::Unimplemented => panic!("using unimplemented data"), - }) - } - - pub fn packet_type(&self) -> PacketType { - match self { - $( Self::$name(_) => PacketType::$name, )* - Self::Unimplemented => panic!("using unimplemented data"), - } - } - } - - $( - impl From for Data { - fn from(p: super::$name) -> Self { - Self::$name(p) - } - } - - impl TryFrom for super::$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 - BounceEvent, - DisconnectEvent, - HelloEvent, - JoinEvent, - LoginEvent, - LogoutEvent, - NetworkEvent, - NickEvent, - EditMessageEvent, - PartEvent, - PingEvent, - PmInitiateEvent, - SendEvent, - SnapshotEvent, - // 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, -} - -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, -} - -#[derive(Debug, Clone)] -pub struct ParsedPacket { - pub id: Option, - pub r#type: PacketType, - pub content: Result, - pub throttled: Option, -} - -impl ParsedPacket { - 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, - }) - } - - 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, - }, - }) - } -} diff --git a/src/api/packets.rs b/src/api/packets.rs new file mode 100644 index 0000000..6c07b6c --- /dev/null +++ b/src/api/packets.rs @@ -0,0 +1,294 @@ +//! 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 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 [`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() + } +} diff --git a/src/api/room_cmds.rs b/src/api/room_cmds.rs index 0a2d553..f02d507 100644 --- a/src/api/room_cmds.rs +++ b/src/api/room_cmds.rs @@ -1,13 +1,17 @@ -//! Chat room commands. +//! Models [chat room commands][0] and their replies. //! //! These commands are available to the client once a session successfully joins //! a room. +//! +//! [0]: https://euphoria.leet.nu/heim/api#chat-room-commands use serde::{Deserialize, Serialize}; use super::{Message, MessageId, PmId, SessionId, SessionView, UserId}; /// Retrieve the full content of a single message in the room. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetMessage { /// The id of the message to retrieve. @@ -23,6 +27,8 @@ pub struct GetMessageReply(pub Message); /// This can be used to supplement the log provided by /// [`SnapshotEvent`](super::SnapshotEvent) (for example, when scrolling back /// further in history). +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Log { /// Maximum number of messages to return (up to 1000). @@ -44,6 +50,8 @@ pub struct LogReply { /// /// This name applies to all messages sent during this session, until the nick /// command is called again. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Nick { /// The requested name (maximum length 36 bytes). @@ -68,6 +76,8 @@ pub struct NickReply { /// Constructs a virtual room for private messaging between the client and the /// given [`UserId`]. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PmInitiate { /// The id of the user to invite to chat privately. @@ -94,6 +104,8 @@ pub struct PmInitiateReply { /// The caller of this command will not receive the corresponding /// [`SendEvent`](super::SendEvent), but will receive the same information in /// the [`SendReply`]. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Send { /// The content of the message (client-defined). @@ -109,6 +121,8 @@ pub struct Send { pub struct SendReply(pub Message); /// Request a list of sessions currently joined in the room. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Who {} diff --git a/src/api/session_cmds.rs b/src/api/session_cmds.rs index 4dab0d4..e60a76c 100644 --- a/src/api/session_cmds.rs +++ b/src/api/session_cmds.rs @@ -1,7 +1,9 @@ -//! Session commands. +//! Models [session commands][0] and their replies. //! //! Session management commands are involved in the initial handshake and //! maintenance of a session. +//! +//! [0]: https://euphoria.leet.nu/heim/api#session-commands use serde::{Deserialize, Serialize}; @@ -11,6 +13,8 @@ use super::{AuthOption, Time}; /// /// This should be sent in response to a [`BounceEvent`](super::BounceEvent) at /// the beginning of a session. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Auth { /// The method of authentication. @@ -32,6 +36,8 @@ pub struct AuthReply { /// /// The server will send back a [`PingReply`] with the same timestamp as soon as /// possible. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ping { /// An arbitrary value, intended to be a unix timestamp. diff --git a/src/api/types.rs b/src/api/types.rs index b1408a8..af5426b 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -1,20 +1,16 @@ -//! Field types. +//! Models the [field types][0]. +//! +//! [0]: https://euphoria.leet.nu/heim/api#field-types -// TODO Add newtype wrappers for different kinds of IDs? - -// Serde's derive macros generate this warning and I can't turn it off locally, -// so I'm turning it off for the entire module. -#![allow(clippy::use_self)] - -use std::num::ParseIntError; -use std::str::FromStr; -use std::{error, fmt}; +use std::{error, fmt, num::ParseIntError, str::FromStr}; use jiff::Timestamp; use serde::{de, ser, Deserialize, Serialize}; use serde_json::Value; /// Describes an account and its preferred name. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AccountView { /// The id of the account. @@ -24,6 +20,8 @@ pub struct AccountView { } /// Mode of authentication. +/// +/// #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AuthOption { @@ -36,6 +34,8 @@ pub enum AuthOption { /// /// It corresponds to a chat message, or a post, or any broadcasted event in a /// room that should appear in the log. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Message { /// The id of the message (unique within a room). @@ -72,6 +72,8 @@ pub struct Message { /// The type of a packet. /// /// Not all of these types have their corresponding data modeled as a struct. +/// +/// #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum PacketType { @@ -250,6 +252,8 @@ impl fmt::Display for PacketType { } /// Describes an account to its owner. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersonalAccountView { /// The id of the account. @@ -261,6 +265,8 @@ pub struct PersonalAccountView { } /// Describes a session and its identity. +/// +/// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionView { /// The id of an agent or account (or bot). @@ -290,6 +296,8 @@ pub struct SessionView { /// A 13-character string, usually used as aunique identifier for some type of object. /// /// It is the base-36 encoding of an unsigned, 64-bit integer. +/// +/// #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)] pub struct Snowflake(pub u64); @@ -307,7 +315,7 @@ impl Snowflake { /// representation of message ids to suddenly use the upper parts of the /// range, and since message ids mostly consist of a timestamp, this /// approach should last until at least 2075. - pub const MAX: Self = Snowflake(i64::MAX as u64); + pub const MAX: Self = Self(i64::MAX as u64); } impl fmt::Display for Snowflake { @@ -324,6 +332,7 @@ impl fmt::Display for Snowflake { } } +/// An error that occurred while parsing a [`Snowflake`]. #[derive(Debug)] pub enum ParseSnowflakeError { InvalidLength(usize), @@ -365,7 +374,7 @@ impl FromStr for Snowflake { return Err(ParseSnowflakeError::InvalidLength(s.len())); } let n = u64::from_str_radix(s, 36)?; - Ok(Snowflake(n)) + Ok(Self(n)) } } @@ -402,6 +411,8 @@ impl<'de> Deserialize<'de> for Snowflake { /// Time is specified as a signed 64-bit integer, giving the number of seconds /// since the Unix Epoch. +/// +/// #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct Time(pub i64); @@ -426,6 +437,8 @@ impl Time { /// /// It is possible for this value to have no prefix and colon, and there is no /// fixed format for the unique value. +/// +/// #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub struct UserId(pub String); @@ -435,21 +448,27 @@ impl fmt::Display for UserId { } } +/// What kind of user a [`UserId`] is. #[derive(Debug, PartialEq, Eq)] -pub enum SessionType { +pub enum UserType { Agent, Account, Bot, } impl UserId { - pub fn session_type(&self) -> Option { + /// Retrieve the [`UserType`] of this user. + /// + /// This method can return [`None`] because user IDs used to have no + /// associated type. Such user IDs can still occur in old room logs, so + /// euphoxide supports them. + pub fn user_type(&self) -> Option { if self.0.starts_with("agent:") { - Some(SessionType::Agent) + Some(UserType::Agent) } else if self.0.starts_with("account:") { - Some(SessionType::Account) + Some(UserType::Account) } else if self.0.starts_with("bot:") { - Some(SessionType::Bot) + Some(UserType::Bot) } else { None } diff --git a/src/lib.rs b/src/lib.rs index adb9a54..e700633 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod api; mod emoji; pub use crate::emoji::Emoji; From 55d672cddb9ef87ce3adf2909e0dcb63e3f34e87 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 5 Dec 2024 14:01:55 +0100 Subject: [PATCH 04/32] Refactor and document nick module --- Cargo.toml | 2 ++ src/lib.rs | 1 + src/nick.rs | 17 ++++++++++------- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9a948f3..b909a94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,11 @@ version = "0.5.1" edition = "2021" [dependencies] +caseless = "0.2.1" jiff = { version = "0.1.15", default-features = false, features = ["std"] } serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" +unicode-normalization = "0.1.24" [lints] rust.unsafe_code = { level = "forbid", priority = 1 } diff --git a/src/lib.rs b/src/lib.rs index e700633..17942a6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; mod emoji; +pub mod nick; pub use crate::emoji::Emoji; diff --git a/src/nick.rs b/src/nick.rs index 03ada70..75a546e 100644 --- a/src/nick.rs +++ b/src/nick.rs @@ -69,17 +69,18 @@ pub fn hue(emoji: &Emoji, nick: &str) -> u8 { /// 1. Apply [`mention`] /// 2. Convert to NFKC /// 3. Case fold +/// 4. Convert to NFC /// -/// Steps 2 and 3 are meant to be an alternative to the NKFC_Casefold derived -/// property that's easier to implement, even though it may be incorrect in some -/// edge cases. -/// -/// [0]: https://github.com/CylonicRaider/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14 +/// Steps 2 to 4 are meant to emulate NKFC_Casefold, but may differ in some edge +/// cases. Most notably, they don't ignore default ignorable code points. Maybe +/// there are also other edge cases I don't know about. pub fn normalize(nick: &str) -> String { mention(nick) // Step 1 .nfkc() // Step 2 .default_case_fold() // Step 3 - .collect() + .collect::() + .nfc() // Step 4 + .collect::() } fn is_non_whitespace_delimiter(c: char) -> bool { @@ -96,12 +97,14 @@ fn is_non_whitespace_delimiter(c: char) -> bool { /// highlight as a mention in the official euphoria client. It should ping any /// people using the original nick. It might also ping other people. /// -/// In the official euphoria client, mentions are non-whitespace characters +/// [In the official euphoria client][0], mentions are non-whitespace characters /// delimited by whitespace and any of the following characters: /// /// `,`, `.`, `!`, `?`, `;`, `&`, `<`, `>`, `'`, `"`. /// /// The first character of a mention may be a delimiting character. +/// +/// [0]: https://github.com/CylonicRaider/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/stores/chat.js#L14 pub fn mention(nick: &str) -> String { let mut nick = nick.chars().filter(|c| !c.is_whitespace()); let mut result = String::new(); From bcedd3350dc871ff5df5a2b2ff2b178feaeea681 Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 5 Dec 2024 14:09:01 +0100 Subject: [PATCH 05/32] Refactor replies module --- Cargo.toml | 1 + src/lib.rs | 1 + src/replies.rs | 16 ++++------------ 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b909a94..2bf0e7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ caseless = "0.2.1" jiff = { version = "0.1.15", default-features = false, features = ["std"] } serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" +tokio = { version = "1.42.0", features = ["sync", "time"] } unicode-normalization = "0.1.24" [lints] diff --git a/src/lib.rs b/src/lib.rs index 17942a6..2de0a32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod api; mod emoji; pub mod nick; +mod replies; pub use crate::emoji::Emoji; diff --git a/src/replies.rs b/src/replies.rs index db00995..9c751fe 100644 --- a/src/replies.rs +++ b/src/replies.rs @@ -1,10 +1,6 @@ -use std::collections::HashMap; -use std::fmt; -use std::hash::Hash; -use std::time::Duration; -use std::{error, result}; +use std::{collections::HashMap, error, fmt, hash::Hash, result, time::Duration}; -use tokio::sync::oneshot::{self, Receiver, Sender}; +use tokio::sync::oneshot; #[derive(Debug)] pub enum Error { @@ -28,7 +24,7 @@ pub type Result = result::Result; #[derive(Debug)] pub struct PendingReply { timeout: Duration, - result: Receiver, + result: oneshot::Receiver, } impl PendingReply { @@ -44,7 +40,7 @@ impl PendingReply { #[derive(Debug)] pub struct Replies { timeout: Duration, - pending: HashMap>, + pending: HashMap>, } impl Replies { @@ -55,10 +51,6 @@ impl Replies { } } - pub fn timeout(&self) -> Duration { - self.timeout - } - pub fn wait_for(&mut self, id: I) -> PendingReply where I: Eq + Hash, From 6f0e08350ecb48a83f5c485aa701f80a729fc9bf Mon Sep 17 00:00:00 2001 From: Joscha Date: Thu, 5 Dec 2024 20:01:34 +0100 Subject: [PATCH 06/32] Refactor basic euphoria connection --- Cargo.toml | 5 +- src/api/packets.rs | 11 + src/conn.rs | 776 ++++++++++++++------------------------------- src/lib.rs | 1 + 4 files changed, 257 insertions(+), 536 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2bf0e7f..d633cb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,13 @@ edition = "2021" [dependencies] caseless = "0.2.1" +futures-util = "0.3.31" jiff = { version = "0.1.15", default-features = false, features = ["std"] } +log = "0.4.22" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" -tokio = { version = "1.42.0", features = ["sync", "time"] } +tokio = { version = "1.42.0", features = ["macros", "sync", "time"] } +tokio-tungstenite = "0.24.0" unicode-normalization = "0.1.24" [lints] diff --git a/src/api/packets.rs b/src/api/packets.rs index 6c07b6c..210ddc5 100644 --- a/src/api/packets.rs +++ b/src/api/packets.rs @@ -216,6 +216,17 @@ pub struct ParsedPacket { } 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, + } + } + /// Convert a [`Packet`] into a [`ParsedPacket`]. /// /// This method may fail if the packet data is invalid. diff --git a/src/conn.rs b/src/conn.rs index 6d88827..315468b 100644 --- a/src/conn.rs +++ b/src/conn.rs @@ -1,361 +1,101 @@ -//! Connection state modeling. +//! Basic connection between client and server. -use std::collections::HashMap; -use std::convert::Infallible; -use std::future::Future; -use std::time::{Duration, Instant}; -use std::{error, fmt, result}; +use std::{error, fmt, result, time::Duration}; -use futures_util::SinkExt; +use futures_util::{SinkExt, StreamExt}; use jiff::Timestamp; use log::debug; -use tokio::net::TcpStream; -use tokio::select; -use tokio::sync::{mpsc, oneshot}; -use tokio_stream::StreamExt; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::{header, HeaderValue}; -use tokio_tungstenite::{tungstenite, MaybeTlsStream, WebSocketStream}; - -use crate::api::packet::{Command, ParsedPacket}; -use crate::api::{ - BounceEvent, Data, HelloEvent, LoginReply, NickEvent, PersonalAccountView, Ping, PingReply, - SessionId, SessionView, SnapshotEvent, Time, UserId, +use tokio::{ + net::TcpStream, + select, + time::{self, Instant}, +}; +use tokio_tungstenite::{ + tungstenite::{self, client::IntoClientRequest, handshake::client::Response, Message}, + MaybeTlsStream, WebSocketStream, }; -use crate::replies::{self, PendingReply, Replies}; -pub type WsStream = WebSocketStream>; +use crate::api::{Data, Packet, PacketType, ParsedPacket, Ping, PingEvent, PingReply, Time}; +/// An error that can occur while using an [`EuphConn`]. #[derive(Debug)] pub enum Error { - /// The connection is now closed. + /// The connection is closed. ConnectionClosed, - /// The connection was not opened in time. - ConnectionTimedOut, - /// The server didn't reply to one of our commands in time. - CommandTimedOut, - /// The server did something that violated the api specification. - ProtocolViolation(&'static str), - /// An error returned by the euphoria server. - Euph(String), + + /// A ping was not replied to in time. + PingTimeout, + + /// A packet was not sent because it was malformed. + MalformedPacket(serde_json::Error), + + /// A malformed packet was received. + ReceivedMalformedPacket(serde_json::Error), + + /// A binary message was received. + ReceivedBinaryMessage, Tungstenite(tungstenite::Error), - SerdeJson(serde_json::Error), } impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::ConnectionClosed => write!(f, "connection closed"), - Self::ConnectionTimedOut => write!(f, "connection did not open in time"), - Self::CommandTimedOut => write!(f, "server did not reply to command in time"), - Self::ProtocolViolation(msg) => write!(f, "{msg}"), - Self::Euph(msg) => write!(f, "{msg}"), + Self::PingTimeout => write!(f, "ping timed out"), + Self::MalformedPacket(err) => write!(f, "malformed packet: {err}"), + Self::ReceivedMalformedPacket(err) => write!(f, "received malformed packet: {err}"), + Self::ReceivedBinaryMessage => write!(f, "received binary message"), Self::Tungstenite(err) => write!(f, "{err}"), - Self::SerdeJson(err) => write!(f, "{err}"), } } } +impl error::Error for Error {} + impl From for Error { fn from(err: tungstenite::Error) -> Self { Self::Tungstenite(err) } } -impl From for Error { - fn from(err: serde_json::Error) -> Self { - Self::SerdeJson(err) - } -} - -impl error::Error for Error {} - +/// An alias of [`Result`](result::Result) for [`Error`]. pub type Result = result::Result; -#[derive(Debug, Clone)] -pub struct Joining { - pub since: Timestamp, - pub hello: Option, - pub snapshot: Option, - pub bounce: Option, +/// 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, } -impl Joining { - fn new() -> Self { +/// Configuration options for a [`Conn`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConnConfig { + /// How long to wait in-between pings. + pub ping_interval: Duration, +} + +impl Default for ConnConfig { + fn default() -> Self { Self { - since: Timestamp::now(), - hello: None, - snapshot: None, - bounce: None, - } - } - - fn on_data(&mut self, data: &Data) -> Result<()> { - 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()), - // TODO Check and maybe expand list of unexpected packet types - Data::JoinEvent(_) - | Data::NetworkEvent(_) - | Data::NickEvent(_) - | Data::EditMessageEvent(_) - | Data::PartEvent(_) - | Data::PmInitiateEvent(_) - | Data::SendEvent(_) => return Err(Error::ProtocolViolation("unexpected packet type")), - _ => {} - } - Ok(()) - } - - fn joined(&self) -> Option { - if let (Some(hello), Some(snapshot)) = (&self.hello, &self.snapshot) { - 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, - }) - } else { - None + ping_interval: Duration::from_secs(30), } } } -#[derive(Debug, Clone)] -pub enum SessionInfo { - Full(SessionView), - Partial(NickEvent), -} - -impl SessionInfo { - pub fn id(&self) -> &UserId { - match self { - Self::Full(sess) => &sess.id, - Self::Partial(nick) => &nick.id, - } - } - - pub fn session_id(&self) -> &SessionId { - match self { - Self::Full(sess) => &sess.session_id, - Self::Partial(nick) => &nick.session_id, - } - } - - pub fn name(&self) -> &str { - match self { - Self::Full(sess) => &sess.name, - Self::Partial(nick) => &nick.to, - } - } -} - -#[derive(Debug, Clone)] -pub struct Joined { - pub since: Timestamp, - pub session: SessionView, - pub account: Option, - 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::SendEvent(p) => { - debug!("Updating listing after send-event"); - self.listing.insert( - p.0.sender.session_id.clone(), - SessionInfo::Full(p.0.sender.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::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(); - } - // The who reply is broken and can't be trusted right now, so we'll - // not even look at it. - _ => {} - } - } -} - -#[derive(Debug, Clone)] -#[allow(clippy::large_enum_variant)] -pub enum State { - Joining(Joining), - Joined(Joined), -} - -impl State { - pub fn into_joining(self) -> Option { - match self { - Self::Joining(joining) => Some(joining), - Self::Joined(_) => None, - } - } - - pub fn into_joined(self) -> Option { - match self { - Self::Joining(_) => None, - Self::Joined(joined) => Some(joined), - } - } - - pub fn joining(&self) -> Option<&Joining> { - match self { - Self::Joining(joining) => Some(joining), - Self::Joined(_) => None, - } - } - - pub fn joined(&self) -> Option<&Joined> { - match self { - Self::Joining(_) => None, - Self::Joined(joined) => Some(joined), - } - } -} - -#[allow(clippy::large_enum_variant)] -enum ConnCommand { - SendCmd(Data, oneshot::Sender>), - GetState(oneshot::Sender), -} - -#[derive(Debug, Clone)] -pub struct ConnTx { - cmd_tx: mpsc::UnboundedSender, -} - -impl ConnTx { - /// The async part of sending a command. - /// - /// This is split into a separate function so that [`Self::send`] can be - /// fully synchronous (you can safely throw away the returned future) while - /// still guaranteeing that the packet was sent. - async fn finish_send(rx: oneshot::Receiver>) -> Result - where - C: Command, - C::Reply: TryFrom, - { - let pending_reply = rx - .await - // This should only happen if something goes wrong during encoding - // of the packet or while sending it through the websocket. Assuming - // the first doesn't happen, the connection is probably closed. - .map_err(|_| Error::ConnectionClosed)?; - - let data = pending_reply - .get() - .await - .map_err(|e| match e { - replies::Error::TimedOut => Error::CommandTimedOut, - replies::Error::Canceled => Error::ConnectionClosed, - })? - .content - .map_err(Error::Euph)?; - - data.try_into() - .map_err(|_| Error::ProtocolViolation("incorrect command reply type")) - } - - /// Send a command to the server. - /// - /// Returns a future containing the server's reply. This future does not - /// have to be awaited and can be safely ignored if you are not interested - /// in the reply. - /// - /// This function may return before the command was sent. To ensure that it - /// was sent before doing something else, await the returned future first. - /// - /// When called multiple times, this function guarantees that the commands - /// are sent in the order that the function is called. - pub fn send(&self, cmd: C) -> impl Future> - where - C: Command + Into, - C::Reply: TryFrom, - { - let (tx, rx) = oneshot::channel(); - let _ = self.cmd_tx.send(ConnCommand::SendCmd(cmd.into(), tx)); - Self::finish_send::(rx) - } - - /// Like [`Self::send`] but ignoring the server's reply. - pub fn send_only>(&self, cmd: C) { - let (tx, _) = oneshot::channel(); - let _ = self.cmd_tx.send(ConnCommand::SendCmd(cmd.into(), tx)); - } - - pub async fn state(&self) -> Result { - let (tx, rx) = oneshot::channel(); - self.cmd_tx - .send(ConnCommand::GetState(tx)) - .map_err(|_| Error::ConnectionClosed)?; - rx.await.map_err(|_| Error::ConnectionClosed) - } -} - -#[derive(Debug)] +/// 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: WsStream, - last_id: usize, - replies: Replies, - - conn_tx: ConnTx, - cmd_rx: mpsc::UnboundedReceiver, + 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 @@ -365,161 +105,136 @@ pub struct Conn { last_ws_ping_replied_to: bool, last_euph_ping_payload: Option