diff --git a/Cargo.toml b/Cargo.toml index d610ad8..705edd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fie" -version = "0.11.0" +version = "0.12.0" authors = ["Douman "] repository = "https://github.com/DoumanAsh/fie" description = "Small and cute social media utility." diff --git a/README.md b/README.md index d1c19f0..8361231 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build status](https://ci.appveyor.com/api/projects/status/oc937oppd38x1y4y/branch/master?svg=true)](https://ci.appveyor.com/project/DoumanAsh/fie/branch/master) [![Build Status](https://travis-ci.org/DoumanAsh/fie.svg?branch=master)](https://travis-ci.org/DoumanAsh/fie) [![Crates.io](https://img.shields.io/crates/v/fie.svg)](https://crates.io/crates/fie) -[![Dependency status](https://deps.rs/crate/fie/0.11.0/status.svg)](https://deps.rs/crate/fie) +[![Dependency status](https://deps.rs/crate/fie/0.12.0/status.svg)](https://deps.rs/crate/fie) Small and cute social media CLI. @@ -11,16 +11,17 @@ Small and cute social media CLI. ## Download links -* Windows [32bit](https://github.com/DoumanAsh/fie/releases/download/0.11.0/fie-0.11.0-i686-pc-windows-msvc.zip) -* Windows [64bit](https://github.com/DoumanAsh/fie/releases/download/0.11.0/fie-0.11.0-x86_64-pc-windows-msvc.zip) -* Linux [64bit](https://github.com/DoumanAsh/fie/releases/download/0.11.0/fie-0.11.0-x86_64-unknown-linux-gnu.zip) -* OSX [64bit](https://github.com/DoumanAsh/fie/releases/download/0.11.0/fie-0.11.0-x86_64-apple-darwin.zip) +* Windows [32bit](https://github.com/DoumanAsh/fie/releases/download/0.12.0/fie-0.12.0-i686-pc-windows-msvc.zip) +* Windows [64bit](https://github.com/DoumanAsh/fie/releases/download/0.12.0/fie-0.12.0-x86_64-pc-windows-msvc.zip) +* Linux [64bit](https://github.com/DoumanAsh/fie/releases/download/0.12.0/fie-0.12.0-x86_64-unknown-linux-gnu.zip) +* OSX [64bit](https://github.com/DoumanAsh/fie/releases/download/0.12.0/fie-0.12.0-x86_64-apple-darwin.zip) ## Supported social platforms: -* Gab (through unofficial API so may break); * Twitter. Using official API. +* Gab (through unofficial API so may break); * Mastodon. Using official API. +* Minds. Using semi-official API. ## Configuration @@ -42,6 +43,7 @@ FLAGS: -g, --gab Use gab.ai. By default all social medias are used unless flag is specified. -h, --help Prints help information -m, --mastodon Use mastodon. By default all social medias are used unless flag is specified. + --minds Use minds. By default all social medias are used unless flag is specified. -t, --twitter Use twitter. By default all social medias are used unless flag is specified. -V, --version Prints version information diff --git a/docs/configuration.md b/docs/configuration.md index 2c78b24..3d7f97f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -15,7 +15,17 @@ If both are missing then error happens Just provide your password and login ```toml -[gab] +[api.gab] +username = "username" +password = "password" +``` + +## Minds + +Just provide your password and login + +```toml +[api.gab] username = "username" password = "password" ``` @@ -30,7 +40,7 @@ After that go to section `Keys and Access Tokens` to retrieve configuration: Put it in section below: ```toml -[twitter.consumer] +[api.twitter.consumer] key = "key" secret = "secret" ``` @@ -39,7 +49,7 @@ secret = "secret" Put it in section below: ```toml -[twitter.access] +[api.twitter.access] key = "token" secret = "secret" ``` @@ -53,7 +63,7 @@ You need to provide host name of the Mastodon instance. Access token can be granted by creating own application via `Preferences->Developement->New Application` ```toml -[mastodon] +[api.mastodon] host = "pawoo.net" access_token = "" ``` diff --git a/fie.toml b/fie.toml index f38cbc8..8f1dc59 100644 --- a/fie.toml +++ b/fie.toml @@ -1,8 +1,13 @@ -# Login and password from Gab.ai +# Login and password from Gab.com [api.gab] username = "username" password = "password" +# Login and password from Minds.com +[api.minds] +username = "username" +password = "password" + # Consumer Token of twitter app [api.twitter.consumer] key = "key" diff --git a/src/cli/cli.rs b/src/cli/cli.rs index e99b9f0..68731c4 100644 --- a/src/cli/cli.rs +++ b/src/cli/cli.rs @@ -21,7 +21,7 @@ impl Args { let args = Self::from_args(); //Unless user specifies manually, we use configuration defaults - if args.flags.twitter || args.flags.gab || args.flags.mastodon { + if args.flags.twitter || args.flags.gab || args.flags.mastodon || args.flags.minds { *platforms = unsafe { mem::transmute(args.flags) } } @@ -40,6 +40,9 @@ pub struct Flags { #[structopt(short = "m", long = "mastodon")] ///Use mastodon. By default all social medias are used unless flag is specified. pub mastodon: bool, + #[structopt(long = "minds")] + ///Use minds. By default all social medias are used unless flag is specified. + pub minds: bool, } #[derive(Debug, StructOpt)] diff --git a/src/cli/main.rs b/src/cli/main.rs index bbdebc4..6411b0c 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -37,6 +37,14 @@ fn create_api(config: Config) -> io::Result { } } + if config.platforms.minds { + if let Err(error) = api.enable(config.api.minds) { + eprintln!("{}", error); + } else { + any_enabled = true + } + } + match any_enabled { true => Ok(api), false => Err(io::Error::new(io::ErrorKind::Other, "No API is enabled :(")), @@ -44,7 +52,7 @@ fn create_api(config: Config) -> io::Result { } fn handle_post_result(result: fie::api::PostResult) { - let (twitter, gab, mastodon) = result.into_parts(); + let (twitter, gab, mastodon, minds) = result.into_parts(); let handle_inner = |prefix, result| if let Some(result) = result { match result { @@ -56,6 +64,7 @@ fn handle_post_result(result: fie::api::PostResult) { handle_inner("Twitter", twitter); handle_inner("Gab", gab); handle_inner("Mastodon", mastodon); + handle_inner("Minds", minds); } fn open_batch(path: &str) -> io::Result> { diff --git a/src/lib/api/gab/mod.rs b/src/lib/api/gab/mod.rs index 5b1996d..44b148c 100644 --- a/src/lib/api/gab/mod.rs +++ b/src/lib/api/gab/mod.rs @@ -124,12 +124,12 @@ impl Gab { // For image we wait twice of time // just to be sure req.or_else(|resp| resp.retry(http::get_timeout()).into_future().flatten()) - .map_err(|_| GabError::ImageUploadSendError) - .and_then(|resp| match resp.is_success() { - true => Ok(resp), - false => Err(GabError::ImageUploadServerReject), - }).and_then(|response| response.json::().map_err(|_| GabError::PostUploadInvalidResponse)) - .map(|response| response.id) + .map_err(|_| GabError::ImageUploadSendError) + .and_then(|resp| match resp.is_success() { + true => Ok(resp), + false => Err(GabError::ImageUploadServerReject), + }).and_then(|response| response.json::().map_err(|_| GabError::PostUploadInvalidResponse)) + .map(|response| response.id) } ///Prepares post upload request. @@ -142,10 +142,10 @@ impl Gab { .send(); req.map_err(|_| GabError::PostUploadSendError) - .and_then(|resp| match resp.is_success() { - true => Ok(resp), - false => Err(GabError::PostUploadServerReject), - }).and_then(|resp| resp.json::().map_err(|_| GabError::PostUploadInvalidResponse)) - .map(|resp| resp.post.id.into()) + .and_then(|resp| match resp.is_success() { + true => Ok(resp), + false => Err(GabError::PostUploadServerReject), + }).and_then(|resp| resp.json::().map_err(|_| GabError::PostUploadInvalidResponse)) + .map(|resp| resp.post.id.into()) } } diff --git a/src/lib/api/minds/data.rs b/src/lib/api/minds/data.rs new file mode 100644 index 0000000..24cd8a6 --- /dev/null +++ b/src/lib/api/minds/data.rs @@ -0,0 +1,78 @@ +//!Minds data + +use serde_derive::{Serialize, Deserialize}; + +use crate::data::PostFlags; + +///Auth payload +#[derive(Serialize, Debug)] +pub struct Auth<'a> { + grant_type: &'static str, + client_id: &'static str, + username: &'a str, + password: &'a str, +} + +impl<'a> Auth<'a> { + ///Creates new payload + pub fn new(username: &'a str, password: &'a str) -> Self { + Auth { + grant_type: "password", + client_id: "mobile", + username, + password, + } + } +} + +///Payload for successful authorization +#[derive(Deserialize, Debug)] +pub struct Oauth2 { + ///Access token + pub access_token: String, + ///Expiration time(units?) + pub expires_in: u64, + ///Request's textual status + pub status: String, +} + +///Payload for post +#[derive(Serialize, Debug)] +pub struct Post<'a> { + wire_threshold: Option, + message: &'a str, + is_rich: u8, + title: Option, + description: Option, + thumbnail: Option, + url: Option, + attachment_guid: &'a Option, + ///Whether content is safe for work or not + pub mature: u8, + access_id: u8, +} + +impl<'a> Post<'a> { + ///Creates new post + pub fn new(message: &'a str, attachment_guid: &'a Option, flags: &PostFlags) -> Self { + Post { + wire_threshold: None, + message, + is_rich: 0, + title: None, + description: None, + thumbnail: None, + url: None, + attachment_guid, + mature: flags.nsfw as u8, + access_id: 2, + } + } +} + +///Response to successful upload/post +#[derive(Deserialize, Debug)] +pub struct UploadResponse { + ///Newly created entity ID + pub guid: String, +} diff --git a/src/lib/api/minds/error.rs b/src/lib/api/minds/error.rs new file mode 100644 index 0000000..6322384 --- /dev/null +++ b/src/lib/api/minds/error.rs @@ -0,0 +1,47 @@ +use std::error::Error; +use std::fmt; + +#[repr(u8)] +#[derive(Debug)] +///Minds errors +pub enum MindsError { + ///Authorization failed. + LoginFailed, + ///Failed to send request to upload image. + ImageUploadSendError, + ///Server rejected image upload. + ImageUploadServerReject, + ///Server responded with invalid data. + /// + ///Should contain `id` + ImageUploadInvalidResponse, + ///Failed to send request to perform text post. + PostUploadSendError, + ///Server rejected posting. + PostUploadServerReject, + ///Server responded with invalid data + /// + ///Should contain `id` + PostUploadInvalidResponse, + +} + +impl fmt::Display for MindsError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.description()) + } +} + +impl Error for MindsError { + fn description(&self) -> &str { + match self { + &MindsError::LoginFailed => "Login has failed", + &MindsError::ImageUploadSendError => "Failed to send request to upload image", + &MindsError::ImageUploadServerReject => "Server rejected upload of image", + &MindsError::ImageUploadInvalidResponse => "Server sent invalid response. Doesn't contain field id", + &MindsError::PostUploadSendError => "Failed to send request to perform text post", + &MindsError::PostUploadServerReject => "Server rejected posting", + &MindsError::PostUploadInvalidResponse => "Server sent invalid response. Doesn't contain field id", + } + } +} diff --git a/src/lib/api/minds/mod.rs b/src/lib/api/minds/mod.rs new file mode 100644 index 0000000..a5b403b --- /dev/null +++ b/src/lib/api/minds/mod.rs @@ -0,0 +1,84 @@ +//!Gab API + +const OAUTH2_URL: &'static str = "https://www.minds.com/api/v2/oauth/token"; +const IMAGES_URL: &'static str = "https://www.minds.com/api/v1/media"; +const POST_URL: &'static str = "https://www.minds.com/api/v1/newsfeed"; + +use crate::data::PostFlags; +use super::http::{self, multipart, Future, IntoFuture, Mime, Request, AutoRuntime, AutoClient}; + +pub mod data; +mod error; + +use data::*; +pub use error::MindsError; + +///Minds API +pub struct Minds { + token: String, +} + +impl Minds { + ///Creates new instances by attempting to login and get access token. + pub fn new(config: crate::config::Minds) -> Result { + let req = Request::post(OAUTH2_URL) + .expect("To create request") + .json(&Auth::new(&config.username, &config.password)) + .expect("To serialize json") + .send() + .finish(); + + let req = match req { + Ok(req) => req, + Err(_) => return Err(MindsError::LoginFailed), + }; + + if !req.is_success() { + return Err(MindsError::LoginFailed); + } + + let oauth2 = match req.json::().finish() { + Ok(oauth2) => oauth2, + Err(_) => return Err(MindsError::LoginFailed), + }; + + Ok(Self { token: oauth2.access_token }) + } + + ///Prepares image upload request. + /// + ///Future result contains `id` from `UploadResponse` + pub fn upload_image(&self, name: &str, mime: &Mime, data: &[u8]) -> impl Future { + let mut form = multipart::Form::new(); + form.add_file_field("file".to_string(), name.to_string(), mime, data); + + let req = Request::post(IMAGES_URL).expect("To create request").bearer_auth(&self.token).multipart(form).send(); + + // For image we wait twice of time + // just to be sure + req.or_else(|resp| resp.retry(http::get_timeout()).into_future().flatten()) + .map_err(|_| MindsError::ImageUploadSendError) + .and_then(|resp| match resp.is_success() { + true => Ok(resp), + false => Err(MindsError::ImageUploadServerReject), + }).and_then(|response| response.json::().map_err(|_| MindsError::PostUploadInvalidResponse)) + .map(|response| response.guid) + } + + ///Prepares post upload request. + pub fn post(&self, message: &str, media_attachments: Option, flags: &PostFlags) -> impl Future { + let req = Request::post(POST_URL) + .expect("To create request") + .bearer_auth(&self.token) + .json(&Post::new(&message, &media_attachments, &flags)) + .expect("To serialzie post data") + .send(); + + req.map_err(|_| MindsError::PostUploadSendError) + .and_then(|resp| match resp.is_success() { + true => Ok(resp), + false => Err(MindsError::PostUploadServerReject), + }).and_then(|resp| resp.json::().map_err(|_| MindsError::PostUploadInvalidResponse)) + .map(|resp| resp.guid.into()) + } +} diff --git a/src/lib/api/mod.rs b/src/lib/api/mod.rs index fe1f60d..13a0746 100644 --- a/src/lib/api/mod.rs +++ b/src/lib/api/mod.rs @@ -1,13 +1,15 @@ //!Social medias API module mod http; -pub mod mastodon; -pub mod gab; pub mod twitter; +pub mod gab; +pub mod mastodon; +pub mod minds; use twitter::{Twitter, TwitterError}; use gab::{Gab, GabError}; use mastodon::{Mastodon, MastodonError}; +use minds::{Minds, MindsError}; use http::{future, Future, AutoRuntime, HttpRuntime}; use crate::data::{join_hash_tags, PostId, Post}; @@ -22,21 +24,24 @@ use std::io; pub enum ApiError { ///Unable to load Image for attachment CannotLoadImage(String, io::Error), - ///Mastodon error - Mastodon(MastodonError), - ///Gab error - Gab(GabError), ///Twitter error Twitter(TwitterError), + ///Gab error + Gab(GabError), + ///Mastodon error + Mastodon(MastodonError), + ///Minds error + Minds(MindsError), } impl fmt::Display for ApiError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { &ApiError::CannotLoadImage(ref name, ref error) => write!(f, "Error opening image '{}'. Error: {}", name, error), - &ApiError::Mastodon(ref error) => write!(f, "Mastodon API Error: {}", error), - &ApiError::Gab(ref error) => write!(f, "Gab API Error: {}", error), &ApiError::Twitter(ref error) => write!(f, "Twitter API Error: {}", error), + &ApiError::Gab(ref error) => write!(f, "Gab API Error: {}", error), + &ApiError::Mastodon(ref error) => write!(f, "Mastodon API Error: {}", error), + &ApiError::Minds(ref error) => write!(f, "MindsError API Error: {}", error), } } } @@ -61,8 +66,13 @@ impl From for ApiError { } } +impl From for ApiError { + fn from(error: MindsError) -> Self { + ApiError::Minds(error) + } +} -type PostResultInner = (Option>, Option>, Option>); +type PostResultInner = (Option>, Option>, Option>, Option>); ///Result of Post. pub struct PostResult { inner: PostResultInner, @@ -84,9 +94,14 @@ impl PostResult { self.inner.2.take() } + ///Retrieves Minds's result + pub fn minds(&mut self) -> Option> { + self.inner.3.take() + } + ///Retrieves underlying errors. /// - ///Order: Twitter, Gab, Mastodon + ///Order: Twitter, Gab, Mastodon, Minds pub fn into_parts(self) -> PostResultInner { self.inner } @@ -97,6 +112,7 @@ pub struct API { twitter: Option, gab: Option, mastodon: Option, + minds: Option, _http: HttpRuntime, } @@ -107,6 +123,7 @@ impl API { twitter: None, mastodon: None, gab: None, + minds: None, _http: http::init(&settings) } } @@ -148,7 +165,7 @@ impl API { None => future::Either::B(future::ok(None)) }; - twitter.join3( + twitter.join4( //Gab if let Some(ref gab) = self.gab { let post = gab.post(&message, &[], &flags).map_err(|error| ApiError::Gab(error)) @@ -170,6 +187,17 @@ impl API { } else { future::Either::B(future::ok(None)) }, + //Minds + if let Some(ref minds) = self.minds { + let post = minds.post(&message, None, &flags).map_err(|error| ApiError::Minds(error)) + .map(|res| Some(Ok(res))) + .or_else(|err| Ok(Some(Err(err)))); + + future::Either::A(post) + + } else { + future::Either::B(future::ok(None)) + }, ).finish() }, _ => { @@ -204,7 +232,7 @@ impl API { }; //Twitter - twitter.join3( + twitter.join4( //Gab if let Some(ref gab) = self.gab { let mut uploads = vec![]; @@ -237,6 +265,18 @@ impl API { } else { future::Either::B(future::ok(None)) }, + //Minds + if let Some(ref minds) = self.minds { + let image = unsafe { images.get_unchecked(0) }; + let image_upload = minds.upload_image(&image.name, &image.mime, &image.mmap[..]); + let uploads = image_upload.map_err(|error| ApiError::Minds(error)) + .and_then(move |image| minds.post(&message, Some(image), &flags).from_err()) + .map(|res| Some(Ok(res))) + .or_else(|err| Ok(Some(Err(err)))); + future::Either::A(uploads) + } else { + future::Either::B(future::ok(None)) + }, ).finish() }, }; @@ -285,3 +325,14 @@ impl ApiEnabler for crate::config::Twitter { Ok(()) } } + +impl ApiEnabler for crate::config::Minds { + fn enable(api: &mut API, config: Self) -> Result<(), ApiError> { + if api.minds.is_some() { + return Ok(()); + } + + api.minds = Some(Minds::new(config)?); + Ok(()) + } +} diff --git a/src/lib/config.rs b/src/lib/config.rs index 0001619..3c8f600 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -17,6 +17,9 @@ pub struct Platforms { ///Whether Mastodon is enabled #[serde(default)] pub mastodon: bool, + ///Whether Minds is enabled + #[serde(default)] + pub minds: bool, } // If the whole section on Platforms is missing then we assume @@ -28,6 +31,7 @@ impl Default for Platforms { mastodon: true, gab: true, twitter: true, + minds: true } } } @@ -73,6 +77,17 @@ pub struct Mastodon { pub access_token: String, } +/// Minds configuration. +#[derive(Deserialize, Debug)] +pub struct Minds { + ///Username for authorization + #[serde(default)] + pub username: String, + ///Password for authorization + #[serde(default)] + pub password: String, +} + fn default_timeout() -> u64 { 5 } @@ -102,6 +117,8 @@ pub struct ApiConfig { pub twitter: Twitter, ///Mastodon information pub mastodon: Mastodon, + ///Minds information + pub minds: Minds, } ///Fie's configuration