mod bots; mod report; use std::{env, fs, num::NonZeroU64}; use atrium_api::app::bsky::embed::defs::AspectRatioData; use atrium_api::app::bsky::embed::gallery::{ImageData, MainData as GalleryData, MainItemsItem}; use atrium_api::app::bsky::feed::post::{RecordData, RecordEmbedRefs, ReplyRefData}; use atrium_api::com::atproto::repo::strong_ref::{Main as StrongRef, MainData as StrongRefData}; use atrium_api::types::{Union, string::Datetime}; use bsky_sdk::BskyAgent; use clap::{Parser, ValueEnum}; use report::{Post, Report}; use serde::Deserialize; pub(crate) const MAX_POST_GRAPHEMES: usize = 300; pub(crate) const MAX_POST_IMAGES: usize = 10; pub(crate) const MAX_IMAGE_SIZE: u64 = 2_000_000; #[derive(Debug, Parser)] struct Args { bot: Bot, report_kind: ReportKind, #[arg(long)] dry: bool, } #[derive(Clone, Debug, ValueEnum)] enum Bot { #[value(name = "fdroid")] FDroid, } #[derive(Clone, Debug, ValueEnum)] enum ReportKind { Breaking, } #[tokio::main] async fn main() -> Result<(), String> { tracing_subscriber::fmt::init(); let args = Args::parse(); let credentials = (!args.dry).then(load_credentials).transpose()?; let (posts, update_state) = match (args.bot, args.report_kind) { (Bot::FDroid, ReportKind::Breaking) => bots::fdroid::Breaking::report().await?, }; if args.dry { let post_count = posts.len(); if posts.is_empty() { println!("No posts to publish."); } for (index, post) in posts.into_iter().enumerate() { if index > 0 { println!(); } println!("Post {}/{}:\n{}", index + 1, post_count, post.text); for image in post.images { println!("Image: {}", image.alt); } } return Ok(()); } if !posts.is_empty() { publish(posts, credentials.unwrap()).await?; } // Do not advance the state unless every post was published successfully. update_state() } #[derive(Deserialize)] struct Credentials { identifier: String, password: String, } fn load_credentials() -> Result { let path = env::var("BSKY_CREDENTIALS_FILE").map_err(|_| { "BSKY_CREDENTIALS_FILE is not set; use --dry to run without publishing".to_owned() })?; let data = fs::read(&path).map_err(|error| format!("failed to read {path}: {error}"))?; serde_json::from_slice(&data).map_err(|error| format!("failed to parse {path}: {error}")) } async fn publish(posts: Vec, credentials: Credentials) -> Result<(), String> { let agent = BskyAgent::builder() .build() .await .map_err(|error| format!("failed to create Bluesky client: {error}"))?; agent .login(credentials.identifier, credentials.password) .await .map_err(|error| format!("failed to log in to Bluesky: {error}"))?; let post_count = posts.len(); let image_count: usize = posts.iter().map(|post| post.images.len()).sum(); let mut uploaded_image_count = 0; let mut records = Vec::with_capacity(post_count); for (index, post) in posts.into_iter().enumerate() { let embed = if post.images.is_empty() { None } else { let mut images = Vec::with_capacity(post.images.len()); for (image_index, image) in post.images.into_iter().enumerate() { let dimensions = imagesize::blob_size(&image.data).map_err(|error| { format!( "failed to determine dimensions of image {} for post {}: {error}", image_index + 1, index + 1 ) })?; let aspect_ratio = AspectRatioData { width: NonZeroU64::new(dimensions.width as u64).ok_or_else(|| { format!( "image {} for post {} has zero width", image_index + 1, index + 1 ) })?, height: NonZeroU64::new(dimensions.height as u64).ok_or_else(|| { format!( "image {} for post {} has zero height", image_index + 1, index + 1 ) })?, } .into(); uploaded_image_count += 1; tracing::info!( image = uploaded_image_count, images = image_count, "uploading image" ); let blob = agent .api .com .atproto .repo .upload_blob(image.data) .await .map_err(|error| { format!( "failed to upload image {} for post {}: {error}", image_index + 1, index + 1 ) })?; images.push(Union::Refs(MainItemsItem::Image(Box::new( ImageData { alt: image.alt, aspect_ratio, image: blob.data.blob, } .into(), )))); } Some(Union::Refs(RecordEmbedRefs::AppBskyEmbedGalleryMain( Box::new(GalleryData { items: images }.into()), ))) }; records.push(RecordData { created_at: Datetime::now(), text: post.text, facets: Some(post.facets), embed, langs: Some(post.langs), tags: Some(post.tags), entities: None, labels: None, reply: None, }); } let mut root: Option = None; let mut parent: Option = None; for (index, mut record) in records.into_iter().enumerate() { if let Some(parent) = parent.as_ref() { record.reply = Some( ReplyRefData { parent: parent.clone(), root: root.as_ref().unwrap().clone(), } .into(), ); } tracing::info!(post = index + 1, posts = post_count, "creating post"); let output = agent .create_record(record) .await .map_err(|error| format!("failed to publish post {}: {error}", index + 1))?; tracing::info!( post = index + 1, posts = post_count, uri = %output.uri, cid = ?output.cid, "created post" ); let reference: StrongRef = StrongRefData { uri: output.uri.clone(), cid: output.cid.clone(), } .into(); root.get_or_insert_with(|| reference.clone()); parent = Some(reference); } Ok(()) } pub(crate) fn http_client() -> reqwest::Client { reqwest::Client::builder() .user_agent(env!("CARGO_PKG_NAME")) .build() .unwrap() }