use rand::seq::SliceRandom; use std::convert::TryInto; use std::io::Cursor; use tracing::{event, Level}; mod twitter; mod wiki; use clap::{Parser, Subcommand}; use image::{DynamicImage, RgbaImage}; use tiny_skia::{Paint, PathBuilder, Pixmap, PixmapPaint, Stroke, Transform}; use twitter::*; use wiki::*; static APP_USER_AGENT: &str = concat!( "bot_", env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"), " ", "reqwest", ); // Render the raw SVG data to an image fn render_svg(data: &[u8], height: u32, with_border: bool) -> StdError { let opt = usvg::Options::default(); let rtree = usvg::Tree::from_data(data, &opt.to_ref()).expect("couldn't parse"); let svg_size = rtree.svg_node().size; // Work out how wide the pixmap of height `height` needs to be to entirely fit the SVG. let pm_width = ((height as f64 / svg_size.height()) * svg_size.width()).ceil() as u32; let mut pixmap = Pixmap::new(pm_width, height).ok_or("Error creating pixmap")?; // Render the svg into a pixmap. resvg::render(&rtree, usvg::FitTo::Height(height), pixmap.as_mut()) .ok_or("Error rendering svg")?; // Make a wider pixmap with a 16:9 AR and the same height. This is a blesséd ratio by twitter // and means we see the whole image nicely in the timeline with no truncation. let mut bigger_pixmap = Pixmap::new(height / 9 * 16, height).ok_or("Error creating bigger pixmap")?; // Then draw our freshly rendered SVG into the middle of the bigger pixmap. bigger_pixmap .draw_pixmap( ((bigger_pixmap.width() - pm_width) / 2).try_into().unwrap(), 0, pixmap.as_ref(), &PixmapPaint::default(), Transform::identity(), None, ) .ok_or("Error drawing onto bigger pixmap")?; let (w, h) = (bigger_pixmap.width(), bigger_pixmap.height()); // Render a red border for debug purposes if with_border { let mut paint = Paint::default(); paint.set_color_rgba8(255, 0, 0, 255); let stroke = Stroke { width: 1.0, ..Default::default() }; let path = { let mut pb = PathBuilder::new(); pb.move_to(0.0, 0.0); pb.line_to(0.0, h as f32 - stroke.width); pb.line_to(w as f32, h as f32 - stroke.width); pb.line_to(w as f32 - stroke.width, 0.0); pb.line_to(0.0, 0.0); pb.finish().unwrap() }; bigger_pixmap.stroke_path(&path, &paint, &stroke, Transform::identity(), None); } let img = RgbaImage::from_raw( bigger_pixmap.width(), bigger_pixmap.height(), bigger_pixmap.data().to_vec(), ) .ok_or("Error creating image from pixmap")?; Ok(DynamicImage::ImageRgba8(img)) } type StdError = Result>; #[derive(Parser)] #[clap(author, version, about)] struct Cli { #[clap(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Authorize the twitter application to acccess a user's account Authorize, /// Scrape images from the category on wikimedia commons ScrapeCategory, /// Scrape images from the iso7010 wikipedia page ScrapeWeb, /// List tweets from the authed user's timeline ListTweets, /// Run the bot - scrape, pick a random entry and tweet it RunBot { #[clap(short, long, action)] dry_run: bool, }, /// Print details about the currently authed user Whoami, } fn main() -> StdError<()> { tracing_subscriber::fmt::init(); let cli = Cli::parse(); match &cli.command { Commands::Authorize => do_authorize(), Commands::ScrapeCategory => do_scrape_category(), Commands::ScrapeWeb => do_scrape_web(), Commands::ListTweets => do_list_tweets(), Commands::Whoami => do_whoami(), Commands::RunBot { dry_run } => run_bot(*dry_run), } } fn do_whoami() -> StdError<()> { let user_token = user_token_from_env(); let user: serde_json::Value = twitter_api( TwitterEndpoint::VerifyCredentials.try_into()?, Some(&user_token), APIAction::Get, &[], )? .json()?; println!("User @{}, (id: {})", user["screen_name"], user["id"]); Ok(()) } fn do_list_tweets() -> StdError<()> { let user_token = user_token_from_env(); let user = twitter_api( TwitterEndpoint::VerifyCredentials.try_into()?, Some(&user_token), APIAction::Get, &[], )? .json::()?; let id = user["id"].as_u64().unwrap(); let timeline: serde_json::Value = twitter_api( reqwest::Url::parse_with_params( &TwitterEndpoint::UserTimeline.to_string(), [ ("count", "200"), ("exclude_replies", "true"), ("include_retweets", "false"), ("trim_user", "true"), ("user_id", id.to_string().as_ref()), ], )?, Some(&user_token), APIAction::Get, &[], )? .json()?; for tweet in timeline.as_array().unwrap() { let tweet = tweet.as_object().unwrap(); println!("{}, \"{}\"", tweet["id"], tweet["text"]); } Ok(()) } fn do_scrape_category() -> StdError<()> { let mut files = get_files_in_category("Category:ISO_7010_safety_signs_(vector_drawings)")?; files.sort(); for f in files { println!("{}", f); } Ok(()) } fn do_scrape_web() -> StdError<()> { let mut files: Vec<_> = scrape_web()?.into_iter().map(|(_, file)| file).collect(); files.sort(); for f in files { println!("{}", f); } Ok(()) } fn get_client(headers: Option) -> StdError { let mut c = reqwest::blocking::Client::builder().user_agent(APP_USER_AGENT); if let Some(headers) = headers { c = c.default_headers(headers); } Ok(c.build()?) } fn run_bot(dry_run: bool) -> StdError<()> { let all = scrape_web()?; let (title, filename) = all .choose(&mut rand::thread_rng()) .ok_or("got no images m8")?; event!(Level::INFO, title, filename, "Picked random thing"); let client = get_client(None)?; event!(Level::INFO, "Fetching metadata..."); // TODO: could crash, probably doesn't matter let meta = get_file_metadata(vec![filename])?.remove(0); event!(Level::INFO, %meta, "Got metadata"); event!(Level::INFO, url = meta.url.to_string(), "Fetching image"); let svg = client.get(meta.url).send()?.bytes()?; let text = format!( "{}\n\nImage source: {}\nAuthor: Wikimedia Commons user {}\n{}{}", title, meta.html_url, meta.author, meta.license_short_name, meta.license_url .map_or("".to_owned(), |u| format!(" ({})", u)) ); if !dry_run { // Render the image nice and big for twitter let img = render_svg(&svg, 1000, false)?; let mut buf = Cursor::new(Vec::new()); img.write_to(&mut buf, image::ImageFormat::Png)?; tweet(&text, Some(buf.into_inner().into()))?; } else { // Render the image smaller for output to terminal let img = render_svg(&svg, 128, true)?; println!("Dry run - would tweet:\n \"{}\"", text); viuer::print( &img, &viuer::Config { absolute_offset: false, width: Some(32), ..Default::default() }, )?; } Ok(()) }