iso7010-a-day/src/main.rs

336 lines
10 KiB
Rust
Raw Normal View History

2021-10-24 13:36:13 +01:00
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<Vec<u8>, Box<dyn std::error::Error>> {
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<reqwest::Url> for TwitterEndpoint<'_> {
type Error = url::ParseError;
fn try_into(self) -> Result<reqwest::Url, Self::Error> {
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<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 oauth_params: HashMap<&str, Cow<str>> = 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<T> = Result<T, Box<dyn std::error::Error>>;
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::<Option<_>>()
.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::<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!")?)
}
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<String> = 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::<String>()
.trim()
.to_owned();
(title, link)
})
.collect::<Vec<(String, &str)>>();
// 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(())
}