diff --git a/Cargo.toml b/Cargo.toml
index 3dcef6f..64caf79 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,3 +4,16 @@ version = "0.1.0"
edition = "2021"
[dependencies]
+anyhow = "1.0.62" # TODO Remove
+futures = "0.3.23"
+log = "0.4.17" # TODO Remove
+rand = "0.8.5"
+serde = { version = "1.0.143", features = ["derive"] }
+serde_json = "1.0.83"
+thiserror = "1.0.32"
+time = { version = "0.3.13", features = ["serde"] }
+tokio = { version = "1.20.1", features = ["time", "sync", "macros", "rt"] }
+
+[dependencies.tokio-tungstenite]
+version = "0.17.2"
+features = ["rustls-tls-native-roots"]
diff --git a/src/api.rs b/src/api.rs
new file mode 100644
index 0000000..95f76c7
--- /dev/null
+++ b/src/api.rs
@@ -0,0 +1,13 @@
+//! Models the euphoria API at .
+
+mod events;
+pub mod packet;
+mod room_cmds;
+mod session_cmds;
+mod types;
+
+pub use events::*;
+pub use packet::Data;
+pub use room_cmds::*;
+pub use session_cmds::*;
+pub use types::*;
diff --git a/src/api/events.rs b/src/api/events.rs
new file mode 100644
index 0000000..1d79b03
--- /dev/null
+++ b/src/api/events.rs
@@ -0,0 +1,170 @@
+//! Asynchronous events.
+
+use serde::{Deserialize, Serialize};
+
+use super::{AuthOption, Message, PersonalAccountView, SessionView, Snowflake, Time, UserId};
+
+/// Indicates that access to a room is denied.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BounceEvent {
+ /// The reason why access was denied.
+ pub reason: Option,
+ /// Authentication options that may be used.
+ pub auth_options: Option>,
+ /// Internal use only.
+ pub agent_id: Option,
+ /// Internal use only.
+ pub ip: Option,
+}
+
+/// Indicates that the session is being closed. The client will subsequently be
+/// disconnected.
+///
+/// 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,
+}
+
+/// 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.
+ pub id: UserId,
+ /// Details about the user's account, if the session is logged in.
+ pub account: Option,
+ /// Details about the session.
+ pub session: SessionView,
+ /// If true, then the account has an explicit access grant to the current
+ /// room.
+ pub account_has_access: Option,
+ /// Whether the account's email address has been verified.
+ pub account_email_verified: Option,
+ /// If true, the session is connected to a private room.
+ pub room_is_private: bool,
+ /// The version of the code being run and served by the server.
+ pub version: String,
+}
+
+/// 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: Snowflake,
+}
+
+/// 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;
+
+/// Indicates some server-side event that impacts the presence of sessions in a
+/// room.
+///
+/// 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`.
+ pub r#type: String,
+ /// The id of the affected server.
+ pub server_id: String,
+ /// The era of the affected server.
+ pub server_era: String,
+}
+
+/// 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.
+ pub session_id: String,
+ /// The id of the agent or account logged into the session.
+ pub id: UserId,
+ /// The previous name associated with the session.
+ pub from: String,
+ /// The name associated with the session henceforth.
+ 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);
+
+/// Represents a server-to-client ping.
+///
+/// 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.
+ pub time: Time,
+ /// The expected time of the next ping event, according to the server's
+ /// clock.
+ pub next: Time,
+}
+
+/// 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.
+ pub from: UserId,
+ /// The nick of the inviting user.
+ pub from_nick: String,
+ /// The room where the invitation was sent from.
+ pub from_room: String,
+ /// The private chat can be accessed at `/room/pm:`.
+ pub pm_id: Snowflake,
+}
+
+/// 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.
+ pub identity: UserId,
+ /// The globally unique id of this session.
+ pub session_id: String,
+ /// The server’s version identifier.
+ pub version: String,
+ /// The list of all other sessions joined to the room (excluding this
+ /// session).
+ pub listing: Vec,
+ /// The most recent messages posted to the room (currently up to 100).
+ pub log: Vec,
+ /// The acting nick of the session; if omitted, client set nick before
+ /// speaking.
+ pub nick: Option,
+ /// If given, this room is for private chat with the given nick.
+ pub pm_with_nick: Option,
+ /// If given, this room is for private chat with the given user.
+ pub pm_with_user_id: Option,
+}
diff --git a/src/api/packet.rs b/src/api/packet.rs
new file mode 100644
index 0000000..e69de56
--- /dev/null
+++ b/src/api/packet.rs
@@ -0,0 +1,192 @@
+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)]
+ pub error: Option,
+ #[serde(default, skip_serializing)]
+ pub throttled: bool,
+ #[serde(skip_serializing)]
+ 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! {
+ BounceEvent,
+ DisconnectEvent,
+ HelloEvent,
+ JoinEvent,
+ LoginEvent,
+ LogoutEvent,
+ NetworkEvent,
+ NickEvent,
+ EditMessageEvent,
+ PartEvent,
+ PingEvent,
+ PmInitiateEvent,
+ SendEvent,
+ SnapshotEvent,
+ Auth,
+ AuthReply,
+ Ping,
+ PingReply,
+ GetMessage,
+ GetMessageReply,
+ Log,
+ LogReply,
+ Nick,
+ NickReply,
+ PmInitiate,
+ PmInitiateReply,
+ Send,
+ SendReply,
+ Who,
+ WhoReply,
+}
+
+commands! {
+ Auth => AuthReply,
+ Ping => PingReply,
+ GetMessage => GetMessageReply,
+ Log => LogReply,
+ Nick => NickReply,
+ PmInitiate => PmInitiateReply,
+ Send => SendReply,
+ Who => WhoReply,
+}
+
+#[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/room_cmds.rs b/src/api/room_cmds.rs
new file mode 100644
index 0000000..02dddd4
--- /dev/null
+++ b/src/api/room_cmds.rs
@@ -0,0 +1,116 @@
+//! Chat room commands.
+
+use serde::{Deserialize, Serialize};
+
+use super::{Message, SessionView, Snowflake, 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.
+ pub id: Snowflake,
+}
+
+/// The message retrieved by [`GetMessage`].
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct GetMessageReply(pub Message);
+
+/// Request messages from the room's message log.
+///
+/// This can be used to supplement the log provided by snapshot-event (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).
+ pub n: usize,
+ /// Return messages prior to this snowflake.
+ pub before: Option,
+}
+
+/// List of messages from the room's message log.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LogReply {
+ /// List of messages returned.
+ pub log: Vec,
+ /// Messages prior to this snowflake were returned.
+ pub before: Option,
+}
+
+/// Set the name you present to the room.
+///
+/// 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).
+ pub name: String,
+}
+
+/// Confirms the [`Nick`] command.
+///
+/// Returns the session's former and new names (the server may modify the
+/// requested nick).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NickReply {
+ /// The id of the session this name applies to.
+ pub session_id: String,
+ /// The id of the agent or account logged into the session.
+ pub id: UserId,
+ /// The previous name associated with the session.
+ pub from: String,
+ /// The name associated with the session henceforth.
+ pub to: String,
+}
+
+/// 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.
+ pub user_id: UserId,
+}
+
+/// Provides the PMID for the requested private messaging room.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PmInitiateReply {
+ /// The private chat can be accessed at `/room/pm:`.
+ pub pm_id: Snowflake,
+ /// The nickname of the recipient of the invitation.
+ pub to_nick: String,
+}
+
+/// Send a message to a room.
+///
+/// The session must be successfully joined with the room. This message will be
+/// broadcast to all sessions joined with the room.
+///
+/// If the room is private, then the message content will be encrypted before it
+/// is stored and broadcast to the rest of the room.
+///
+/// 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).
+ pub content: String,
+ /// The id of the parent message, if any.
+ pub parent: Option,
+}
+
+/// The message that was sent.
+///
+/// this includes the message id, which was populated by the server.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SendReply(pub Message);
+
+/// Request a list of sessions currently joined in the room.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Who;
+
+/// Lists the sessions currently joined in the room.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WhoReply {
+ /// A list of session views.
+ listing: Vec,
+}
diff --git a/src/api/session_cmds.rs b/src/api/session_cmds.rs
new file mode 100644
index 0000000..e169f7d
--- /dev/null
+++ b/src/api/session_cmds.rs
@@ -0,0 +1,43 @@
+//! Session commands.
+
+use serde::{Deserialize, Serialize};
+
+use super::{AuthOption, Time};
+
+/// Attempt to join a private room.
+///
+/// This should be sent in response to a bounce event at the beginning of a
+/// session.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Auth {
+ /// The method of authentication.
+ pub r#type: AuthOption,
+ /// Use this field for [`AuthOption::Passcode`] authentication.
+ pub passcode: Option,
+}
+
+/// Reports whether the [`Auth`] command succeeded.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct AuthReply {
+ /// True if authentication succeeded.
+ pub success: bool,
+ /// If [`Self::success`] was false, the reason for failure.
+ pub reason: Option,
+}
+
+/// Initiate a client-to-server ping.
+///
+/// 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.
+ pub time: Time,
+}
+
+/// Response to a [`Ping`] command or [`PingEvent`](super::PingEvent).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct PingReply {
+ /// The timestamp of the ping being replied to.
+ pub time: Option