277 lines
8.7 KiB
Rust
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(())
|
||
|
}
|