aboutsummaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2026-10-25 09:08:01 +0100
committerMartin Fischer <martin@push-f.com>2026-10-25 19:10:47 +0100
commit41212a7a15198e53d205252a02069a7dd6ddadeb (patch)
tree38a24eebf956b2453675ac1cefec925faf09de02 /src/main.rs
initial commit
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs224
1 files changed, 224 insertions, 0 deletions
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<Credentials, String> {
+ 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<Post>, 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<StrongRef> = None;
+ let mut parent: Option<StrongRef> = 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()
+}