use crate::{get_client, StdError}; use itertools::Itertools; use std::borrow::Cow; use std::convert::TryInto; use std::fmt; use std::io::prelude::*; use std::{collections::HashMap, io::Write}; use tracing::{event, instrument, Level}; const APP_TOKEN_ENV_VAR: &str = "TWITTER_APP_TOKEN"; const APP_SECRET_ENV_VAR: &str = "TWITTER_APP_SECRET"; const USER_TOKEN_ENV_VAR: &str = "TWITTER_USER_TOKEN"; const USER_SECRET_ENV_VAR: &str = "TWITTER_USER_SECRET"; static CB_URL: &str = "http://localhost:6969/cb"; pub fn user_token_from_env() -> oauth1::Token<'static> { oauth1::Token::new( std::env::var(USER_TOKEN_ENV_VAR).expect("No user token env var"), std::env::var(USER_SECRET_ENV_VAR).expect("No user secret env var"), ) } pub enum TwitterEndpoint { OauthRequestToken, OauthAccessToken, OauthAuthenticate, UpdateStatus, UserTimeline, VerifyCredentials, } impl std::fmt::Display for TwitterEndpoint { fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { const BASE: &str = "https://api.twitter.com"; let endpoint = match self { Self::OauthAuthenticate => "oauth/authenticate", Self::OauthRequestToken => "oauth/request_token", Self::OauthAccessToken => "oauth/access_token", Self::UpdateStatus => "1.1/statuses/update.json", Self::UserTimeline => "1.1/statuses/user_timeline.json", Self::VerifyCredentials => "1.1/account/verify_credentials.json", }; write!(f, "{}/{}", BASE, endpoint) } } impl TryInto for TwitterEndpoint { type Error = url::ParseError; fn try_into(self) -> Result { reqwest::Url::parse(&self.to_string()) } } pub enum PostData<'a> { Empty, Multipart(reqwest::blocking::multipart::Form), Data(&'a [(&'a str, Cow<'a, str>)]), } impl fmt::Debug for PostData<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "{}", match self { Self::Empty => "Empty", Self::Multipart(_) => "Multipart", Self::Data(_) => "Data", } ) } } #[derive(Debug)] pub enum APIAction<'a> { Get, Post(PostData<'a>), } impl APIAction<'_> { pub fn get_verb(&self) -> &'static str { match self { Self::Get => "GET", Self::Post(_) => "POST", } } } // Make an authed twitter API request #[instrument(skip(user_token), fields(url=url.to_string()))] pub fn twitter_api<'a>( url: reqwest::Url, user_token: Option<&oauth1::Token>, action: APIAction, extra_oauth_params: &[(&str, &str)], ) -> StdError { let consumer_token = oauth1::Token::new( std::env::var(APP_TOKEN_ENV_VAR)?, std::env::var(APP_SECRET_ENV_VAR)?, ); let mut headers = reqwest::header::HeaderMap::new(); let mut params: HashMap<&str, Cow> = extra_oauth_params .iter() .cloned() .map(|(x, y)| (x, y.into())) .collect(); // Copy all our query parameters and add them to the list of params for oauth1 signature // generation. // This is a bit awkward, as params is a map from &str to Cow but query_pairs() returns // (Cow, Cow) tuples. So we call into_owned to make (String, String) tuples, and then // borrow from there. If there's a way to do it without copying, I couldn't find it. let pairs: Vec<_> = url.query_pairs().into_owned().collect(); for (k, v) in &pairs { params.insert(k, Cow::Borrowed(v)); } // If the request is a key/value form post, we also need to include those parameters when // generating the signature. if let APIAction::Post(PostData::Data(d)) = action { params.extend(d.to_owned()) } // The url used to generate the signature must not include the query params let mut url_sans_query = url.clone(); url_sans_query.set_query(None); headers.insert( reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&oauth1::authorize( action.get_verb(), url_sans_query.as_str(), &consumer_token, user_token, Some(params), ))?, ); let client = get_client(Some(headers))?; let req = match action { APIAction::Get => client.get(url), APIAction::Post(PostData::Empty) => client.post(url), APIAction::Post(PostData::Data(data)) => client.post(url).form(data), APIAction::Post(PostData::Multipart(form)) => client.post(url).multipart(form), }; event!(Level::INFO, "Sending request"); let res = req.send()?; if !res.status().is_success() { return Err(format!( "Got non-200 response: status {}, {}", res.status(), res.text()? ) .into()); } Ok(res) } pub fn do_authorize() -> StdError<()> { println!("Authorizing you lol!"); // Oauth1 leg 1 let res = twitter_api( TwitterEndpoint::OauthRequestToken.try_into()?, None, APIAction::Post(PostData::Empty), &[("oauth_callback", CB_URL)], )? .text()?; let returned_params: HashMap<&str, &str> = res .split('&') .map(|s| s.split('=').collect_tuple()) .collect::>() .ok_or("Unexpected oauth step 1 response")?; // Oauth1 leg 2 let user_url = reqwest::Url::parse_with_params( &TwitterEndpoint::OauthAuthenticate.to_string(), [("oauth_token", returned_params["oauth_token"])], )?; println!("Plz do the thing in the browser"); webbrowser::open(user_url.as_str())?; let listener = std::net::TcpListener::bind("127.0.0.1:6969")?; let mut stream = listener.incoming().next().ok_or("Error getting stream")??; let mut buf = [0u8; 4096]; stream.read(&mut buf[..])?; let target = std::str::from_utf8(buf.split(|c| *c == b' ').nth(1).ok_or("No target found")?)?; let oauth_verifier = reqwest::Url::parse("https://example.net/")? .join(target)? .query_pairs() .find_map(|(k, v)| { if k == "oauth_verifier" { Some(v.into_owned()) } else { None } }) .ok_or("no oauth_verifier in response")?; stream.write_all(b"HTTP/1.1 200 OK\r\n\r\nThanks lmao\r\n")?; stream.shutdown(std::net::Shutdown::Read)?; // Oauth1 leg 3 let res = twitter_api( TwitterEndpoint::OauthAccessToken.try_into()?, None, APIAction::Post(PostData::Data(&[( "oauth_verifier", Cow::Owned(oauth_verifier), )])), &[("oauth_token", returned_params["oauth_token"])], )? .text()?; let returned_params: HashMap<&str, &str> = res .split('&') .map(|s| s.split('=').collect_tuple()) .collect::>() .ok_or("Unexpected oauth step 3 response")?; println!( "Authorized for {}.\nRun with {}={} {}={}", returned_params["screen_name"], USER_TOKEN_ENV_VAR, returned_params["oauth_token"], USER_SECRET_ENV_VAR, returned_params["oauth_token_secret"] ); Ok(()) } fn upload_image(user_token: &oauth1::Token, img: Cow<'static, [u8]>) -> StdError { let form = reqwest::blocking::multipart::Form::new() .part("media", reqwest::blocking::multipart::Part::bytes(img)); let res: serde_json::Value = twitter_api( "https://upload.twitter.com/1.1/media/upload.json".try_into()?, Some(user_token), APIAction::Post(PostData::Multipart(form)), &[], )? .json()?; Ok(res["media_id"].as_u64().ok_or("media_id not u64!")?) } pub fn tweet(text: &str, img: Option>) -> StdError<()> { let user_token = oauth1::Token::new( std::env::var(USER_TOKEN_ENV_VAR)?, std::env::var(USER_SECRET_ENV_VAR)?, ); let user: serde_json::Value = twitter_api( TwitterEndpoint::VerifyCredentials.try_into()?, Some(&user_token), APIAction::Get, &[], )? .json()?; println!( "Tweeting for user @{}, (id: {})", user["screen_name"], user["id"] ); let mut post_data = vec![("status", Cow::Borrowed(text))]; if let Some(img) = img { println!("Uploading image..."); let img_id = upload_image(&user_token, img)?; post_data.push(("media_ids", Cow::Owned(img_id.to_string()))) } event!(Level::INFO, "Sending tweet..."); twitter_api( TwitterEndpoint::UpdateStatus.try_into()?, Some(&user_token), APIAction::Post(PostData::Data(&post_data[0..])), &[], )?; Ok(()) }