use itertools::Itertools; use rand::seq::SliceRandom; use std::borrow::Cow; use std::convert::TryInto; use std::io::prelude::*; use std::{collections::HashMap, io::Write}; 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"; const IMG_HEIGHT: u32 = 1000; static APP_USER_AGENT: &str = concat!( "bot_", env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"), " ", "reqwest", ); static CB_URL: &str = "http://localhost:6969/cb"; fn render_svg(data: &[u8]) -> Result, Box> { let opt = usvg::Options::default(); let rtree = usvg::Tree::from_data(&data, &opt.to_ref()).expect("couldn't parse"); let mut pixmap = tiny_skia::Pixmap::new(IMG_HEIGHT, IMG_HEIGHT).ok_or("Error creating pixmap")?; resvg::render( &rtree, usvg::FitTo::Size(IMG_HEIGHT, IMG_HEIGHT), pixmap.as_mut(), ) .ok_or_else(|| "Error rendering svg")?; let mut bigger_pixmap = tiny_skia::Pixmap::new(IMG_HEIGHT / 9 * 16, IMG_HEIGHT) .ok_or("Error creating bigger pixmap")?; bigger_pixmap .draw_pixmap( (bigger_pixmap.width() / 2 - IMG_HEIGHT / 2) .try_into() .unwrap(), 0, pixmap.as_ref(), &tiny_skia::PixmapPaint::default(), tiny_skia::Transform::identity(), None, ) .ok_or("Error drawing onto bigger pixmap")?; let png_data = bigger_pixmap.encode_png()?; Ok(png_data) } enum PostData<'a> { Empty, Multipart(reqwest::blocking::multipart::Form), Data(&'a [(&'a str, &'a str)]), } enum APIAction<'a> { Get, Post(PostData<'a>), } struct TwitterEndpoint<'a>(&'a str); impl TryInto for TwitterEndpoint<'_> { type Error = url::ParseError; fn try_into(self) -> Result { reqwest::Url::parse(&format!("https://api.twitter.com/{}", self.0)) } } // Make an authed twitter API request 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 oauth_params: HashMap<&str, Cow> = extra_oauth_params .iter() .cloned() .map(|(x, y)| (x, y.into())) .collect(); // If the request is a key/value form post, we need to include those parameters when // generating the signature. match action { APIAction::Post(PostData::Data(d)) => { oauth_params.extend(d.iter().cloned().map(|(x, y)| (x, y.into()))) } _ => {} } headers.insert( reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&oauth1::authorize( if matches!(action, APIAction::Post(_)) { "POST" } else { "GET" }, url.as_str(), &consumer_token, user_token, Some(oauth_params), ))?, ); let client = reqwest::blocking::Client::builder() .user_agent(APP_USER_AGENT) .default_headers(headers) .build()?; 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), }; let res = req.send()?; if !res.status().is_success() { return Err(format!( "Got non-200 response: status {}, {}", res.status(), res.text()? ) .into()); } Ok(res) } type StdError = Result>; fn main() -> StdError<()> { let matches = clap::App::new(env!("CARGO_PKG_NAME")) .version(env!("CARGO_PKG_VERSION")) .subcommand(clap::SubCommand::with_name("authorize").about("Authorize the twitter application to access a user's account by popping open a web browser and returning the credentials once authorized.")).get_matches(); match matches.subcommand() { ("authorize", _) => do_authorize(), _ => run_bot(), } } fn do_authorize() -> StdError<()> { println!("Authorizing you lol!"); // Oauth1 leg 1 let res = twitter_api( TwitterEndpoint("oauth/request_token").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( "https://api.twitter.com/oauth/authenticate", [("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' ') .skip(1) .next() .ok_or("No target found")?, )?; let oauth_verifier = reqwest::Url::parse("https://example.net/")? .join(target.into())? .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(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("oauth/access_token").try_into()?, None, APIAction::Post(PostData::Data(&[("oauth_verifier", &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!")?) } fn run_bot() -> StdError<()> { let user_token = oauth1::Token::new( std::env::var(USER_TOKEN_ENV_VAR)?, std::env::var(USER_SECRET_ENV_VAR)?, ); let args: Vec = std::env::args().collect(); if args.len() < 2 { println!("usage: ./thing out.png"); } // Parse CSS selectors to scrape elements let gallerybox_sel = scraper::Selector::parse(".mw-body-content li.gallerybox") .map_err(|e| format!("{:?}", e))?; let link_sel = scraper::Selector::parse("a.image").map_err(|e| format!("{:?}", e))?; let title_sel = scraper::Selector::parse(".gallerytext p").map_err(|e| format!("{:?}", e))?; let original_sel = scraper::Selector::parse(".fullMedia a").map_err(|e| format!("{:?}", e))?; // Fetch stuff! let client = reqwest::blocking::Client::builder() .user_agent(APP_USER_AGENT) .build()?; println!("Fetching main page"); let txt = client .get("https://en.wikipedia.org/wiki/ISO_7010") .send()? .text()?; let page = scraper::Html::parse_document(txt.as_str()); let things = page .select(&gallerybox_sel) .map(|a| { let link = a .select(&link_sel) .next() .unwrap() .value() .attr("href") .unwrap(); let title = a .select(&title_sel) .next() .unwrap() .text() .collect::() .trim() .to_owned(); (title, link) }) .collect::>(); // Pick a random entry and fetch the original file let (title, link) = things .choose(&mut rand::thread_rng()) .ok_or_else(|| "got no images m8")?; println!("Fetching image page"); let media_page = client .get(format!("https://en.wikipedia.org{}", link)) .send()? .text()?; let page = scraper::Html::parse_document(media_page.as_str()); let link = page .select(&original_sel) .next() .unwrap() .value() .attr("href") .unwrap(); let svg = client.get(format!("https:{}", link)).send()?.bytes()?; let png_data = render_svg(&svg)?; let user: serde_json::Value = twitter_api( TwitterEndpoint("1.1/account/verify_credentials.json").try_into()?, Some(&user_token), APIAction::Get, &[], )? .json()?; println!( "Tweeting for user @{}, (id: {})", user["screen_name"], user["id"] ); println!("Uploading image..."); let img_id = upload_image(&user_token, Cow::from(png_data))?; let tweet = title; println!("Sending tweet..."); twitter_api( TwitterEndpoint("1.1/statuses/update.json").try_into()?, Some(&user_token), APIAction::Post(PostData::Data(&[ ("media_ids", &img_id.to_string()), ("status", tweet), ])), &[], )?; Ok(()) }