From 45f2c64c8ec1562d40fbc5174a7f3da3c1e28a5c Mon Sep 17 00:00:00 2001 From: Joscha Date: Fri, 6 Dec 2024 12:07:41 +0100 Subject: [PATCH] Model client connection state --- src/api/events.rs | 2 +- src/api/room_cmds.rs | 2 +- src/client.rs | 3 + src/client/state.rs | 307 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 5 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 src/client.rs create mode 100644 src/client/state.rs diff --git a/src/api/events.rs b/src/api/events.rs index 9729e93..b46dcb7 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -188,7 +188,7 @@ pub struct SnapshotEvent { pub session_id: SessionId, /// The server’s version identifier. pub version: String, - /// The list of all other sessions joined to the room (excluding this + /// The list of all other sessions joined to the room (excluding our /// session). pub listing: Vec, /// The most recent messages posted to the room (currently up to 100). diff --git a/src/api/room_cmds.rs b/src/api/room_cmds.rs index f02d507..f961af0 100644 --- a/src/api/room_cmds.rs +++ b/src/api/room_cmds.rs @@ -129,6 +129,6 @@ pub struct Who {} /// Lists the sessions currently joined in the room. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WhoReply { - /// A list of session views. + /// A list of session views (including our session). pub listing: Vec, } diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..ed10083 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,3 @@ +//! Connecting to a server as a client. + +pub mod state; diff --git a/src/client/state.rs b/src/client/state.rs new file mode 100644 index 0000000..a90edde --- /dev/null +++ b/src/client/state.rs @@ -0,0 +1,307 @@ +//! Models the client's connection state. + +use std::collections::HashMap; + +use jiff::Timestamp; +use log::debug; + +use crate::api::{ + BounceEvent, Data, HelloEvent, NickEvent, PersonalAccountView, SessionId, SessionView, + SnapshotEvent, UserId, +}; + +/// Information about a session in the room. +/// +/// For quite a while before finally going down altogether, the euphoria.io +/// instance had an unreliable nick list: Listings returned by the server were +/// usually incomplete. Because of this, the bot library uses any observable +/// action by a session (including nick changes) to update the listing. Since +/// nick events don't include full session info though, the [`SessionInfo`] enum +/// can contain partial information. +/// +/// This level of paranioa probably isn't required any more now that the only +/// euphoria instance is working correctly. However, the code already works and +/// users who don't want to worry about it can just ignore partial session +/// infos. +#[derive(Debug, Clone)] +pub enum SessionInfo { + Full(SessionView), + Partial(NickEvent), +} + +impl SessionInfo { + /// Retrieve the user id of the session. + pub fn id(&self) -> &UserId { + match self { + Self::Full(sess) => &sess.id, + Self::Partial(nick) => &nick.id, + } + } + + /// Retrieve the session id of the session. + pub fn session_id(&self) -> &SessionId { + match self { + Self::Full(sess) => &sess.session_id, + Self::Partial(nick) => &nick.session_id, + } + } + + /// Retrieve the user name of the session. + pub fn name(&self) -> &str { + match self { + Self::Full(sess) => &sess.name, + Self::Partial(nick) => &nick.to, + } + } +} + +impl From for SessionInfo { + fn from(value: SessionView) -> Self { + Self::Full(value) + } +} + +impl From for SessionInfo { + fn from(value: NickEvent) -> Self { + Self::Partial(value) + } +} + +/// The state of the connection before the client has joined the room. +/// +/// Depending on the room, the client may need to authenticate or log in in +/// order to join. +#[derive(Debug, Clone)] +pub struct Joining { + /// Since when the connection has been in this state. + pub since: Timestamp, + /// A [`HelloEvent`], if one has been received. + /// + /// Contains information about the client's own session. + pub hello: Option, + /// A [`SnapshotEvent`], if one has been received. + pub snapshot: Option, + /// A [`BounceEvent`], if one has been received. + pub bounce: Option, +} + +impl Joining { + fn new() -> Self { + Self { + since: Timestamp::now(), + hello: None, + snapshot: None, + bounce: None, + } + } + + fn on_data(&mut self, data: &Data) { + match data { + Data::BounceEvent(p) => self.bounce = Some(p.clone()), + Data::HelloEvent(p) => self.hello = Some(p.clone()), + Data::SnapshotEvent(p) => self.snapshot = Some(p.clone()), + _ => {} + } + } + + fn to_joined(&self) -> Option { + let hello = self.hello.as_ref()?; + let snapshot = self.snapshot.as_ref()?; + + let mut session = hello.session.clone(); + + if let Some(nick) = &snapshot.nick { + session.name = nick.clone(); + } + + let listing = snapshot + .listing + .iter() + .cloned() + .map(|s| (s.session_id.clone(), SessionInfo::Full(s))) + .collect::>(); + + Some(Joined { + since: Timestamp::now(), + session, + account: hello.account.clone(), + listing, + }) + } +} + +/// The state of the connection after the client has successfully joined the +/// room. +/// +/// The client may need to set a nick in order to be able to send messages. +/// However, it can access the room history without nick. +#[derive(Debug, Clone)] +pub struct Joined { + /// Since when the connection has been in this state. + pub since: Timestamp, + /// The client's own session. + pub session: SessionView, + /// Account information, if the client is logged in. + pub account: Option, + /// All sessions currently connected to the room (except the client's own + /// session). + pub listing: HashMap, +} + +impl Joined { + fn on_data(&mut self, data: &Data) { + match data { + Data::JoinEvent(p) => { + debug!("Updating listing after join-event"); + self.listing + .insert(p.0.session_id.clone(), SessionInfo::Full(p.0.clone())); + } + Data::PartEvent(p) => { + debug!("Updating listing after part-event"); + self.listing.remove(&p.0.session_id); + } + Data::NetworkEvent(p) => { + if p.r#type == "partition" { + debug!("Updating listing after network-event with type partition"); + self.listing.retain(|_, s| match s { + SessionInfo::Full(s) => { + s.server_id != p.server_id && s.server_era != p.server_era + } + // We can't know if the session was disconnected by the + // partition or not, so we're erring on the side of + // caution and assuming they were kicked. If we're + // wrong, we'll re-add the session as soon as it + // performs another visible action. + // + // If we always kept such sessions, we might keep + // disconnected ones indefinitely, thereby keeping them + // from moving on, instead forever tethering them to the + // digital realm. + SessionInfo::Partial(_) => false, + }); + } + } + Data::SendEvent(p) => { + debug!("Updating listing after send-event"); + self.listing.insert( + p.0.sender.session_id.clone(), + SessionInfo::Full(p.0.sender.clone()), + ); + } + Data::NickEvent(p) => { + debug!("Updating listing after nick-event"); + self.listing + .entry(p.session_id.clone()) + .and_modify(|s| match s { + SessionInfo::Full(session) => session.name = p.to.clone(), + SessionInfo::Partial(_) => *s = SessionInfo::Partial(p.clone()), + }) + .or_insert_with(|| SessionInfo::Partial(p.clone())); + } + Data::NickReply(p) => { + debug!("Updating own session after nick-reply"); + assert_eq!(self.session.id, p.id); + self.session.name = p.to.clone(); + } + Data::WhoReply(p) => { + debug!("Updating listing after who-reply"); + self.listing.clear(); + for session in p.listing.clone() { + if session.session_id == self.session.session_id { + self.session = session; + } else { + self.listing + .insert(session.session_id.clone(), session.into()); + } + } + } + _ => {} + } + } +} + +/// The state of a connection to the server, from a client's perspective. +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +pub enum State { + /// The client has not joined the room yet. + Joining(Joining), + /// The client has successfully joined the room. + Joined(Joined), +} + +impl State { + /// Create a new state for a fresh connection. + /// + /// Assumes that no packets have been received yet. See also + /// [`Self::on_data`]. + pub fn new() -> Self { + Joining::new().into() + } + + /// If the state consists of a [`Joining`], return a reference to it. + pub fn as_joining(&self) -> Option<&Joining> { + match self { + Self::Joining(joining) => Some(joining), + Self::Joined(_) => None, + } + } + + /// If the state consists of a [`Joined`], return a reference to it. + pub fn as_joined(&self) -> Option<&Joined> { + match self { + Self::Joining(_) => None, + Self::Joined(joined) => Some(joined), + } + } + + /// If the state consists of a [`Joining`], return it. + pub fn into_joining(self) -> Option { + match self { + Self::Joining(joining) => Some(joining), + Self::Joined(_) => None, + } + } + + /// If the state consists of a [`Joined`], return it. + pub fn into_joined(self) -> Option { + match self { + Self::Joining(_) => None, + Self::Joined(joined) => Some(joined), + } + } + + /// Update the state with a packet received from the server. + /// + /// This method should be called whenever any packet is received from the + /// server. Skipping packets may cause the state to become inconsistent. + pub fn on_data(&mut self, data: &Data) { + match self { + Self::Joining(joining) => { + joining.on_data(data); + if let Some(joined) = joining.to_joined() { + *self = joined.into(); + } + } + Self::Joined(joined) => joined.on_data(data), + } + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +impl From for State { + fn from(value: Joining) -> Self { + Self::Joining(value) + } +} + +impl From for State { + fn from(value: Joined) -> Self { + Self::Joined(value) + } +} diff --git a/src/lib.rs b/src/lib.rs index 457c7d0..5b75f2e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod client; pub mod conn; mod emoji; pub mod error;