diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4327f8c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,164 @@ +pub mod model; + +use clap::Parser; + +#[derive(Debug, Clone, Parser)] +struct CliArgs { + homeserver: String, + + token: String, + + user_id: String, +} + +pub struct FediClient { + http: reqwest::Client, + homeserver: String, + token: String, + user_id: String, +} + +impl FediClient { + pub fn new(homeserver: String, user_id: String, token: String) -> Self { + let http = reqwest::Client::default(); + FediClient { + http, homeserver, token, user_id, + } + } + + pub fn url(&self, endpoint: &str) -> String { + format!("https://{}/api/v1/{}", self.homeserver, endpoint) + } + + pub fn headers(&self) -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("Authorization", format!("Bearer {}", self.token).parse().unwrap()); + headers + } + + pub async fn lists(&self) -> reqwest::Result> { + self.http + .get(self.url("lists")) + .headers(self.headers()) + .send() + .await? + .error_for_status()? + .json() + .await + } + + pub async fn list_accounts(&self, list: &str) -> reqwest::Result> { + self.http + .get(self.url(&format!("lists/{list}/accounts"))) + .headers(self.headers()) + .query(&[("with_relationships", true)]) // TODO ?? without &[] it panics: "unsupported pair" + .send() + .await? + .error_for_status()? + .json() + .await + } + + pub async fn list_add_accounts(&self, list: &str, account_ids: Vec) -> reqwest::Result<()> { + self.http + .post(self.url(&format!("lists/{list}/accounts"))) + .headers(self.headers()) + .json(&model::FediAccountIds { account_ids }) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + pub async fn list_remove_accounts(&self, list: &str, account_ids: Vec) -> reqwest::Result<()> { + self.http + .delete(self.url(&format!("lists/{list}/accounts"))) + .headers(self.headers()) + .json(&model::FediAccountIds { account_ids }) + .send() + .await? + .error_for_status()?; + Ok(()) + } + + async fn get_related_users(&self, user_id: &str, relation: &str) -> reqwest::Result> { + let mut out = Vec::new(); + let limit = 40; // TODO make it customizable? idk + loop { + let mut page : Vec = self.http + .get(self.url(&format!("accounts/{user_id}/{relation}"))) + .headers(self.headers()) + .query(&[("limit", limit)]) + .query(&[("with_relationships", true)]) + .send() + .await? + .error_for_status()? + .json() + .await?; + let count = page.len(); + out.append(&mut page); // TODO can i just concat these vecs at the end? + if count != limit { break } // end of pages + } + Ok(out) + } + + pub async fn followers(&self) -> reqwest::Result> { + self.get_related_users(&self.user_id, "followers").await + } + + pub async fn following(&self) -> reqwest::Result> { + self.get_related_users(&self.user_id, "following").await + } +} + + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .compact() + .init(); + + let args = CliArgs::parse(); + + let client = FediClient::new(args.homeserver, args.user_id, args.token); + + let mut mutuals = std::collections::BTreeMap::new(); + + client.followers() + .await.unwrap() + .into_iter() + .filter(|u| match u.pleroma.relationship { + model::EmptyOption::Some(ref relation) => relation.following, + model::EmptyOption::None {} => { + tracing::warn!("missing relationships for user '{}', skipping", u.fqn); + false + }, + }).for_each(|u| { mutuals.insert(u.fqn.clone(), u); }); + + let lists = client.lists().await.unwrap(); + // TODO create mutuals list if not present + let mutuals_list = lists.iter().find(|l| l.title == "mutuals").expect("no mutuals list!"); + let current_mutuals = client.list_accounts(&mutuals_list.id).await.unwrap(); + + // first remove all users that aren't mutuals anymore + let mut user_ids = Vec::new(); + for usr in current_mutuals { + if mutuals.contains_key(&usr.fqn) { + tracing::debug!("user '{}' is still a mutual", usr.fqn); + mutuals.remove(&usr.fqn); + } else { + tracing::info!("removing mutual '{}'", usr.fqn); + user_ids.push(usr.id.clone()); + } + } + client.list_remove_accounts(&mutuals_list.id, user_ids).await.unwrap(); + + // then add all new mutuals + let mut user_ids = Vec::new(); + for (_id, usr) in mutuals { + tracing::info!("adding new mutual '{}'", usr.fqn); + user_ids.push(usr.id.clone()); + } + client.list_add_accounts(&mutuals_list.id, user_ids).await.unwrap(); + +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..c80a0c8 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,166 @@ +#[derive(Debug, Clone, serde::Serialize)] +pub struct FediAccountIds { + pub account_ids: Vec, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediList { + pub id: String, // TODO fuck you pleroma these are NUMBERS!!!! + pub title: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUserField { + pub name: String, + pub value: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediEmoji { + pub url: String, + pub shortcode: String, + pub static_url: String, + pub visible_in_picker: bool, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUser { + pub id: String, + pub header: String, + pub fields: Vec, + pub source: FediUserSource, + pub pleroma: FediUserProperties, + pub url: String, + pub username: String, + pub created_at: String, // chrono::DateTime, + pub emojis: Vec, + pub akkoma: Option, + pub following_count: u32, + pub note: String, + pub last_status_at: Option, // Option>, + pub acct: String, + pub avatar: String, + pub avatar_static: String, + pub bot: bool, + pub display_name: String, + pub followers_count: u32, + pub fqn: String, + pub header_static: String, + pub locked: bool, + pub statuses_count: u32, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUserProperties { + pub background_image: Option, + pub skip_thread_containment: bool, + pub tags: Vec, + pub ap_id: String, + pub also_known_as: Vec, + pub hide_follows: bool, + pub hide_follows_count: bool, + pub hide_followers: bool, + pub hide_followers_count: bool, + pub is_confirmed: bool, + pub is_suggested: bool, + pub is_moderator: bool, + pub is_admin: bool, + pub hide_favorites: bool, + pub favicon: Option, + pub relationship: EmptyOption, + pub deactivated: bool, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUserRelationship { + pub id: String, + pub following: bool, + pub note: String, + pub blocked_by: bool, + pub blocking: bool, + pub domain_blocking: bool, + pub endorsed: bool, + pub followed_by: bool, + pub muting: bool, + pub muting_notifications: bool, + pub notifying: bool, + pub requested: bool, + pub requested_by: bool, + pub showing_reblogs: bool, + pub subscribing: bool, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUserSource { + pub sensitive: bool, + pub fields: Vec, + pub pleroma: FediUserSourceType, + pub note: String, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUserSourceType { + pub actor_type: String, + pub discoverable: bool, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUserAkkomaExtra { + pub instance: FediUserAkkomaExtraInstance, + pub status_ttl_days: Option, +} + +#[derive(Debug, Clone, serde::Deserialize)] +pub struct FediUserAkkomaExtraInstance { + pub name: String, + pub favicon: Option, + pub nodeinfo: Option, +} + +#[allow(non_snake_case)] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct NodeInfo { + pub openRegistrations: Option, + pub protocols: Option>, + pub services: Option, + pub software: Option, + pub version: Option, + pub metadata: Option, +} + +#[allow(non_snake_case)] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct NodeInfoServices { + pub inbound: Vec, + pub outbound: Vec, +} + +#[allow(non_snake_case)] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct NodeInfoSoftware { + pub name: String, + pub version: String, +} + +#[allow(non_snake_case)] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct NodeInfoUsage { + pub localPosts: u32, + pub users: NodeInfoUsageUsers, +} + +#[allow(non_snake_case)] +#[derive(Debug, Clone, serde::Deserialize)] +pub struct NodeInfoUsageUsers { + pub activeHalfYear: u32, + pub activeMonth: u32, + pub total: u32, +} + +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +#[serde(untagged)] +pub enum EmptyOption { // pleroma sets "relationship" to an empty object.... WHYYYY + Some(T), + None {}, +}