From 41212a7a15198e53d205252a02069a7dd6ddadeb Mon Sep 17 00:00:00 2001 From: Martin Fischer Date: Sun, 25 Oct 2026 09:08:01 +0100 Subject: initial commit --- src/main.rs | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 src/main.rs (limited to 'src/main.rs') diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..0a5b264 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,224 @@ +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, + entities: None, + labels: None, + langs: None, + reply: None, + tags: 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() +} -- cgit v1.3.1