//! # go-sbotcli-rs //! //! Rust wrapper around the Go `sbotcli` ScuttleButt tool ([cryptoscope/ssb](https://github.com/cryptoscope/ssb)), allowing interaction with a `gosbot` instance. //! //! ## Example //! //! ```rust //! use go_sbotcli_rs::{Sbot, SbotCliError}; //! //! fn example() -> Result<(), SbotCliError> { //! // uses default paths for `sbotcli` and `go-sbot` working directory //! let sbot = Sbot::init(None, None)?; //! //! let id = "@p13zSAiOpguI9nsawkGijsnMfWmFd5rlUNpzekEE+vI=.ed25519"; //! //! let follow_ref = sbot.follow(id)?; //! let block_ref = sbot.block(id)?; //! //! let invite_code = sbot.create_invite()?; //! //! Ok(()) //! } //! ``` //! //! ## Documentation //! //! Use `cargo doc` to generate and serve the Rust documentation for this library: //! //! ```bash //! git clone https://git.coopcloud.tech/PeachCloud/go-sbotcli-rs.git //! cd peach-sbotcli //! cargo doc --no-deps --open //! ``` //! //! ## License //! //! LGPL-3.0. use serde_json::Value; use serde::{Serialize, Deserialize}; use ssb_multiformats::multihash::Multihash; use ssb_legacy_msg_data::LegacyF64; pub mod error; mod utils; pub use crate::error::SbotCliError; use std::{ffi::OsString, path::PathBuf, process::Command, result::Result}; /// HELPER STRUCTS FOR GETTING AND SETTING VALUES FROM SBOT /// A struct which represents any ssb message in the log. /// /// Modified from https://github.com/sunrise-choir/ssb-validate. /// /// Every ssb message in the log has a content field, as a first-level key. /// This content field is further parsed based on the specific type of message. /// /// Data type representing the `value` of a message object (`KVT`). More information concerning the /// data model can be found /// in the [`Metadata` documentation](https://spec.scuttlebutt.nz/feed/messages.html#metadata). #[derive(Serialize, Deserialize, Debug)] #[serde(deny_unknown_fields)] pub struct SsbMessageValue { pub previous: Option, pub author: String, pub sequence: u64, pub timestamp: LegacyF64, pub hash: String, pub content: Value, pub signature: String, } /// A struct which represents a relationship with another user (peer). /// Any combination of following and blocking is possible except /// for following=true AND blocking=true. #[derive(Serialize, Deserialize, Debug)] pub struct PeerRelationship { following: bool, blocking: bool } /// SBOT METHODS /// An `sbotcli` instance with associated methods for querying a Go sbot server. pub struct Sbot { /// The path to the `sbotcli` binary. pub sbotcli_path: OsString, /// The working directory of the `go-sbot` instance (where the Scuttlebutt database is stored). pub sbot_working_dir: OsString, } impl Sbot { /// Initialise an `sbotcli` instance. Sets default path parameters for the `sbotcli` binary and /// `go-sbot` working directory if none are provided. Alternatively, uses the provided /// parameter(s) to define the path(s). /// /// # Arguments /// /// * `sbotcli_path` - an optional string slice representing the file path to the sbotcli binary /// * `sbot_working_dir` - an optional string slice representing a directory path where sbotcli stores its data /// pub fn init( sbotcli_path: Option<&str>, sbot_working_dir: Option<&str>, ) -> Result { // set default path for sbotcli let mut path = PathBuf::from(r"/usr/bin/sbotcli"); // overwrite the default path if one has been provided via the `sbotcli_path` arg if let Some(p) = sbotcli_path { path = PathBuf::from(p); }; let mut dir = PathBuf::new(); if let Some(d) = sbot_working_dir { // define the `sbot_working_dir` using the provided arg dir.push(d) } else { // set default path for go-sbot working directory if no arg is provided // returns `Option` let home_dir = dirs::home_dir(); // it's possible that the home directory cannot be determined, hence the `match` match home_dir { Some(home_dir_path) => { dir.push(home_dir_path); dir.push(".ssb-go"); } // return an error if the home directory could not be determined None => return Err(SbotCliError::NoHomeDir), } }; Ok(Self { sbotcli_path: path.into_os_string(), sbot_working_dir: dir.into_os_string(), }) } /* BLOBS */ // TODO: file an issue // - doesn't seem to be implemented in sbotcli yet // - unsure of input type (`file_path`) // - unsure about using `-` to open `stdin` // /// Add a file to the blob store. /// /// Calls `sbotcli blobs add [file_path]`. On success: trims the trailing whitespace from `stdout` and returns the blob reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `file_path` - A string slice representing a file path to the blob to be added /// pub fn add_blob(&self, file_path: &str) -> Result { let output = Command::new(&self.sbotcli_path) .arg("blobs") .arg("add") .arg(file_path) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Blob(format!("Error adding blob: {}", stderr))) } } /* CONTACTS */ /// Set relationship with a peer. /// /// A relationship has two properties: /// - following (either true or false) /// - blocking (either true or false) /// /// This is an idempotent function which sets the relationship with a peer for following and blocking, /// based on the arguments that are passed in. /// /// It uses `sbotcli publish contact [id]`. Passing the --following and/or --blocking flags, /// if following or blocking is set to true, respectively. /// /// On success: trims the trailing whitespace from `stdout` /// and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `id` - A string slice representing an id (profile reference / public key) of the account to follow /// * `following` - a boolean representing whether to follow this account or not /// * `blocking` - a boolean representing whether to block this account or not /// pub fn set_relationship(&self, id: &str, following: bool, blocking: bool) -> Result { let mut command = Command::new(&self.sbotcli_path); command .arg("publish") .arg("contact"); if following { command.arg("--following"); } if blocking { command.arg("--blocking"); } let output = command .arg(id) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Contact(format!( "Error setting relationship with peer: {}", stderr ))) } } /// Get the latest value of a current relationship with a peer. /// /// We determine the value of the relationship by looking up the most recent contact /// message made about that peer. /// /// If no contact message is found, then a relationship of following=false blocking=false /// is returned. /// /// Calls `sbotcli bytype --limit 1 --reverse contact`. /// /// On success: parses a PeerRelationship from the ssb_message or returns one with default values. /// On error: returns the `stderr` output with a description. /// /// # arguments /// /// * `id` - a string slice representing the id (profile reference / public key) /// of the peer to look up the relationship with. /// pub fn get_relationship(&self, _id: &str) -> Result { // TODO: need to filter this query down to the actual user it should be for // right now this is not a real query let output = Command::new(&self.sbotcli_path) .arg("bytype") .arg("--limit") .arg("1") .arg("--reverse") .arg("contact") .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; println!("stdout: {:?}", stdout); let ssb_message : SsbMessageValue = serde_json::from_str(&stdout).map_err(|err| { SbotCliError::GetRelationship(format!("error deserializing ssb message while getting relationship to peer: {}", err)) })?; let relationship : PeerRelationship = serde_json::from_value(ssb_message.content).map_err(|err| { SbotCliError::GetRelationship(format!("error deserializing ssb message content while getting relationship to peer: {}", err)) })?; Ok(relationship) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::GetRelationship(format!( "error getting relationship to peer: {}", stderr ))) } } /// Follow a peer. Does not change whatever blocking relationship user already has with this peer. /// /// On success: trims the trailing whitespace from `stdout` /// and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `id` - A string slice representing an id (profile reference / public key) of the account to follow /// pub fn follow(&self, id: &str) -> Result { let current_relationship = self.get_relationship(id)?; self.set_relationship(id, true, current_relationship.blocking) } /// Block a peer. Does not change whatever following relationship user already has with this peer. /// /// Calls `sbotcli publish contact --blocking [id]`. On success: trims the trailing whitespace from `stdout` and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `id` - A string slice representing an id (profile reference / public key) of the account to block /// pub fn block(&self, id: &str) -> Result { let current_relationship = self.get_relationship(id)?; self.set_relationship(id, current_relationship.following, true) } /// Unfollow a peer. Does not change whatever blocking relationship user already has with this peer. /// /// On success: trims the trailing whitespace from `stdout` /// and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `id` - A string slice representing an id (profile reference / public key) of the account to follow /// pub fn unfollow(&self, id: &str) -> Result { let current_relationship = self.get_relationship(id)?; self.set_relationship(id, false, current_relationship.blocking) } /// Unblock a peer. Does not change whatever following relationship user already has with this peer. /// /// Calls `sbotcli publish contact --blocking [id]`. On success: trims the trailing whitespace from `stdout` and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `id` - A string slice representing an id (profile reference / public key) of the account to block /// pub fn unblock(&self, id: &str) -> Result { let current_relationship = self.get_relationship(id)?; self.set_relationship(id, current_relationship.following, false) } /* GET ABOUT MESSAGES */ /// Get the value of an about message with the given key. /// /// Calls `sbotcli bytype --limit 10 --reverse about`. On success: parses the `stdout` to extract the /// `key` and returns it. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `key` - A string slice representing the name of they that is being retrieved /// * `id` - A string slice representing the id (profile reference / public key) /// of the user to get the about message from /// pub fn get_about_message(&self, key: &str, _id: &str) -> Result, SbotCliError> { let output = Command::new(&self.sbotcli_path) .arg("bytype") .arg("--limit") .arg("1") .arg("--reverse") .arg("about") .output()?; if output.status.success() { // TODO: figure out how to take a stream of messages, and filter down to just ones with the value we are looking for // this code is not real dont look at it let stdout = String::from_utf8(output.stdout)?; let ssb_message : SsbMessageValue = serde_json::from_str(&stdout).map_err(|err| { SbotCliError::GetAboutMsgs(format!("error deserializing ssb message while getting about message: {}", err)) })?; let value = ssb_message.content.get(key); match value { Some(val) => { let value_as_str = val.as_str(); match value_as_str { Some(v) => Ok(Some(v.to_string())), None => Err(SbotCliError::GetAboutMsgs(format!("error parsing {} from about message", key))) } }, None => Err(SbotCliError::GetAboutMsgs(format!("error parsing {} from about message", key))) } } else { let stderr = std::str::from_utf8(&output.stderr)?; // TODO: create a more generic error variant Err(SbotCliError::GetAboutMsgs(format!( "Error fetching about messages: {}", stderr ))) } } /// Return latest name assignment from `about` msgs, for the public key /// associated with the local sbot instance. /// /// Calls `sbotcli bytype --limit 10 --reverse about`. On success: parses the `stdout` to extract the /// `name` and returns it. On error: returns the `stderr` output with a description. /// pub fn get_name(&self) -> Result, SbotCliError> { let self_id = self.whoami()?; self.get_about_message("name", &self_id) } /// Return latest description assignment from `about` msgs, for the public key /// associated with the local sbot instance. /// /// Calls `sbotcli bytype --limit 10 --reverse about`. On success: parses the `stdout` to extract the /// `description` and returns it. On error: returns the `stderr` output with a description. /// pub fn get_description(&self) -> Result, SbotCliError> { let self_id = self.whoami()?; self.get_about_message("description", &self_id) } /// Return latest profile image assignment from `about` msgs, for the public key /// associated with the local sbot instance. /// /// Calls `sbotcli bytype --limit 10 --reverse about`. On success: parses the `stdout` to extract the /// `image` and returns it. On error: returns the `stderr` output with a description. /// pub fn get_profile_image(&self) -> Result, SbotCliError> { let self_id = self.whoami()?; self.get_about_message("image", &self_id) } /* INVITES */ /// Accept an invite code (trigger a mutual follow with the peer who generated the invite). /// /// Calls `sbotcli invite accept [invite_code]`. On success: trims the trailing whitespace from `stdout` and returns the follow message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `invite` - A string slice representing an invite code /// pub fn accept_invite(&self, invite: &str) -> Result { let output = Command::new(&self.sbotcli_path) .arg("invite") .arg("accept") .arg(invite) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Invite(format!( "Error accepting invite: {}", stderr ))) } } /// Return an invite code from go-sbot. /// /// Calls `sbotcli invite create`. On success: trims the trailing whitespace from `stdout` and returns the invite code. On error: returns the `stderr` output with a description. /// pub fn create_invite(&self) -> Result { let output = Command::new(&self.sbotcli_path) .arg("invite") .arg("create") .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let invite = stdout.trim_end().to_string(); Ok(invite) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Invite(format!( "Error creating invite: {}", stderr ))) } } /* PUBLISH */ /// Publish an about message with an image. /// /// Calls `sbotcli publish about --image [blob_reference] [id]`. On success: trims the trailing whitespace from `stdout` and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `blob_ref` - A string slice representing a blob reference /// * `id` - A string slice representing an id (profile reference / public key) /// pub fn publish_image(&self, blob_ref: &str, id: &str) -> Result { let output = Command::new(&self.sbotcli_path) .arg("publish") .arg("about") .arg("--image") .arg(blob_ref) .arg(id) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Publish(format!( "Error publishing image: {}", stderr ))) } } // TODO: file an issue // - doesn't seem to be implemented in sbotcli yet // /// Publish an about message with a description. /// /// Calls `sbotcli publish about --description [description] [id]`. On success: trims the trailing whitespace from `stdout` and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `description` - A string slice representing a profile description (bio) /// * `id` - A string slice representing an id (profile reference / public key) /// pub fn publish_description(&self, description: &str, id: &str) -> Result { let output = Command::new(&self.sbotcli_path) .arg("publish") .arg("about") .arg("--description") .arg(description) .arg(id) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Publish(format!( "Error publishing description: {}", stderr ))) } } /// Publish an about message with a name. /// /// Calls `sbotcli publish about --name [name] [id]`. On success: trims the trailing whitespace from `stdout` and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `name` - A string slice representing a profile name (bio) /// * `id` - A string slice representing the id (profile reference / public key) /// of the profile being named /// pub fn publish_name(&self, id: &str, name: &str) -> Result { let output = Command::new(&self.sbotcli_path) .arg("publish") .arg("about") .arg("--name") .arg(name) .arg(id) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Publish(format!( "Error publishing name: {}", stderr ))) } } /// Publish an about message with a name for one's own profile, /// using whoami to find your own id. /// /// Calls `sbotcli publish about --name [name] [self_id]`. /// passing the id of the currently running sbot as self_id. /// On success: trims the trailing whitespace from `stdout` and returns the message reference. /// On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `name` - A string slice of the new name you would like to self-identify with /// pub fn publish_own_name(&self, name: &str) -> Result { let self_id = &self.whoami()?; self.publish_name(name, self_id) } /// Publish an about message with a description for one's own profile, /// using whoami to find your own id. /// /// Calls `sbotcli publish about --description [description] [self_id]`. /// passing the id of the currently running sbot as self_id. /// On success: trims the trailing whitespace from `stdout` and returns the message reference. /// On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `description` - A string slice of the description you would like to use /// pub fn publish_own_description(&self, description: &str) -> Result { let self_id = &self.whoami()?; self.publish_description(description, self_id) } /// Publish a post (public message). /// /// Calls `sbotcli publish post [text]". On success: trims the trailing whitespace from `stdout` and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `text` - A string slice representing a post (public message) /// pub fn publish_post(&self, text: &str) -> Result { let output = Command::new(&self.sbotcli_path) .arg("publish") .arg("post") .arg(text) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Publish(format!( "Error publishing public post: {}", stderr ))) } } /// Publish a private message. Currently only supports sending the message to a single recipient /// (multi-recipient support to be added later). /// /// Calls `sbotcli publish post --recps [id] [msg]`. On success: trims the trailing whitespace from `stdout` /// and returns the message reference. On error: returns the `stderr` output with a description. /// /// # Arguments /// /// * `id` - A string slice representing an id (profile reference / public key) /// * `msg` - A string slice representing a private message /// pub fn publish_private_message(&self, id: &str, msg: &str) -> Result { let output = Command::new(&self.sbotcli_path) .arg("publish") .arg("post") .arg("--recps") .arg(id) .arg(msg) .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let msg_ref = stdout.trim_end().to_string(); Ok(msg_ref) } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::Publish(format!( "Error publishing private message: {}", stderr ))) } } /* WHOAMI */ /// Return the Scuttlebutt ID from go-sbot using `whoami`. /// /// Calls `sbotcli call whoami`. On success: parses the `stdout` to extract the ID and returns it. /// On error: returns the `stderr` output with a description. /// pub fn whoami(&self) -> Result { let output = Command::new(&self.sbotcli_path) .arg("call") .arg("whoami") .output()?; if output.status.success() { let stdout = String::from_utf8(output.stdout)?; let id = utils::regex_finder(r#""id": "(.*)"\n"#, &stdout)?; match id { // if the regex matches, then return the result Some(id) => { Ok(id) }, // if the regex does not match, then return an error None => { Err(SbotCliError::WhoAmI("Error calling whoami: failed to capture the id value using regex".to_string())) } } } else { let stderr = std::str::from_utf8(&output.stderr)?; Err(SbotCliError::WhoAmI(format!( "Error calling whoami: {}", stderr ))) } } } #[cfg(test)] mod tests { #[test] fn it_works() { let result = 2 + 2; assert_eq!(result, 4); } }