iso7010-a-day/src/twitter.rs

277 lines
8.7 KiB
Rust

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<reqwest::Url> for TwitterEndpoint {
type Error = url::ParseError;
fn try_into(self) -> Result<reqwest::Url, Self::Error> {
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<reqwest::blocking::Response> {
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<str>> = 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<str> but query_pairs() returns
// (Cow<str>, Cow<str>) 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::<Option<_>>()
.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::<Option<_>>()
.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<u64> {
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<Cow<'static, [u8]>>) -> 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(())
}