diff --git a/src/messages.rs b/src/messages.rs index d5024e7..35413f2 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -63,11 +63,11 @@ impl SsbMessageValue { /// Helper function which returns true if this message is of the given type, /// and false if the type does not match or is not found - pub fn is_message_type(&self, message_type: SsbMessageContentType) -> bool { + pub fn is_message_type(&self, _message_type: SsbMessageContentType) -> bool { let self_message_type = self.get_message_type(); match self_message_type { Ok(mtype) => { - matches!(mtype, message_type) + matches!(mtype, _message_type) } Err(_err) => false, } diff --git a/src/sbot/about.rs b/src/sbot/about.rs new file mode 100644 index 0000000..0c92feb --- /dev/null +++ b/src/sbot/about.rs @@ -0,0 +1,174 @@ +use std::collections::HashMap; + +use async_std::stream::{Stream, StreamExt}; +use futures::pin_mut; + +use crate::{ + error::GolgiError, + messages::{SsbMessageContentType, SsbMessageValue}, + sbot::sbot_connection::{Sbot, SubsetQuery, SubsetQueryOptions}, +}; + +impl Sbot { + /// Get the about messages for a particular user in order of recency. + pub async fn get_about_message_stream( + &mut self, + ssb_id: &str, + ) -> Result>, GolgiError> { + let query = SubsetQuery::Author { + op: "author".to_string(), + feed: ssb_id.to_string(), + }; + // specify that most recent messages should be returned first + let query_options = SubsetQueryOptions { + descending: Some(true), + keys: None, + page_limit: None, + }; + let get_subset_stream = self.get_subset_stream(query, Some(query_options)).await?; + // TODO: after fixing sbot regression, + // change this subset query to filter by type about in addition to author + // and remove this filter section + // filter down to about messages + let about_message_stream = get_subset_stream.filter(|msg| match msg { + Ok(val) => val.is_message_type(SsbMessageContentType::About), + Err(_err) => false, + }); + // return about message stream + Ok(about_message_stream) + } + + /// Get value of latest about message with given key from given user + pub async fn get_latest_about_message( + &mut self, + ssb_id: &str, + key: &str, + ) -> Result, GolgiError> { + // get about_message_stream + let about_message_stream = self.get_about_message_stream(ssb_id).await?; + // now we have a stream of about messages with most recent at the front of the vector + pin_mut!(about_message_stream); + // iterate through the vector looking for most recent about message with the given key + let latest_about_message_res: Option> = + about_message_stream + // find the first msg that contains the field `key` + .find(|res| match res { + Ok(msg) => msg.content.get(key).is_some(), + Err(_) => false, + }) + .await; + // Option> -> Option + let latest_about_message = latest_about_message_res.and_then(|msg| msg.ok()); + // Option -> Option + let latest_about_value = latest_about_message.and_then(|msg| { + msg + // SsbMessageValue -> Option<&Value> + .content + .get(key) + // Option<&Value> -> + .and_then(|value| value.as_str()) + // Option<&str> -> Option + .map(|value| value.to_string()) + }); + // return value is either `Ok(Some(String))` or `Ok(None)` + Ok(latest_about_value) + } + + /// Get HashMap of profile info for given user + pub async fn get_profile_info( + &mut self, + ssb_id: &str, + ) -> Result, GolgiError> { + let keys_to_search_for = vec!["name", "description", "image"]; + self.get_about_info(ssb_id, keys_to_search_for).await + } + + /// Get HashMap of name and image for given user + /// (this is can be used to display profile images of a list of users) + pub async fn get_name_and_image( + &mut self, + ssb_id: &str, + ) -> Result, GolgiError> { + let keys_to_search_for = vec!["name", "image"]; + self.get_about_info(ssb_id, keys_to_search_for).await + } + + /// Get HashMap of about keys to values for given user + /// by iteratively searching through a stream of about messages, + /// in order of recency, + /// until we find all about messages for all needed info + /// or reach the end of the stream. + /// + /// # Arguments + /// + /// * `ssb_id` - A reference to a string slice which represents the id of the user to get info about. + /// * `keys_to_search_for` - A mutable vector of string slice, which represent the about keys + /// that will be searched for. As they are found, keys are removed from the vector. + pub async fn get_about_info( + &mut self, + ssb_id: &str, + mut keys_to_search_for: Vec<&str>, + ) -> Result, GolgiError> { + // get about_message_stream + let about_message_stream = self.get_about_message_stream(ssb_id).await?; + // now we have a stream of about messages with most recent at the front of the vector + pin_mut!(about_message_stream); // needed for iteration + let mut profile_info: HashMap = HashMap::new(); + // iterate through the stream while it still has more values and + // we still have keys we are looking for + while let Some(res) = about_message_stream.next().await { + // if there are no more keys we are looking for, then we are done + if keys_to_search_for.is_empty() { + break; + } + // if there are still keys we are looking for, then continue searching + match res { + Ok(msg) => { + // for each key we are searching for, check if this about + // message contains a value for that key + for key in &keys_to_search_for.clone() { + let option_val = msg + .content + .get(key) + .and_then(|val| val.as_str()) + .map(|val| val.to_string()); + match option_val { + Some(val) => { + // if a value is found, then insert it + profile_info.insert(key.to_string(), val); + // remove this key fom keys_to_search_for, since we are no longer searching for it + keys_to_search_for.retain(|val| val != key) + } + None => continue, + } + } + } + Err(_err) => { + // skip errors + continue; + } + } + } + Ok(profile_info) + } + + /// Get latest about name from given user + /// + /// # Arguments + /// + /// * `ssb_id` - A reference to a string slice which represents the ssb user + /// to lookup the about name for. + pub async fn get_name(&mut self, ssb_id: &str) -> Result, GolgiError> { + self.get_latest_about_message(ssb_id, "name").await + } + + /// Get latest about description from given user + /// + /// # Arguments + /// + /// * `ssb_id` - A reference to a string slice which represents the ssb user + /// to lookup the about description for. + pub async fn get_description(&mut self, ssb_id: &str) -> Result, GolgiError> { + self.get_latest_about_message(ssb_id, "description").await + } +} diff --git a/src/sbot/friends.rs b/src/sbot/friends.rs new file mode 100644 index 0000000..e3605a1 --- /dev/null +++ b/src/sbot/friends.rs @@ -0,0 +1,110 @@ +use kuska_ssb::api::dto::content::{FriendsHops, RelationshipQuery}; + +use crate::{error::GolgiError, messages::SsbMessageContent, sbot::sbot_connection::Sbot, utils}; + +impl Sbot { + // Convenience method to set a relationship with following: true, blocking: false + pub async fn follow(&mut self, contact: &str) -> Result { + self.set_relationship(contact, true, false).await + } + + // Convenience method to set a relationship with following: false, blocking: true + pub async fn block(&mut self, contact: &str) -> Result { + self.set_relationship(contact, false, true).await + } + + /// Publishes a contact relationship to the given user (with ssb_id) with the given state. + pub async fn set_relationship( + &mut self, + contact: &str, + following: bool, + blocking: bool, + ) -> Result { + let msg = SsbMessageContent::Contact { + contact: Some(contact.to_string()), + following: Some(following), + blocking: Some(blocking), + autofollow: None, + }; + self.publish(msg).await + } + + /// Call the `friends isFollowing` RPC method and return a message reference. + /// Returns true if src_id is following dest_id and false otherwise. + pub async fn friends_is_following( + &mut self, + args: RelationshipQuery, + ) -> Result { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection + .client + .friends_is_following_req_send(args) + .await?; + + utils::get_async( + &mut sbot_connection.rpc_reader, + req_id, + utils::string_res_parse, + ) + .await + } + + /// Call the `friends isblocking` RPC method and return a message reference. + /// Returns true if src_id is blocking dest_id and false otherwise. + pub async fn friends_is_blocking( + &mut self, + args: RelationshipQuery, + ) -> Result { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection + .client + .friends_is_blocking_req_send(args) + .await?; + + utils::get_async( + &mut sbot_connection.rpc_reader, + req_id, + utils::string_res_parse, + ) + .await + } + + // Gets a Vec where each element is a peer you are following + pub async fn get_follows(&mut self) -> Result, GolgiError> { + self.friends_hops(FriendsHops { + max: 1, + start: None, + reverse: Some(false), + }) + .await + } + + // Gets a Vec where each element is a peer who follows you + /// TODO: currently this method is not working + /// go-sbot does not seem to listen to the reverse=True parameter + /// and just returns follows + async fn get_followers(&mut self) -> Result, GolgiError> { + self.friends_hops(FriendsHops { + max: 1, + start: None, + reverse: Some(true), + }) + .await + } + + /// Call the `friends hops` RPC method and return a Vector + /// where each element of the vector is the ssb_id of a peer. + /// + /// When opts.reverse = True, it should return peers who are following you + /// (but this is currently not working) + pub async fn friends_hops(&mut self, args: FriendsHops) -> Result, GolgiError> { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection.client.friends_hops_req_send(args).await?; + utils::get_source_until_eof( + &mut sbot_connection.rpc_reader, + req_id, + utils::string_res_parse, + ) + .await + } +} diff --git a/src/sbot/get_subset.rs b/src/sbot/get_subset.rs new file mode 100644 index 0000000..792ba0f --- /dev/null +++ b/src/sbot/get_subset.rs @@ -0,0 +1,38 @@ +use async_std::stream::Stream; + +use crate::{ + error::GolgiError, + messages::SsbMessageValue, + sbot::sbot_connection::{Sbot, SubsetQuery, SubsetQueryOptions}, + utils, + utils::get_source_stream, +}; + +impl Sbot { + /// Call the `partialReplication getSubset` RPC method + /// and return a Stream of Result + /// + /// # Arguments + /// + /// * `query` - A `SubsetQuery` which specifies what filters to use. + /// * `option` - An Option<`SubsetQueryOptions`> which, if provided, adds additional + /// specifications to the query, such as specifying page limit and/or descending. + pub async fn get_subset_stream( + &mut self, + query: SubsetQuery, + options: Option, + ) -> Result>, GolgiError> { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection + .client + .getsubset_req_send(query, options) + .await?; + let get_subset_stream = get_source_stream( + sbot_connection.rpc_reader, + req_id, + utils::ssb_message_res_parse, + ) + .await; + Ok(get_subset_stream) + } +} diff --git a/src/sbot/history_stream.rs b/src/sbot/history_stream.rs new file mode 100644 index 0000000..3497322 --- /dev/null +++ b/src/sbot/history_stream.rs @@ -0,0 +1,30 @@ +use async_std::stream::Stream; +use kuska_ssb::api::dto::CreateHistoryStreamIn; + +use crate::{ + error::GolgiError, messages::SsbMessageValue, sbot::sbot_connection::Sbot, utils, + utils::get_source_stream, +}; + +impl Sbot { + /// Call the `createHistoryStream` RPC method + /// and return a Stream of Result. + pub async fn create_history_stream( + &mut self, + id: String, + ) -> Result>, GolgiError> { + let mut sbot_connection = self.get_sbot_connection().await?; + let args = CreateHistoryStreamIn::new(id); + let req_id = sbot_connection + .client + .create_history_stream_req_send(&args) + .await?; + let history_stream = get_source_stream( + sbot_connection.rpc_reader, + req_id, + utils::ssb_message_res_parse, + ) + .await; + Ok(history_stream) + } +} diff --git a/src/sbot/invite.rs b/src/sbot/invite.rs new file mode 100644 index 0000000..8b04437 --- /dev/null +++ b/src/sbot/invite.rs @@ -0,0 +1,32 @@ +use crate::{error::GolgiError, sbot::sbot_connection::Sbot, utils}; + +impl Sbot { + /// Call the `invite create` RPC method and return the created invite + pub async fn invite_create(&mut self, uses: u16) -> Result { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection.client.invite_create_req_send(uses).await?; + + utils::get_async( + &mut sbot_connection.rpc_reader, + req_id, + utils::string_res_parse, + ) + .await + } + + /// Call the `invite use` RPC method and return a reference to the message. + pub async fn invite_use(&mut self, invite_code: &str) -> Result { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection + .client + .invite_use_req_send(invite_code) + .await?; + + utils::get_async( + &mut sbot_connection.rpc_reader, + req_id, + utils::string_res_parse, + ) + .await + } +} diff --git a/src/sbot/mod.rs b/src/sbot/mod.rs new file mode 100644 index 0000000..5de9371 --- /dev/null +++ b/src/sbot/mod.rs @@ -0,0 +1,10 @@ +/// This module contains the golgi API for interacting with a running go-sbot instance. +mod about; +mod friends; +mod get_subset; +mod history_stream; +mod invite; +mod publish; +mod sbot_connection; + +pub use sbot_connection::*; diff --git a/src/sbot/publish.rs b/src/sbot/publish.rs new file mode 100644 index 0000000..d6a8174 --- /dev/null +++ b/src/sbot/publish.rs @@ -0,0 +1,73 @@ +use crate::{error::GolgiError, messages::SsbMessageContent, sbot::sbot_connection::Sbot, utils}; + +impl Sbot { + /// Call the `publish` RPC method and return a message reference. + /// + /// # Arguments + /// + /// * `msg` - A `SsbMessageContent` `enum` whose variants include `Pub`, `Post`, `Contact`, `About`, + /// `Channel` and `Vote`. See the `kuska_ssb` documentation for further details such as field + /// names and accepted values for each variant. + pub async fn publish(&mut self, msg: SsbMessageContent) -> Result { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection.client.publish_req_send(msg).await?; + + utils::get_async( + &mut sbot_connection.rpc_reader, + req_id, + utils::string_res_parse, + ) + .await + } + + /// Wrapper for publish which constructs and publishes a post message appropriately from a string. + /// + /// # Arguments + /// + /// * `text` - A reference to a string slice which represents the text to be published in the post + pub async fn publish_post(&mut self, text: &str) -> Result { + let msg = SsbMessageContent::Post { + text: text.to_string(), + mentions: None, + }; + self.publish(msg).await + } + + /// Wrapper for publish which constructs and publishes an about description message appropriately from a string. + /// + /// # Arguments + /// + /// * `description` - A reference to a string slice which represents the text to be published as an about description. + pub async fn publish_description(&mut self, description: &str) -> Result { + let msg = SsbMessageContent::About { + about: self.id.to_string(), + name: None, + title: None, + branch: None, + image: None, + description: Some(description.to_string()), + location: None, + start_datetime: None, + }; + self.publish(msg).await + } + + /// Wrapper for publish which constructs and publishes an about name message appropriately from a string. + /// + /// # Arguments + /// + /// * `name` - A reference to a string slice which represents the text to be published as an about name. + pub async fn publish_name(&mut self, name: &str) -> Result { + let msg = SsbMessageContent::About { + about: self.id.to_string(), + name: Some(name.to_string()), + title: None, + branch: None, + image: None, + description: None, + location: None, + start_datetime: None, + }; + self.publish(msg).await + } +} diff --git a/src/sbot/sbot_connection.rs b/src/sbot/sbot_connection.rs new file mode 100644 index 0000000..9ab3807 --- /dev/null +++ b/src/sbot/sbot_connection.rs @@ -0,0 +1,137 @@ +//! Sbot type and associated methods. +use async_std::net::TcpStream; + +use kuska_handshake::async_std::BoxStream; +use kuska_sodiumoxide::crypto::{auth, sign::ed25519}; +use kuska_ssb::{ + api::ApiCaller, + discovery, keystore, + keystore::OwnedIdentity, + rpc::{RpcReader, RpcWriter}, +}; + +// re-export types from kuska +pub use kuska_ssb::api::dto::content::{FriendsHops, SubsetQuery, SubsetQueryOptions}; + +use crate::{error::GolgiError, utils}; + +/// A struct representing a connection with a running sbot. +/// A client and an rpc_reader can together be used to make requests to the sbot +/// and read the responses. +/// Note there can be multiple SbotConnection at the same time. +pub struct SbotConnection { + /// client for writing requests to go-bot + pub client: ApiCaller, + /// RpcReader object for reading responses from go-sbot + pub rpc_reader: RpcReader, +} + +/// The Scuttlebutt identity, keys and configuration parameters for connecting to a local sbot +pub struct Sbot { + pub id: String, + public_key: ed25519::PublicKey, + private_key: ed25519::SecretKey, + address: String, + // aka caps key (scuttleverse identifier) + network_id: auth::Key, +} + +impl Sbot { + /// Initiate a connection with an sbot instance. Define the IP address, port and network key + /// for the sbot, then retrieve the public key, private key (secret) and identity from the + /// `.ssb-go/secret` file. Open a TCP stream to the sbot and perform the secret handshake. If successful, create a box stream and split it into a writer and reader. Return RPC handles to the sbot as part of the `struct` output. + pub async fn connect( + ip_port: Option, + net_id: Option, + ) -> Result { + let address = if ip_port.is_none() { + "127.0.0.1:8008".to_string() + } else { + ip_port.unwrap() + }; + + let network_id = if net_id.is_none() { + discovery::ssb_net_id() + } else { + auth::Key::from_slice(&hex::decode(net_id.unwrap()).unwrap()).unwrap() + }; + + let OwnedIdentity { pk, sk, id } = keystore::from_gosbot_local() + .await + .expect("couldn't read local secret"); + + Ok(Self { + id, + public_key: pk, + private_key: sk, + address, + network_id, + }) + } + + /// Creates a new connection with the sbot, + /// using the address, network_id, public_key and private_key supplied when Sbot was initialized. + /// + /// Note that a single Sbot can have multiple SbotConnection at the same time. + pub async fn get_sbot_connection(&self) -> Result { + let address = self.address.clone(); + let network_id = self.network_id.clone(); + let public_key = self.public_key; + let private_key = self.private_key.clone(); + Sbot::_get_sbot_connection_helper(address, network_id, public_key, private_key).await + } + + /// Private helper function which creates a new connection with sbot, + /// but with all variables passed as arguments. + async fn _get_sbot_connection_helper( + address: String, + network_id: auth::Key, + public_key: ed25519::PublicKey, + private_key: ed25519::SecretKey, + ) -> Result { + let socket = TcpStream::connect(&address) + .await + .map_err(|source| GolgiError::Io { + source, + context: "socket error; failed to initiate tcp stream connection".to_string(), + })?; + + let handshake = kuska_handshake::async_std::handshake_client( + &mut &socket, + network_id.clone(), + public_key, + private_key.clone(), + public_key, + ) + .await + .map_err(GolgiError::Handshake)?; + + let (box_stream_read, box_stream_write) = + BoxStream::from_handshake(socket.clone(), socket, handshake, 0x8000).split_read_write(); + + let rpc_reader = RpcReader::new(box_stream_read); + let client = ApiCaller::new(RpcWriter::new(box_stream_write)); + let sbot_connection = SbotConnection { rpc_reader, client }; + Ok(sbot_connection) + } + + /// Call the `whoami` RPC method and return an `id`. + pub async fn whoami(&mut self) -> Result { + let mut sbot_connection = self.get_sbot_connection().await?; + let req_id = sbot_connection.client.whoami_req_send().await?; + + let result = utils::get_async( + &mut sbot_connection.rpc_reader, + req_id, + utils::json_res_parse, + ) + .await?; + + let id = result + .get("id") + .ok_or_else(|| GolgiError::Sbot("id key not found on whoami call".to_string()))? + .as_str() + .ok_or_else(|| GolgiError::Sbot("whoami returned non-string value".to_string()))?; + Ok(id.to_string()) + } +}