forked from MightyPork/group-actor
				
			
							parent
							
								
									1ea3aa7cb0
								
							
						
					
					
						commit
						5a631f785e
					
				| @ -0,0 +1,209 @@ | ||||
| use std::sync::Arc; | ||||
| use std::time::{Duration, Instant}; | ||||
| 
 | ||||
| use elefren::{FediClient, StatusBuilder}; | ||||
| use elefren::debug::EventDisplay; | ||||
| use elefren::debug::NotificationDisplay; | ||||
| use elefren::entities::event::Event; | ||||
| use elefren::entities::notification::{Notification, NotificationType}; | ||||
| use elefren::status_builder::Visibility; | ||||
| use futures::StreamExt; | ||||
| 
 | ||||
| use crate::store::{ConfigStore, GroupError}; | ||||
| use crate::store::data::GroupConfig; | ||||
| use crate::utils::LogError; | ||||
| 
 | ||||
| /// This is one group's config store capable of persistence
 | ||||
| #[derive(Debug)] | ||||
| pub struct GroupHandle { | ||||
|     pub(crate) client: FediClient, | ||||
|     pub(crate) config: GroupConfig, | ||||
|     pub(crate) store: Arc<ConfigStore>, | ||||
| } | ||||
| 
 | ||||
| impl GroupHandle { | ||||
|     pub async fn save(&mut self) -> Result<(), GroupError> { | ||||
|         debug!("Saving group config & status"); | ||||
|         self.store.set_group_config(self.config.clone()).await?; | ||||
|         self.config.clear_dirty_status(); | ||||
|         Ok(()) | ||||
|     } | ||||
| 
 | ||||
|     pub async fn save_if_needed(&mut self) -> Result<(), GroupError> { | ||||
|         if self.config.is_dirty() { | ||||
|             self.save().await?; | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
| 
 | ||||
|     pub async fn reload(&mut self) -> Result<(), GroupError> { | ||||
|         if let Some(g) = self.store.get_group_config(self.config.get_acct()).await { | ||||
|             self.config = g; | ||||
|             Ok(()) | ||||
|         } else { | ||||
|             Err(GroupError::GroupNotExist) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| trait NotifTimestamp { | ||||
|     fn timestamp_millis(&self) -> u64; | ||||
| } | ||||
| 
 | ||||
| impl NotifTimestamp for Notification { | ||||
|     fn timestamp_millis(&self) -> u64 { | ||||
|         self.created_at.timestamp_millis().max(0) as u64 | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl GroupHandle { | ||||
|     pub async fn run(&mut self) -> Result<(), GroupError> { | ||||
|         const PERIODIC_SAVE: Duration = Duration::from_secs(60); | ||||
|         const PING_INTERVAL: Duration = Duration::from_secs(15); | ||||
| 
 | ||||
|         assert!(PERIODIC_SAVE >= PING_INTERVAL); | ||||
| 
 | ||||
|         let mut next_save = Instant::now() + PERIODIC_SAVE; // so we save at start
 | ||||
| 
 | ||||
|         loop { | ||||
|             debug!("Opening streaming API socket"); | ||||
|             let mut events = self.client.streaming_user().await?; | ||||
| 
 | ||||
|             match self.catch_up_with_missed_notifications().await { | ||||
|                 Ok(true) => { | ||||
|                     debug!("Some missed notifs handled"); | ||||
|                     // Save asap!
 | ||||
|                     next_save = Instant::now() - PERIODIC_SAVE | ||||
|                 } | ||||
|                 Ok(false) => { | ||||
|                     debug!("No notifs missed"); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     error!("Failed to handle missed notifs: {}", e); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             loop { | ||||
|                 if next_save < Instant::now() { | ||||
|                     self.save_if_needed().await | ||||
|                         .log_error("Failed to save group"); | ||||
|                     next_save = Instant::now() + PERIODIC_SAVE; | ||||
|                 } | ||||
| 
 | ||||
|                 let timeout = next_save.saturating_duration_since(Instant::now()) | ||||
|                     .min(PING_INTERVAL) | ||||
|                     .max(Duration::from_secs(1)); | ||||
| 
 | ||||
|                 match tokio::time::timeout(timeout, events.next()).await { | ||||
|                     Ok(Some(event)) => { | ||||
|                         debug!("(@{}) Event: {}", self.config.get_acct(), EventDisplay(&event)); | ||||
|                         match event { | ||||
|                             Event::Update(_status) => {} | ||||
|                             Event::Notification(n) => { | ||||
|                                 self.handle_notification(n).await; | ||||
|                             } | ||||
|                             Event::Delete(_id) => {} | ||||
|                             Event::FiltersChanged => {} | ||||
|                         } | ||||
|                     } | ||||
|                     Ok(None) => { | ||||
|                         warn!("Group @{} socket closed, restarting...", self.config.get_acct()); | ||||
|                         break; | ||||
|                     } | ||||
|                     Err(_) => { | ||||
|                         // Timeout so we can save if needed
 | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 trace!("Pinging"); | ||||
|                 events.send_ping().await.log_error("Fail to send ping"); | ||||
|             } | ||||
| 
 | ||||
|             warn!("Notif stream closed, will reopen"); | ||||
|             tokio::time::sleep(Duration::from_millis(1000)).await; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     async fn handle_notification(&mut self, n: Notification) { | ||||
|         debug!("Handling notif #{}", n.id); | ||||
|         let ts = n.timestamp_millis(); | ||||
|         self.config.set_last_notif(ts); | ||||
| 
 | ||||
|         match n.notification_type { | ||||
|             NotificationType::Mention => { | ||||
|                 if let Some(status) = n.status { | ||||
|                     if status.content.contains("/gi") || status.content.contains("\\gi") { | ||||
|                         info!("Mention ignored by gi"); | ||||
|                     } else if status.content.contains("/gb") || status.content.contains("\\gb") { | ||||
|                         if let Some(id) = status.in_reply_to_id { | ||||
|                             info!("Boosting prev post by GB"); | ||||
|                             tokio::time::sleep(Duration::from_millis(500)).await; | ||||
| //                            self.client.reblog(&id).await.log_error("Failed to boost");
 | ||||
|                         } | ||||
|                     } else { | ||||
|                         info!("Boosting mention"); | ||||
|                         tokio::time::sleep(Duration::from_millis(500)).await; | ||||
| //                        self.client.reblog(&status.id).await.log_error("Failed to boost");
 | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             NotificationType::Follow => { | ||||
|                 info!("New follower!"); | ||||
|                 tokio::time::sleep(Duration::from_millis(500)).await; | ||||
| 
 | ||||
|                 /* | ||||
|                 let post = StatusBuilder::new() | ||||
|                     .status(format!("@{} welcome to the group!", &n.account.acct)) | ||||
|                     .content_type("text/markdown") | ||||
|                     .visibility(Visibility::Unlisted) | ||||
|                     .build().expect("error build status"); | ||||
| 
 | ||||
|                 let _ = self.client.new_status(post).await.log_error("Failed to post"); | ||||
|                  */ | ||||
|             } | ||||
|             _ => {} | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// Catch up with missed notifications, returns true if any were handled
 | ||||
|     async fn catch_up_with_missed_notifications(&mut self) -> Result<bool, GroupError> { | ||||
|         const MAX_CATCHUP_NOTIFS: usize = 25; | ||||
|         let last_notif = self.config.get_last_notif(); | ||||
| 
 | ||||
|         let notifications = self.client.notifications().await?; | ||||
|         let mut iter = notifications.items_iter(); | ||||
| 
 | ||||
|         let mut notifs_to_handle = vec![]; | ||||
| 
 | ||||
|         // They are retrieved newest first, but we want oldest first for chronological handling
 | ||||
| 
 | ||||
|         let mut num = 0; | ||||
|         while let Some(n) = iter.next_item().await { | ||||
|             let ts = n.timestamp_millis(); | ||||
|             if ts <= last_notif { | ||||
|                 break; // reached our last seen notif
 | ||||
|             } | ||||
|             notifs_to_handle.push(n); | ||||
|             num += 1; | ||||
|             if num > MAX_CATCHUP_NOTIFS { | ||||
|                 warn!("Too many notifs missed to catch up!"); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if notifs_to_handle.is_empty() { | ||||
|             return Ok(false); | ||||
|         } | ||||
| 
 | ||||
|         notifs_to_handle.reverse(); | ||||
| 
 | ||||
|         debug!("{} notifications to catch up!", notifs_to_handle.len()); | ||||
| 
 | ||||
|         for n in notifs_to_handle { | ||||
|             debug!("Handling missed notification: {}", NotificationDisplay(&n)); | ||||
|             self.handle_notification(n).await; | ||||
|         } | ||||
| 
 | ||||
|         return Ok(true); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,333 @@ | ||||
| use std::collections::{HashMap, HashSet}; | ||||
| 
 | ||||
| use crate::store; | ||||
| use crate::store::GroupError; | ||||
| use elefren::AppData; | ||||
| 
 | ||||
| /// This is the inner data struct holding the config
 | ||||
| #[derive(Debug, Clone, Serialize, Deserialize, Default)] | ||||
| pub(crate) struct Config { | ||||
|     groups: HashMap<String, GroupConfig>, | ||||
| } | ||||
| 
 | ||||
| impl Config { | ||||
|     pub(crate) fn iter_groups(&self) -> impl Iterator<Item=&GroupConfig>{ | ||||
|         self.groups.values() | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn get_group_config(&self, acct : &str) -> Option<&GroupConfig> { | ||||
|         self.groups.get(acct) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn set_group_config(&mut self, grp : GroupConfig) { | ||||
|         self.groups.insert(grp.acct.clone(), grp); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /// This is the inner data struct holding a group's config
 | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| #[serde(default)] | ||||
| pub(crate) struct GroupConfig { | ||||
|     enabled: bool, | ||||
|     /// Group actor's acct
 | ||||
|     acct: String, | ||||
|     /// elefren data
 | ||||
|     appdata : AppData, | ||||
|     /// List of admin account "acct" names, e.g. piggo@piggo.space
 | ||||
|     admin_users: HashSet<String>, | ||||
|     /// List of users allowed to post to the group, if it is member-only
 | ||||
|     member_users: HashSet<String>, | ||||
|     /// List of users banned from posting to the group
 | ||||
|     banned_users: HashSet<String>, | ||||
|     /// True if only members should be allowed to write
 | ||||
|     member_only: bool, | ||||
|     /// Banned domain names, e.g. kiwifarms.cc
 | ||||
|     banned_servers: HashSet<String>, | ||||
|     /// Last seen notification timestamp
 | ||||
|     last_notif_ts: u64, | ||||
| 
 | ||||
|     #[serde(skip)] | ||||
|     dirty: bool, | ||||
| } | ||||
| 
 | ||||
| impl Default for GroupConfig { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             enabled: true, | ||||
|             acct: "".to_string(), | ||||
|             appdata: AppData { | ||||
|                 base: Default::default(), | ||||
|                 client_id: Default::default(), | ||||
|                 client_secret: Default::default(), | ||||
|                 redirect: Default::default(), | ||||
|                 token: Default::default() | ||||
|             }, | ||||
|             admin_users: Default::default(), | ||||
|             member_users: Default::default(), | ||||
|             banned_users: Default::default(), | ||||
|             member_only: false, | ||||
|             banned_servers: Default::default(), | ||||
|             last_notif_ts: 0, | ||||
|             dirty: false, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl GroupConfig { | ||||
|     pub(crate) fn new(acct : String, appdata: AppData) -> Self { | ||||
|         Self { | ||||
|             acct, | ||||
|             appdata, | ||||
|             ..Default::default() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn is_enabled(&self) -> bool { | ||||
|         self.enabled | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn set_enabled(&mut self, ena: bool){ | ||||
|         self.enabled = ena; | ||||
|         self.mark_dirty(); | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn get_appdata(&self) -> &AppData { | ||||
|         &self.appdata | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn set_appdata(&mut self, appdata: AppData) { | ||||
|         self.appdata = appdata; | ||||
|         self.mark_dirty(); | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn set_last_notif(&mut self, ts: u64) { | ||||
|         self.last_notif_ts = self.last_notif_ts.max(ts); | ||||
|         self.mark_dirty(); | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn get_last_notif(&self) -> u64 { | ||||
|         self.last_notif_ts | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn get_acct(&self) -> &str { | ||||
|         &self.acct | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn is_admin(&self, acct: &str) -> bool { | ||||
|         self.admin_users.contains(acct) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn is_member(&self, acct: &str) -> bool { | ||||
|         self.member_users.contains(acct) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn is_banned(&self, acct: &str) -> bool { | ||||
|         self.banned_users.contains(acct) | ||||
|             || self.is_users_server_banned(acct) | ||||
|     } | ||||
| 
 | ||||
|     /// Check if the user's server is banned
 | ||||
|     fn is_users_server_banned(&self, acct: &str) -> bool { | ||||
|         let server = acct_to_server(acct); | ||||
|         self.banned_servers.contains(server) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn can_write(&self, acct: &str) -> bool { | ||||
|         if self.is_admin(acct) { | ||||
|             true | ||||
|         } else { | ||||
|             !self.is_banned(acct) && ( | ||||
|                 !self.is_member_only() | ||||
|                     || self.is_member(acct) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn set_admin(&mut self, acct: &str, admin: bool) -> Result<(), GroupError> { | ||||
|         if admin { | ||||
|             if self.is_banned(acct) { | ||||
|                 return Err(GroupError::UserIsBanned); | ||||
|             } | ||||
|             self.admin_users.insert(acct.to_owned()); | ||||
|         } else { | ||||
|             self.admin_users.remove(acct); | ||||
|         } | ||||
|         self.mark_dirty(); | ||||
|         Ok(()) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn set_member(&mut self, acct: &str, member: bool) -> Result<(), GroupError> { | ||||
|         if member { | ||||
|             if self.is_banned(acct) { | ||||
|                 return Err(GroupError::UserIsBanned); | ||||
|             } | ||||
|             self.member_users.insert(acct.to_owned()); | ||||
|         } else { | ||||
|             self.member_users.remove(acct); | ||||
|         } | ||||
|         self.mark_dirty(); | ||||
|         Ok(()) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn ban_user(&mut self, acct: &str, ban: bool) -> Result<(), GroupError> { | ||||
|         if ban { | ||||
|             if self.is_admin(acct) { | ||||
|                 return Err(GroupError::UserIsAdmin); | ||||
|             } | ||||
|             self.banned_users.insert(acct.to_owned()); | ||||
|         } else { | ||||
|             self.banned_users.remove(acct); | ||||
|         } | ||||
|         Ok(()) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn ban_server(&mut self, server: &str, ban: bool) -> Result<(), GroupError> { | ||||
|         if ban { | ||||
|             for acct in &self.admin_users { | ||||
|                 let acct_server = acct_to_server(acct); | ||||
|                 if acct_server == server { | ||||
|                     return Err(GroupError::AdminsOnServer); | ||||
|                 } | ||||
|             } | ||||
|             self.banned_servers.insert(server.to_owned()); | ||||
|         } else { | ||||
|             self.banned_servers.remove(server); | ||||
|         } | ||||
|         self.mark_dirty(); | ||||
|         Ok(()) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn set_member_only(&mut self, member_only: bool) { | ||||
|         self.member_only = member_only; | ||||
|         self.mark_dirty(); | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn is_member_only(&self) -> bool { | ||||
|         self.member_only | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn mark_dirty(&mut self) { | ||||
|         self.dirty = true; | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn is_dirty(&self) -> bool { | ||||
|         self.dirty | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn clear_dirty_status(&mut self) { | ||||
|         self.dirty = false; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn acct_to_server(acct: &str) -> &str { | ||||
|     acct.split('@').nth(1) | ||||
|         .unwrap_or_default() | ||||
| } | ||||
| 
 | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use crate::store::{GroupError}; | ||||
|     use crate::store::data::{GroupConfig, acct_to_server}; | ||||
| 
 | ||||
|     #[test] | ||||
|     fn test_acct_to_server() { | ||||
|         assert_eq!("pikachu.rocks", acct_to_server("raichu@pikachu.rocks")); | ||||
|         assert_eq!("pikachu.rocks", acct_to_server("m@pikachu.rocks")); | ||||
|         assert_eq!("", acct_to_server("what")); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn test_default_rules() { | ||||
|         let mut group = GroupConfig::default(); | ||||
|         assert!(!group.is_member_only()); | ||||
|         assert!(!group.is_member("piggo@piggo.space")); | ||||
|         assert!(!group.is_admin("piggo@piggo.space")); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "anyone can post by default"); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn test_member_only() { | ||||
|         let mut group = GroupConfig::default(); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "rando can write in public group"); | ||||
| 
 | ||||
|         group.set_member_only(true); | ||||
|         assert!(!group.can_write("piggo@piggo.space"), "rando can't write in member-only group"); | ||||
| 
 | ||||
|         // Admin in member only
 | ||||
|         group.set_admin("piggo@piggo.space", true).unwrap(); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "admin non-member can write in member-only group"); | ||||
|         group.set_admin("piggo@piggo.space", false).unwrap(); | ||||
|         assert!(!group.can_write("piggo@piggo.space"), "removed admin removes privileged write access"); | ||||
| 
 | ||||
|         // Member in member only
 | ||||
|         group.set_member("piggo@piggo.space", true).unwrap(); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "member can post in member-only group"); | ||||
|         group.set_admin("piggo@piggo.space", true).unwrap(); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "member+admin can post in member-only group"); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn test_banned_users() { | ||||
|         // Banning single user
 | ||||
|         let mut group = GroupConfig::default(); | ||||
|         group.ban_user("piggo@piggo.space", true).unwrap(); | ||||
|         assert!(!group.can_write("piggo@piggo.space"), "banned user can't post"); | ||||
|         group.ban_user("piggo@piggo.space", false).unwrap(); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "un-ban works"); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn test_banned_members() { | ||||
|         // Banning single user
 | ||||
|         let mut group = GroupConfig::default(); | ||||
|         group.set_member_only(true); | ||||
| 
 | ||||
|         group.set_member("piggo@piggo.space", true).unwrap(); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "member can write"); | ||||
|         assert!(group.is_member("piggo@piggo.space"), "member is member"); | ||||
|         assert!(!group.is_banned("piggo@piggo.space"), "user not banned by default"); | ||||
| 
 | ||||
|         group.ban_user("piggo@piggo.space", true).unwrap(); | ||||
|         assert!(group.is_member("piggo@piggo.space"), "still member even if banned"); | ||||
|         assert!(group.is_banned("piggo@piggo.space"), "banned user is banned"); | ||||
| 
 | ||||
|         assert!(!group.can_write("piggo@piggo.space"), "banned member can't post"); | ||||
| 
 | ||||
|         // unban
 | ||||
|         group.ban_user("piggo@piggo.space", false).unwrap(); | ||||
|         assert!(group.can_write("piggo@piggo.space"), "un-ban works"); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn test_server_ban() { | ||||
|         let mut group = GroupConfig::default(); | ||||
|         assert!(group.can_write("hitler@nazi.camp"), "randos can write"); | ||||
| 
 | ||||
|         group.ban_server("nazi.camp", true).unwrap(); | ||||
|         assert!(!group.can_write("hitler@nazi.camp"), "users from banned server can't write"); | ||||
|         assert!(!group.can_write("1488@nazi.camp"), "users from banned server can't write"); | ||||
|         assert!(group.can_write("troll@freezepeach.xyz"), "other users can still write"); | ||||
| 
 | ||||
|         group.ban_server("nazi.camp", false).unwrap(); | ||||
|         assert!(group.can_write("hitler@nazi.camp"), "server unban works"); | ||||
|     } | ||||
| 
 | ||||
|     #[test] | ||||
|     fn test_sanity() { | ||||
|         let mut group = GroupConfig::default(); | ||||
| 
 | ||||
|         group.set_admin("piggo@piggo.space", true).unwrap(); | ||||
|         assert_eq!(Err(GroupError::UserIsAdmin), group.ban_user("piggo@piggo.space", true), "can't bad admin users"); | ||||
|         group.ban_user("piggo@piggo.space", false).expect("can unbad admin"); | ||||
| 
 | ||||
|         group.ban_user("hitler@nazi.camp", true).unwrap(); | ||||
|         assert_eq!(Err(GroupError::UserIsBanned), group.set_admin("hitler@nazi.camp", true), "can't make banned users admins"); | ||||
| 
 | ||||
|         group.ban_server("freespeechextremist.com", true).unwrap(); | ||||
|         assert_eq!(Err(GroupError::UserIsBanned), group.set_admin("nibber@freespeechextremist.com", true), "can't make server-banned users admins"); | ||||
| 
 | ||||
|         assert!(group.is_admin("piggo@piggo.space")); | ||||
|         assert_eq!(Err(GroupError::AdminsOnServer), group.ban_server("piggo.space", true), "can't bad server with admins"); | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,225 @@ | ||||
| use std::collections::{HashMap, HashSet}; | ||||
| use std::hash::{Hash, Hasher}; | ||||
| use std::path::{Path, PathBuf}; | ||||
| use std::sync::Arc; | ||||
| 
 | ||||
| use elefren::{FediClient, Registration, Scopes, scopes}; | ||||
| use elefren::entities::event::Event; | ||||
| use futures::StreamExt; | ||||
| use tokio::sync::RwLock; | ||||
| 
 | ||||
| use data::{Config, GroupConfig}; | ||||
| 
 | ||||
| use crate::group_handle::GroupHandle; | ||||
| 
 | ||||
| pub(crate) mod data; | ||||
| 
 | ||||
| #[derive(Debug, Default)] | ||||
| pub struct ConfigStore { | ||||
|     store_path: PathBuf, | ||||
|     save_pretty: bool, | ||||
|     data: tokio::sync::RwLock<Config>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub struct NewGroupOptions { | ||||
|     pub server: String, | ||||
|     pub acct: String, | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub struct StoreOptions { | ||||
|     pub store_path: String, | ||||
|     pub save_pretty: bool, | ||||
| } | ||||
| 
 | ||||
| impl ConfigStore { | ||||
|     /// Create a new instance of the store.
 | ||||
|     /// If a path is given, it will try to load the content from a file.
 | ||||
|     pub async fn new(options: StoreOptions) -> Result<Arc<Self>, GroupError> { | ||||
|         let path: &Path = options.store_path.as_ref(); | ||||
| 
 | ||||
|         let config = if path.is_file() { | ||||
|             let f = tokio::fs::read(path).await?; | ||||
|             serde_json::from_slice(&f)? | ||||
|         } else { | ||||
|             let empty = Config::default(); | ||||
|             tokio::fs::write(path, serde_json::to_string(&empty)?.as_bytes()).await?; | ||||
|             empty | ||||
|         }; | ||||
| 
 | ||||
|         Ok(Arc::new(Self { | ||||
|             store_path: path.to_owned(), | ||||
|             save_pretty: options.save_pretty, | ||||
|             data: RwLock::new(config), | ||||
|         })) | ||||
|     } | ||||
| 
 | ||||
|     /// Spawn a new group
 | ||||
|     pub async fn auth_new_group(self: &Arc<Self>, opts: NewGroupOptions) -> Result<GroupHandle, GroupError> { | ||||
|         let registration = Registration::new(&opts.server) | ||||
|             .client_name("group-actor") | ||||
|             .force_login(true) | ||||
|             .scopes(make_scopes()) | ||||
|             .build().await?; | ||||
| 
 | ||||
|         println!("--- Authenticating NEW bot user @{} ---", opts.acct); | ||||
|         let client = elefren::helpers::cli::authenticate(registration).await?; | ||||
|         let appdata = client.data.clone(); | ||||
| 
 | ||||
|         let data = GroupConfig::new(opts.acct, appdata); | ||||
| 
 | ||||
|         // save & persist
 | ||||
|         self.set_group_config(data.clone()).await?; | ||||
| 
 | ||||
|         Ok(GroupHandle { | ||||
|             client, | ||||
|             config: data, | ||||
|             store: self.clone(), | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /// Re-auth an existing group
 | ||||
|     pub async fn reauth_group(self: &Arc<Self>, acct: &str) -> Result<GroupHandle, GroupError> { | ||||
|         let groups = self.data.read().await; | ||||
|         let mut config = groups.get_group_config(acct).ok_or(GroupError::GroupNotExist)?.clone(); | ||||
| 
 | ||||
|         println!("--- Re-authenticating bot user @{} ---", acct); | ||||
|         let registration = Registration::new(config.get_appdata().base.to_string()) | ||||
|             .client_name("group-actor") | ||||
|             .force_login(true) | ||||
|             .scopes(make_scopes()) | ||||
|             .build().await?; | ||||
| 
 | ||||
|         let client = elefren::helpers::cli::authenticate(registration).await?; | ||||
|         let appdata = client.data.clone(); | ||||
| 
 | ||||
|         config.set_appdata(appdata); | ||||
|         self.set_group_config(config.clone()).await?; | ||||
| 
 | ||||
|         Ok(GroupHandle { | ||||
|             client, | ||||
|             config, | ||||
|             store: self.clone(), | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /// Spawn existing group using saved creds
 | ||||
|     pub async fn spawn_groups(self: Arc<Self>) -> Vec<GroupHandle> { | ||||
|         let groups = self.data.read().await; | ||||
|         let groups_iter = groups.iter_groups().cloned(); | ||||
| 
 | ||||
|         // Connect in parallel
 | ||||
|         futures::stream::iter(groups_iter).map(|gc| async { | ||||
|             if !gc.is_enabled() { | ||||
|                 debug!("Group @{} is DISABLED", gc.get_acct()); | ||||
|                 return None; | ||||
|             } | ||||
| 
 | ||||
|             debug!("Connecting to @{}", gc.get_acct()); | ||||
| 
 | ||||
|             let client = FediClient::from(gc.get_appdata().clone()); | ||||
| 
 | ||||
|             match client.verify_credentials().await { | ||||
|                 Ok(account) => { | ||||
|                     info!("Group account verified: @{}, {}", account.acct, account.display_name); | ||||
|                 } | ||||
|                 Err(e) => { | ||||
|                     error!("Group @{} auth error: {}", gc.get_acct(), e); | ||||
|                     return None; | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             Some(GroupHandle { | ||||
|                 client, | ||||
|                 config: gc, | ||||
|                 store: self.clone(), | ||||
|             }) | ||||
|         }).buffer_unordered(8).collect::<Vec<_>>().await | ||||
|             .into_iter().flatten().collect() | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) async fn get_group_config(&self, group: &str) -> Option<GroupConfig> { | ||||
|         let c = self.data.read().await; | ||||
|         c.get_group_config(group).map(|inner| { | ||||
|             inner.clone() | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     //noinspection RsSelfConvention
 | ||||
|     /// Set group config to the store. The store then saved.
 | ||||
|     pub(crate) async fn set_group_config<'a>(&'a self, config: GroupConfig) -> Result<(), GroupError> { | ||||
|         let mut data = self.data.write().await; | ||||
|         data.set_group_config(config); | ||||
|         self.persist(&data).await?; | ||||
|         Ok(()) | ||||
|     } | ||||
| 
 | ||||
|     /// Persist the store
 | ||||
|     async fn persist(&self, data: &Config) -> Result<(), GroupError> { | ||||
|         tokio::fs::write(&self.store_path, | ||||
|                          if self.save_pretty { | ||||
|                              serde_json::to_string_pretty(&data) | ||||
|                          } else { | ||||
|                              serde_json::to_string(&data) | ||||
|                          }?.as_bytes()) | ||||
|             .await?; | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Error)] | ||||
| pub enum GroupError { | ||||
|     #[error("Operation refused because the user is admin")] | ||||
|     UserIsAdmin, | ||||
|     #[error("Operation refused because the user is banned")] | ||||
|     UserIsBanned, | ||||
|     #[error("Server could not be banned because there are admin users on it")] | ||||
|     AdminsOnServer, | ||||
|     #[error("Group config is missing in the config store")] | ||||
|     GroupNotExist, | ||||
|     #[error(transparent)] | ||||
|     IoError(#[from] std::io::Error), | ||||
|     #[error(transparent)] | ||||
|     Serializer(#[from] serde_json::Error), | ||||
|     #[error(transparent)] | ||||
|     Elefren(#[from] elefren::Error), | ||||
| } | ||||
| 
 | ||||
| // this is for tests
 | ||||
| impl PartialEq for GroupError { | ||||
|     fn eq(&self, other: &Self) -> bool { | ||||
|         match (self, other) { | ||||
|             (Self::UserIsAdmin, Self::UserIsAdmin) => true, | ||||
|             (Self::UserIsBanned, Self::UserIsBanned) => true, | ||||
|             (Self::AdminsOnServer, Self::AdminsOnServer) => true, | ||||
|             (Self::GroupNotExist, Self::GroupNotExist) => true, | ||||
|             _ => false, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn make_scopes() -> Scopes { | ||||
|     Scopes::read(scopes::Read::Accounts) | ||||
|         | Scopes::read(scopes::Read::Notifications) | ||||
|         | Scopes::read(scopes::Read::Statuses) | ||||
|         | Scopes::read(scopes::Read::Follows) | ||||
|         | Scopes::write(scopes::Write::Statuses) | ||||
|         | Scopes::write(scopes::Write::Media) | ||||
| } | ||||
| 
 | ||||
| // trait TapOk<T> {
 | ||||
| //     fn tap_ok<F: FnOnce(&T)>(self, f: F) -> Self;
 | ||||
| // }
 | ||||
| //
 | ||||
| // impl<T, E> TapOk<T> for Result<T, E> {
 | ||||
| //     fn tap_ok<F: FnOnce(&T)>(self, f: F) -> Self {
 | ||||
| //         match self {
 | ||||
| //             Ok(v) => {
 | ||||
| //                 f(&v);
 | ||||
| //                 Ok(v)
 | ||||
| //             }
 | ||||
| //             Err(e) => Err(e)
 | ||||
| //         }
 | ||||
| //     }
 | ||||
| // }
 | ||||
| @ -0,0 +1,16 @@ | ||||
| use std::error::Error; | ||||
| 
 | ||||
| pub trait LogError { | ||||
|     fn log_error<S : AsRef<str>>(self, msg: S); | ||||
| } | ||||
| 
 | ||||
| impl<V, E : Error> LogError for Result<V, E> { | ||||
|     fn log_error<S : AsRef<str>>(self, msg: S) { | ||||
|         match self { | ||||
|             Ok(_) => {} | ||||
|             Err(e) => { | ||||
|                error!("{}: {}", msg.as_ref(), e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
					Loading…
					
					
				
		Reference in new issue