diff options
| author | Martin Fischer <martin@push-f.com> | 2026-10-25 09:08:01 +0100 |
|---|---|---|
| committer | Martin Fischer <martin@push-f.com> | 2026-10-25 19:10:47 +0100 |
| commit | 41212a7a15198e53d205252a02069a7dd6ddadeb (patch) | |
| tree | 38a24eebf956b2453675ac1cefec925faf09de02 /src | |
initial commit
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/delete-recent.rs | 128 | ||||
| -rw-r--r-- | src/bots/fdroid/index.rs | 97 | ||||
| -rw-r--r-- | src/bots/fdroid/mod.rs | 572 | ||||
| -rw-r--r-- | src/bots/mod.rs | 1 | ||||
| -rw-r--r-- | src/main.rs | 224 | ||||
| -rw-r--r-- | src/report.rs | 57 |
6 files changed, 1079 insertions, 0 deletions
diff --git a/src/bin/delete-recent.rs b/src/bin/delete-recent.rs new file mode 100644 index 0000000..5486555 --- /dev/null +++ b/src/bin/delete-recent.rs @@ -0,0 +1,128 @@ +use std::{env, fs}; + +use atrium_api::{ + app::bsky::feed::post::RecordData, + com::atproto::repo::list_records::{OutputData, ParametersData}, + types::{ + TryFromUnknown, + string::{AtIdentifier, Datetime}, + }, +}; +use bsky_sdk::BskyAgent; +use chrono::{Duration, Utc}; +use clap::Parser; +use serde::Deserialize; + +#[derive(Debug, Parser)] +#[command(about = "Delete posts created by the account during the last hour")] +struct Args { + /// Print matching posts without deleting them. + #[arg(long)] + dry: bool, +} + +#[derive(Deserialize)] +struct Credentials { + identifier: String, + password: String, +} + +#[tokio::main] +async fn main() -> Result<(), String> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let credentials = load_credentials()?; + let repo: AtIdentifier = credentials + .identifier + .parse() + .map_err(|error| format!("invalid Bluesky identifier: {error}"))?; + let cutoff = Datetime::new((Utc::now() - Duration::hours(1)).fixed_offset()); + + 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 mut cursor = None; + let mut posts = Vec::new(); + let mut reached_cutoff = false; + + while !reached_cutoff { + let output = agent + .api + .com + .atproto + .repo + .list_records( + ParametersData { + collection: "app.bsky.feed.post".parse().unwrap(), + cursor, + limit: Some(100.try_into().unwrap()), + repo: repo.clone(), + // listRecords is newest-first by default. + reverse: None, + } + .into(), + ) + .await + .map_err(|error| format!("failed to list posts: {error}"))?; + + let OutputData { + records, + cursor: next_cursor, + } = output.data; + for record in records { + let record = record.data; + let post = RecordData::try_from_unknown(record.value) + .map_err(|error| format!("failed to parse post {}: {error}", record.uri))?; + if post.created_at < cutoff { + reached_cutoff = true; + break; + } + posts.push((record.uri, post.text)); + } + + cursor = next_cursor; + if cursor.is_none() { + break; + } + } + + if posts.is_empty() { + println!("No posts from the last hour."); + return Ok(()); + } + + for (uri, text) in &posts { + if args.dry { + println!("Would delete {uri}: {text}"); + } else { + tracing::info!(%uri, "deleting post"); + agent + .delete_record(uri) + .await + .map_err(|error| format!("failed to delete {uri}: {error}"))?; + println!("Deleted {uri}: {text}"); + } + } + + if args.dry { + println!("{} post(s) would be deleted.", posts.len()); + } else { + println!("Deleted {} post(s).", posts.len()); + } + + Ok(()) +} + +fn load_credentials() -> Result<Credentials, String> { + let path = env::var("BSKY_CREDENTIALS_FILE") + .map_err(|_| "BSKY_CREDENTIALS_FILE is not set".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}")) +} diff --git a/src/bots/fdroid/index.rs b/src/bots/fdroid/index.rs new file mode 100644 index 0000000..61cc44d --- /dev/null +++ b/src/bots/fdroid/index.rs @@ -0,0 +1,97 @@ +//! Types for the F-Droid repository index. + +use std::collections::HashMap; + +use serde::{Deserialize, Deserializer}; + +pub(super) const PHONE_DEVICE_TYPE: &str = "phone"; +pub(super) const EN_LOCALE: &str = "en-US"; + +#[derive(Debug, Deserialize)] +pub(super) struct Index { + pub(super) repo: Repo, + pub(super) packages: HashMap<String, Package>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Repo { + pub(super) timestamp: u64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Package { + pub(super) metadata: Metadata, + /// Releases keyed by the APK's SHA-256 digest. + pub(super) versions: HashMap<String, Version>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Version { + /// Anti-feature explanations keyed first by anti-feature name and then by locale. + #[serde(default, rename = "antiFeatures")] + pub(super) anti_features: HashMap<AntiFeature, HashMap<String, String>>, +} + +/// An [anti-feature](https://f-droid.org/docs/Anti-Features/). +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(super) enum AntiFeature { + Ads, + DisabledAlgorithm, + KnownVuln, + NoSourceSince, + NonFreeAdd, + NonFreeAssets, + NonFreeDep, + NonFreeNet, + TetheredNet, + Tracking, + Other(String), +} + +impl<'de> Deserialize<'de> for AntiFeature { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let name = String::deserialize(deserializer)?; + Ok(match name.as_str() { + "Ads" => Self::Ads, + "DisabledAlgorithm" => Self::DisabledAlgorithm, + "KnownVuln" => Self::KnownVuln, + "NoSourceSince" => Self::NoSourceSince, + "NonFreeAdd" => Self::NonFreeAdd, + "NonFreeAssets" => Self::NonFreeAssets, + "NonFreeDep" => Self::NonFreeDep, + "NonFreeNet" => Self::NonFreeNet, + "TetheredNet" => Self::TetheredNet, + "Tracking" => Self::Tracking, + _ => { + tracing::warn!("observed unknown F-Droid anti-feature {name}"); + Self::Other(name) + } + }) + } +} + +#[derive(Debug, Deserialize)] +pub(super) struct Metadata { + pub(super) added: u64, + /// App names keyed by locale. Every package has an `en-US` name. + #[serde(default)] + pub(super) name: HashMap<String, String>, + /// Short app descriptions keyed by locale. + #[serde(default)] + pub(super) summary: HashMap<String, String>, + /// Screenshots keyed first by device type and then by locale. + #[serde(default)] + pub(super) screenshots: HashMap<String, HashMap<String, Vec<Screenshot>>>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Screenshot { + /// Path to the screenshot relative to the repository address. + pub(super) name: String, + /// Screenshot file size in bytes, retained for enforcing upload limits. + #[allow(dead_code)] + pub(super) size: u64, +} diff --git a/src/bots/fdroid/mod.rs b/src/bots/fdroid/mod.rs new file mode 100644 index 0000000..c0e7198 --- /dev/null +++ b/src/bots/fdroid/mod.rs @@ -0,0 +1,572 @@ +//! F-Droid + +mod index; + +use atrium_api::app::bsky::richtext::facet::{ByteSliceData, LinkData, MainData, MainFeaturesItem}; +use atrium_api::types::Union; +use reqwest::{ + StatusCode, + header::{ETAG, IF_NONE_MATCH}, +}; +use serde::{Deserialize, Serialize}; +use unicode_segmentation::UnicodeSegmentation; + +use self::index::{AntiFeature, EN_LOCALE, Index, PHONE_DEVICE_TYPE, Package, Screenshot}; +use crate::{ + MAX_IMAGE_SIZE, MAX_POST_GRAPHEMES, MAX_POST_IMAGES, http_client, + report::{Post, PostImage, Report}, +}; + +const REPOSITORY_URL: &str = "https://f-droid.org/repo"; +const PACKAGE_URL_PREFIX: &str = "https://f-droid.org/packages/"; +const ANTI_FEATURES_EXCLUDING_APPS: &[AntiFeature] = + &[AntiFeature::KnownVuln, AntiFeature::NoSourceSince]; + +#[derive(Debug, Default, Deserialize, Serialize)] +pub struct BreakingState { + pub last_repo_timestamp: Option<u64>, + pub index_etag: Option<String>, +} + +pub struct Breaking; + +impl Report for Breaking { + type State = BreakingState; + + const STATE_FILE: &'static str = "fdroid-breaking.json"; + + async fn generate(state: Self::State) -> Result<(Vec<Post>, Self::State), String> { + let mut request = http_client().get(format!("{REPOSITORY_URL}/index-v2.json")); + if let Some(etag) = state.index_etag.as_deref() { + request = request.header(IF_NONE_MATCH, etag); + } + + let response = request + .send() + .await + .map_err(|error| format!("failed to fetch F-Droid index: {error}"))?; + if response.status() == StatusCode::NOT_MODIFIED { + return Ok((Vec::new(), state)); + } + + let response = response + .error_for_status() + .map_err(|error| format!("failed to fetch F-Droid index: {error}"))?; + let index_etag = response + .headers() + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let index = response + .json::<Index>() + .await + .map_err(|error| format!("failed to parse F-Droid index: {error}"))?; + + let Some(timestamp) = state.last_repo_timestamp else { + // We don't know the timestamp of the last index version. + tracing::info!( + timestamp = index.repo.timestamp, + "recording initial repository timestamp; will announce new apps with the next index version" + ); + let new_state = BreakingState { + last_repo_timestamp: Some(index.repo.timestamp), + index_etag, + }; + return Ok((Vec::new(), new_state)); + }; + + if index.repo.timestamp < timestamp { + let error = format!( + "F-Droid repository timestamp moved backwards from {timestamp} to {}", + index.repo.timestamp + ); + tracing::error!("{error}"); + return Err(error); + } + + let mut apps: Vec<_> = index + .packages + .iter() + .filter_map(|(id, package)| { + (package.metadata.added > timestamp) + .then(|| App::from_package(id.as_str(), package)) + }) + .collect(); + + tracing::info!( + timestamp = index.repo.timestamp, + new_apps = apps.len(), + total_apps = index.packages.len(), + "building posts" + ); + + apps.sort_unstable_by(|left, right| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.id.cmp(right.id)) + }); + + Ok(( + download_images(build_announcement_posts(&apps)).await?, + BreakingState { + last_repo_timestamp: Some(index.repo.timestamp), + index_etag, + }, + )) + } +} + +struct App<'a> { + id: &'a str, + name: &'a str, + summary: &'a str, + screenshot: Option<&'a Screenshot>, + anti_features: Vec<AntiFeature>, +} + +impl<'a> App<'a> { + fn from_package(id: &'a str, package: &'a Package) -> Self { + Self { + id, + name: package + .metadata + .name + .get(EN_LOCALE) + .map_or(id, String::as_str), + summary: package + .metadata + .summary + .get(EN_LOCALE) + .map_or("", String::as_str), + screenshot: package + .metadata + .screenshots + .get(PHONE_DEVICE_TYPE) + .and_then(|screenshots| screenshots.get(EN_LOCALE)) + .and_then(|screenshots| { + screenshots + .iter() + // We could resize images but realistically they should fit. + .find(|screenshot| screenshot.size < MAX_IMAGE_SIZE) + }), + anti_features: package + .versions + .values() + .flat_map(|version| version.anti_features.keys()) + // "Tethered network service" means an app depends on a network service, + // which isn't easy to self-host. There's a lot of such network services out there + // be it OpenStreetMap or a public transit API ... not much apps can do about that. + // So we just disregard this anti-feature in this bot. + .filter(|&anti_feature| anti_feature != &AntiFeature::TetheredNet) + .cloned() + .collect(), + } + } +} + +fn build_announcement_posts(apps: &[App<'_>]) -> Vec<PendingPost> { + let apps: Vec<_> = apps + .iter() + .filter(|app| { + let Some(anti_feature) = ANTI_FEATURES_EXCLUDING_APPS + .iter() + .find(|anti_feature| app.anti_features.contains(anti_feature)) + else { + return true; + }; + + tracing::info!( + app_id = app.id, + app_name = app.name, + "omitting F-Droid app due to anti-feature {anti_feature:?}" + ); + false + }) + .collect(); + + if apps.is_empty() { + return Vec::new(); + } + + let mut builders = vec![PostBuilder::new(format!( + "{} new F-Droid packages are available:", + apps.len() + ))]; + + let (without_anti_features, with_anti_features): (Vec<_>, Vec<_>) = apps + .into_iter() + .partition(|app| app.anti_features.is_empty()); + let (with_screenshots, without_screenshots): (Vec<_>, Vec<_>) = without_anti_features + .into_iter() + .partition(|app| app.screenshot.is_some()); + + for app in with_screenshots { + let name = truncate(app.name, MAX_POST_GRAPHEMES); + let screenshot = app.screenshot.unwrap(); + let image = PendingImage { + url: format!("{REPOSITORY_URL}{}", screenshot.name), + // Using the summary for the alt text isn't great + // but the Bluesky lexicon provides no alternative. + alt: format!("{}: {}", app.name, app.summary), + }; + + let post = builders.last().unwrap(); + let separator = if post.text.is_empty() { + "" + } else if post.images.is_empty() { + "\n" + } else { + ", " + }; + if !post.can_fit(separator, &name) || post.images.len() == MAX_POST_IMAGES { + builders.push(PostBuilder::new(String::new())); + } + + let post = builders.last_mut().unwrap(); + let separator = if post.text.is_empty() { + "" + } else if post.images.is_empty() { + "\n" + } else { + ", " + }; + post.push_app(separator, &name, &name, app.id, Some(image)); + } + + for app in without_screenshots { + push_bullet_app(&mut builders, app); + } + + if let Some((first, rest)) = with_anti_features.split_first() { + const ANTI_FEATURES_HEADING: &str = + "New apps with an anti-feature (other than tethered network):"; + let line_limit = + MAX_POST_GRAPHEMES - grapheme_count(ANTI_FEATURES_HEADING) - grapheme_count("\n"); + let (name, line) = app_line(first, line_limit); + + // Keep anti-feature apps in their own section, starting at a post boundary. + builders.push(PostBuilder::new(ANTI_FEATURES_HEADING.to_owned())); + builders + .last_mut() + .unwrap() + .push_app("\n", &line, &name, first.id, None); + + for app in rest { + push_bullet_app(&mut builders, app); + } + } + + builders.into_iter().map(PostBuilder::finish).collect() +} + +fn push_bullet_app(builders: &mut Vec<PostBuilder>, app: &App<'_>) { + let (name, line) = app_line(app, MAX_POST_GRAPHEMES); + let post = builders.last().unwrap(); + let separator = if post.text.is_empty() { "" } else { "\n" }; + if !post.can_fit(separator, &line) { + builders.push(PostBuilder::new(String::new())); + } + + let post = builders.last_mut().unwrap(); + let separator = if post.text.is_empty() { "" } else { "\n" }; + post.push_app(separator, &line, &name, app.id, None); +} + +/// Build a bullet that fits within `limit`, preserving as much of the summary as possible. +fn app_line(app: &App<'_>, limit: usize) -> (String, String) { + let prefix = "* "; + let anti_features = anti_feature_annotation(&app.anti_features); + let suffix = ": "; + let fixed = grapheme_count(prefix) + + grapheme_count(app.name) + + grapheme_count(&anti_features) + + grapheme_count(suffix); + + if fixed <= limit { + let summary = truncate(app.summary, limit - fixed); + ( + app.name.to_owned(), + format!("{prefix}{}{anti_features}{suffix}{summary}", app.name), + ) + } else { + let reserved = grapheme_count(prefix) + grapheme_count(&anti_features); + let name = truncate(app.name, limit.saturating_sub(reserved)); + let line = format!("{prefix}{name}{anti_features}"); + (name, line) + } +} + +fn anti_feature_annotation(anti_features: &[AntiFeature]) -> String { + if anti_features.is_empty() { + return String::new(); + } + + let mut icons: Vec<_> = anti_features + .iter() + .map(|anti_feature| match anti_feature { + AntiFeature::Tracking => (0, "๐๏ธ"), + AntiFeature::Ads => (1, "๐ชง"), + AntiFeature::NonFreeNet => (2, "โ๏ธ"), + AntiFeature::NonFreeAssets => (3, "๐ผ๏ธ"), + AntiFeature::NonFreeAdd => (4, "๐งฉ"), + AntiFeature::NonFreeDep => (5, "๐ฆ"), + AntiFeature::DisabledAlgorithm => (6, "๐"), // shouldn't happen for new apps + AntiFeature::Other(_) => (7, "โ"), + AntiFeature::KnownVuln | AntiFeature::NoSourceSince | AntiFeature::TetheredNet => { + unreachable!() + } + }) + .collect(); + icons.sort_unstable_by_key(|(order, _)| *order); + icons.dedup_by_key(|(order, _)| *order); + + format!( + " ({})", + icons + .into_iter() + .map(|(_, icon)| icon) + .collect::<Vec<_>>() + .join("") + ) +} + +fn truncate(text: &str, limit: usize) -> String { + let length = grapheme_count(text); + if length <= limit { + return text.to_owned(); + } + if limit == 0 { + return String::new(); + } + + let mut shortened: String = text.graphemes(true).take(limit - 1).collect(); + shortened.push('โฆ'); + shortened +} + +fn grapheme_count(text: &str) -> usize { + text.graphemes(true).count() +} + +struct PendingPost { + text: String, + facets: Vec<atrium_api::app::bsky::richtext::facet::Main>, + images: Vec<PendingImage>, +} + +struct PendingImage { + url: String, + alt: String, +} + +struct PostBuilder { + text: String, + graphemes: usize, + facets: Vec<atrium_api::app::bsky::richtext::facet::Main>, + images: Vec<PendingImage>, +} + +impl PostBuilder { + fn new(text: String) -> Self { + let graphemes = grapheme_count(&text); + Self { + text, + graphemes, + facets: Vec::new(), + images: Vec::new(), + } + } + + fn can_fit(&self, separator: &str, text: &str) -> bool { + self.graphemes + grapheme_count(separator) + grapheme_count(text) <= MAX_POST_GRAPHEMES + } + + fn push_app( + &mut self, + separator: &str, + line: &str, + name: &str, + id: &str, + image: Option<PendingImage>, + ) { + self.text.push_str(separator); + let byte_start = self.text.len() + line.find(name).unwrap_or(0); + self.text.push_str(line); + let byte_end = byte_start + name.len(); + self.graphemes += grapheme_count(separator) + grapheme_count(line); + + self.facets.push( + MainData { + features: vec![Union::Refs(MainFeaturesItem::Link(Box::new( + LinkData { + uri: format!("{PACKAGE_URL_PREFIX}{id}/"), + } + .into(), + )))], + index: ByteSliceData { + byte_start, + byte_end, + } + .into(), + } + .into(), + ); + if let Some(image) = image { + self.images.push(image); + } + } + + fn finish(self) -> PendingPost { + PendingPost { + text: self.text, + facets: self.facets, + images: self.images, + } + } +} + +async fn download_images(pending_posts: Vec<PendingPost>) -> Result<Vec<Post>, String> { + let client = http_client(); + let mut posts = Vec::with_capacity(pending_posts.len()); + + for (post_index, post) in pending_posts.into_iter().enumerate() { + let mut images = Vec::with_capacity(post.images.len()); + for image in post.images { + let data = client + .get(&image.url) + .send() + .await + .and_then(reqwest::Response::error_for_status) + .map_err(|error| { + format!( + "failed to download image {} for post {}: {error}", + image.url, + post_index + 1 + ) + })? + .bytes() + .await + .map_err(|error| { + format!( + "failed to download image {} for post {}: {error}", + image.url, + post_index + 1 + ) + })?; + images.push(PostImage { + data: data.to_vec(), + alt: image.alt, + }); + } + posts.push(Post { + text: post.text, + facets: post.facets, + images, + }); + } + + Ok(posts) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_announcement_posts() { + assert!(build_announcement_posts(&[]).is_empty()); + + let screenshot = Screenshot { + name: "/screenshots/camera.png".to_owned(), + size: 123, + }; + let apps = [ + App { + id: "org.example.camera", + name: "Camera ๐ท", + summary: "Take photos", + screenshot: Some(&screenshot), + anti_features: Vec::new(), + }, + App { + id: "org.example.notes", + name: "Notes", + summary: "Take notes", + screenshot: None, + anti_features: Vec::new(), + }, + App { + id: "org.example.tracker", + name: "Tracker", + summary: "Tracks users", + screenshot: None, + anti_features: vec![ + AntiFeature::Other("UnknownFeature".to_owned()), + AntiFeature::Tracking, + AntiFeature::NonFreeNet, + AntiFeature::NonFreeDep, + AntiFeature::NonFreeAssets, + AntiFeature::NonFreeAdd, + AntiFeature::DisabledAlgorithm, + AntiFeature::Ads, + ], + }, + App { + id: "org.example.vulnerable", + name: "Vulnerable", + summary: "Has a known vulnerability", + screenshot: None, + anti_features: vec![AntiFeature::KnownVuln], + }, + App { + id: "org.example.abandoned", + name: "Abandoned", + summary: "No longer has source available", + screenshot: None, + anti_features: vec![AntiFeature::NoSourceSince], + }, + ]; + + let posts = build_announcement_posts(&apps); + + assert_eq!(posts.len(), 2); + assert_eq!( + posts[0].text, + "3 new F-Droid packages are available:\n\ + Camera ๐ท\n\ + * Notes: Take notes" + ); + assert_eq!( + posts[1].text, + "New apps with an anti-feature (other than tethered network):\n\ + * Tracker (๐๏ธ๐ชงโ๏ธ๐ผ๏ธ๐งฉ๐ฆ๐โ): Tracks users" + ); + assert_eq!(posts[0].images.len(), 1); + assert_eq!( + posts[0].images[0].url, + "https://f-droid.org/repo/screenshots/camera.png" + ); + assert_eq!(posts[0].images[0].alt, "Camera ๐ท: Take photos"); + assert!(posts[1].images.is_empty()); + + let expected_links = [ + (&posts[0], "Camera ๐ท", "org.example.camera"), + (&posts[0], "Notes", "org.example.notes"), + (&posts[1], "Tracker", "org.example.tracker"), + ]; + for (post, name, id) in expected_links { + let facet = post + .facets + .iter() + .find(|facet| &post.text[facet.index.byte_start..facet.index.byte_end] == name) + .unwrap(); + match facet.features.as_slice() { + [Union::Refs(MainFeaturesItem::Link(link))] => { + assert_eq!(link.uri, format!("{PACKAGE_URL_PREFIX}{id}/")); + } + features => panic!("expected one link feature, got {features:?}"), + } + } + } +} diff --git a/src/bots/mod.rs b/src/bots/mod.rs new file mode 100644 index 0000000..63414d3 --- /dev/null +++ b/src/bots/mod.rs @@ -0,0 +1 @@ +pub mod fdroid; 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() +} diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..d776469 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,57 @@ +use std::{fs, io, path::Path}; + +use atrium_api::app::bsky::richtext::facet::Main as Facet; +use serde::{Serialize, de::DeserializeOwned}; + +pub(crate) struct Post { + pub text: String, + pub facets: Vec<Facet>, + /// Images to upload before creating the post record. + pub images: Vec<PostImage>, +} + +pub(crate) struct PostImage { + pub data: Vec<u8>, + pub alt: String, +} + +pub(crate) trait Report: Sized { + type State: Default + DeserializeOwned + Serialize; + + const STATE_FILE: &'static str; + + async fn generate(state: Self::State) -> Result<(Vec<Post>, Self::State), String>; + + async fn report() -> Result<(Vec<Post>, impl FnOnce() -> Result<(), String>), String> { + let state_path = Path::new(Self::STATE_FILE); + let state = load_state(state_path)?; + let (posts, new_state) = Self::generate(state).await?; + Ok((posts, move || save_state(state_path, &new_state))) + } +} + +fn load_state<T>(path: &Path) -> Result<T, String> +where + T: Default + DeserializeOwned, +{ + match fs::read(path) { + Ok(data) => serde_json::from_slice(&data) + .map_err(|error| format!("failed to parse {}: {error}", path.display())), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(T::default()), + Err(error) => Err(format!("failed to read {}: {error}", path.display())), + } +} + +fn save_state<T>(path: &Path, state: &T) -> Result<(), String> +where + T: Serialize, +{ + let data = serde_json::to_vec_pretty(state) + .map_err(|error| format!("failed to serialize {}: {error}", path.display()))?; + let temporary_path = path.with_extension("json.tmp"); + + fs::write(&temporary_path, data) + .map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?; + fs::rename(&temporary_path, path) + .map_err(|error| format!("failed to replace {}: {error}", path.display())) +} |
