aboutsummaryrefslogtreecommitdiff
path: root/src
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
initial commit
Diffstat (limited to 'src')
-rw-r--r--src/bin/delete-recent.rs128
-rw-r--r--src/bots/fdroid/index.rs97
-rw-r--r--src/bots/fdroid/mod.rs572
-rw-r--r--src/bots/mod.rs1
-rw-r--r--src/main.rs224
-rw-r--r--src/report.rs57
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()))
+}