diff --git a/Cargo.lock b/Cargo.lock index 21b8d23..f184833 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,7 @@ checksum = "ea57b42383d091c85abcc2706240b94ab2a8fa1fc81c10ff23c4de06e2a90b5e" [[package]] name = "elefren" version = "0.22.0" +source = "git+https://git.ondrovo.com/MightyPork/elefren-fork.git#de38639fd178ae8ae47adb880ed965437a3d608e" dependencies = [ "chrono", "doc-comment", diff --git a/Cargo.toml b/Cargo.toml index d72d7f9..a17d29d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ build = "build.rs" [dependencies] #elefren = { version = "0.22.0", features = ["toml"] } -elefren = { path = "../elefren22-fork" } -#elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git", features = ["toml"] } +#elefren = { path = "../elefren22-fork" } +elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git" } env_logger = "0.9.0" diff --git a/src/command.rs b/src/command.rs index d33f7ba..406ad01 100644 --- a/src/command.rs +++ b/src/command.rs @@ -13,6 +13,7 @@ pub enum StatusCommand { RemoveMember(String), GrantAdmin(String), RemoveAdmin(String), + Announce(String), OpenGroup, CloseGroup, Help, @@ -42,7 +43,7 @@ static RE_BOOST: once_cell::sync::Lazy = Lazy::new(|| { }); static RE_IGNORE: once_cell::sync::Lazy = Lazy::new(|| { - command!(r"i(?:gn(?:ore)?)?") + command!(r"i(?:g(?:n(?:ore)?)?)?") }); static RE_BAN_USER: once_cell::sync::Lazy = Lazy::new(|| { @@ -62,19 +63,19 @@ static RE_UNBAN_SERVER: once_cell::sync::Lazy = Lazy::new(|| { }); static RE_ADD_MEMBER: once_cell::sync::Lazy = Lazy::new(|| { - command!(r"(?:accept|invite|member|add)\s+", p_user!()) + command!(r"(?:add)\s+", p_user!()) }); static RE_REMOVE_MEMBER: once_cell::sync::Lazy = Lazy::new(|| { - command!(r"(?:expel|kick|remove)\s+", p_user!()) + command!(r"(?:kick|remove)\s+", p_user!()) }); static RE_GRANT_ADMIN: once_cell::sync::Lazy = Lazy::new(|| { - command!(r"(?:op|admin|grant)\s+", p_user!()) + command!(r"(?:op|admin)\s+", p_user!()) }); static RE_REVOKE_ADMIN: once_cell::sync::Lazy = Lazy::new(|| { - command!(r"(?:deop|unop|deadmin|unadmin|ungrant|revoke)\s+", p_user!()) + command!(r"(?:deop|deadmin|revoke)\s+", p_user!()) }); static RE_OPEN_GROUP: once_cell::sync::Lazy = Lazy::new(|| { @@ -90,11 +91,15 @@ static RE_HELP: once_cell::sync::Lazy = Lazy::new(|| { }); static RE_MEMBERS: once_cell::sync::Lazy = Lazy::new(|| { - command!(r"(?:list|members)") + command!(r"(?:members)") }); static RE_LEAVE: once_cell::sync::Lazy = Lazy::new(|| { - command!(r"(?:leave|quit)") + command!(r"(?:leave)") +}); + +static RE_ANNOUNCE: once_cell::sync::Lazy = Lazy::new(|| { + Regex::new(concat!(r"(?:^|\s|>|\n)[\\/]announce\s+(.*)$")).unwrap() }); pub fn parse_status(content: &str) -> Vec { @@ -124,6 +129,8 @@ pub fn parse_status(content: &str) -> Vec { let mut commands = vec![]; + // one-use commands + if RE_BOOST.is_match(&content) { debug!("BOOST"); commands.push(StatusCommand::Boost); @@ -149,7 +156,17 @@ pub fn parse_status(content: &str) -> Vec { commands.push(StatusCommand::CloseGroup); } - if let Some(c) = RE_BAN_USER.captures(&content) { + if let Some(c) = RE_ANNOUNCE.captures(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str().trim(); + debug!("ANNOUNCE: «{}»", s); + commands.push(StatusCommand::Announce(s.to_owned())); + } + } + + // multi-occurence commands + + for c in RE_BAN_USER.captures_iter(&content) { if let Some(s) = c.get(1) { let s = s.as_str(); let s = s.trim_start_matches('@'); @@ -158,7 +175,7 @@ pub fn parse_status(content: &str) -> Vec { } } - if let Some(c) = RE_UNBAN_USER.captures(&content) { + for c in RE_UNBAN_USER.captures_iter(&content) { if let Some(s) = c.get(1) { let s = s.as_str(); let s = s.trim_start_matches('@'); @@ -167,21 +184,21 @@ pub fn parse_status(content: &str) -> Vec { } } - if let Some(c) = RE_BAN_SERVER.captures(&content) { + for c in RE_BAN_SERVER.captures_iter(&content) { if let Some(s) = c.get(1) { debug!("BAN SERVER: {}", s.as_str()); commands.push(StatusCommand::BanServer(s.as_str().to_owned())); } } - if let Some(c) = RE_UNBAN_SERVER.captures(&content) { + for c in RE_UNBAN_SERVER.captures_iter(&content) { if let Some(s) = c.get(1) { debug!("UNBAN SERVER: {}", s.as_str()); commands.push(StatusCommand::UnbanServer(s.as_str().to_owned())); } } - if let Some(c) = RE_ADD_MEMBER.captures(&content) { + for c in RE_ADD_MEMBER.captures_iter(&content) { if let Some(s) = c.get(1) { let s = s.as_str(); let s = s.trim_start_matches('@'); @@ -190,7 +207,7 @@ pub fn parse_status(content: &str) -> Vec { } } - if let Some(c) = RE_REMOVE_MEMBER.captures(&content) { + for c in RE_REMOVE_MEMBER.captures_iter(&content) { if let Some(s) = c.get(1) { let s = s.as_str(); let s = s.trim_start_matches('@'); @@ -199,7 +216,7 @@ pub fn parse_status(content: &str) -> Vec { } } - if let Some(c) = RE_GRANT_ADMIN.captures(&content) { + for c in RE_GRANT_ADMIN.captures_iter(&content) { if let Some(s) = c.get(1) { let s = s.as_str(); let s = s.trim_start_matches('@'); @@ -208,7 +225,7 @@ pub fn parse_status(content: &str) -> Vec { } } - if let Some(c) = RE_REVOKE_ADMIN.captures(&content) { + for c in RE_REVOKE_ADMIN.captures_iter(&content) { if let Some(s) = c.get(1) { let s = s.as_str(); let s = s.trim_start_matches('@'); @@ -224,8 +241,8 @@ pub fn parse_status(content: &str) -> Vec { mod test { use crate::command::{parse_status, StatusCommand}; - use super::{RE_ADD_MEMBER, RE_BAN_SERVER, RE_BAN_USER, RE_BOOST, RE_CLOSE_GROUP, RE_GRANT_ADMIN, RE_HELP, - RE_IGNORE, RE_LEAVE, RE_MEMBERS, RE_OPEN_GROUP, RE_REMOVE_MEMBER, RE_REVOKE_ADMIN}; + use super::{RE_ADD_MEMBER, RE_ANNOUNCE, RE_BAN_SERVER, RE_BAN_USER, RE_BOOST, RE_CLOSE_GROUP, RE_GRANT_ADMIN, + RE_HELP, RE_IGNORE, RE_LEAVE, RE_MEMBERS, RE_OPEN_GROUP, RE_REMOVE_MEMBER, RE_REVOKE_ADMIN}; #[test] fn test_boost() { @@ -247,12 +264,13 @@ mod test { #[test] fn test_ignore() { assert!(RE_IGNORE.is_match("/i")); + assert!(RE_IGNORE.is_match("/ig")); + assert!(RE_IGNORE.is_match("/ign")); assert!(RE_IGNORE.is_match("/i mm")); assert!(RE_IGNORE.is_match("/i.")); assert!(RE_IGNORE.is_match("\\i")); assert!(!RE_IGNORE.is_match("boo/i")); assert!(RE_IGNORE.is_match("bla\n/i")); - assert!(RE_IGNORE.is_match("/ign")); assert!(RE_IGNORE.is_match("/ignore")); assert!(RE_IGNORE.is_match("/ignore x")); assert!(RE_IGNORE.is_match("/ignore\n")); @@ -260,6 +278,7 @@ mod test { assert!(!RE_IGNORE.is_match("/ignorey")); assert!(RE_IGNORE.is_match("/i\nxxx")); assert!(!RE_IGNORE.is_match("/ileble\n")); + assert!(!RE_IGNORE.is_match("/ignx")); } #[test] @@ -318,13 +337,9 @@ mod test { #[test] fn test_add_member() { - assert!(RE_ADD_MEMBER.is_match("/accept lain@pleroma.soykaf.com")); - assert!(RE_ADD_MEMBER.is_match("/accept @lain@pleroma.soykaf.com")); - assert!(RE_ADD_MEMBER.is_match("\\accept @lain")); - - assert!(RE_ADD_MEMBER.is_match("/invite @lain")); - assert!(RE_ADD_MEMBER.is_match("/add @lain")); - assert!(RE_ADD_MEMBER.is_match("/member @lain")); + assert!(RE_ADD_MEMBER.is_match("/add lain@pleroma.soykaf.com")); + assert!(RE_ADD_MEMBER.is_match("/add @lain@pleroma.soykaf.com")); + assert!(RE_ADD_MEMBER.is_match("\\add @lain")); let c = RE_ADD_MEMBER.captures("/add @lain"); assert!(c.is_some()); @@ -335,9 +350,9 @@ mod test { fn test_remove_member() { assert!(!RE_REMOVE_MEMBER.is_match("/admin lain@pleroma.soykaf.com")); - assert!(RE_REMOVE_MEMBER.is_match("/expel lain@pleroma.soykaf.com")); - assert!(RE_REMOVE_MEMBER.is_match("/expel @lain@pleroma.soykaf.com")); - assert!(RE_REMOVE_MEMBER.is_match("\\expel @lain")); + assert!(RE_REMOVE_MEMBER.is_match("/remove lain@pleroma.soykaf.com")); + assert!(RE_REMOVE_MEMBER.is_match("/remove @lain@pleroma.soykaf.com")); + assert!(RE_REMOVE_MEMBER.is_match("\\remove @lain")); assert!(RE_REMOVE_MEMBER.is_match("/kick @lain")); assert!(RE_REMOVE_MEMBER.is_match("/remove @lain")); @@ -352,7 +367,7 @@ mod test { assert!(!RE_GRANT_ADMIN.is_match("/expel lain@pleroma.soykaf.com")); assert!(RE_GRANT_ADMIN.is_match("/admin lain@pleroma.soykaf.com")); - assert!(RE_GRANT_ADMIN.is_match("/grant @lain@pleroma.soykaf.com")); + assert!(RE_GRANT_ADMIN.is_match("/op @lain@pleroma.soykaf.com")); assert!(RE_GRANT_ADMIN.is_match("\\op @lain")); let c = RE_GRANT_ADMIN.captures("/op @lain@pleroma.soykaf.com"); @@ -366,12 +381,9 @@ mod test { assert!(RE_REVOKE_ADMIN.is_match("/revoke @lain")); assert!(RE_REVOKE_ADMIN.is_match("/deop @lain")); - assert!(RE_REVOKE_ADMIN.is_match("/unop @lain")); assert!(RE_REVOKE_ADMIN.is_match("/deadmin @lain")); - assert!(RE_REVOKE_ADMIN.is_match("/unadmin @lain")); - assert!(RE_REVOKE_ADMIN.is_match("/ungrant @lain")); - let c = RE_REVOKE_ADMIN.captures("/ungrant @lain"); + let c = RE_REVOKE_ADMIN.captures("/revoke @lain"); assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); } @@ -412,16 +424,24 @@ mod test { fn test_members() { assert!(!RE_MEMBERS.is_match("/admin lain@pleroma.soykaf.com")); assert!(RE_MEMBERS.is_match("/members")); - assert!(RE_MEMBERS.is_match("/list")); } #[test] fn test_leave() { assert!(!RE_LEAVE.is_match("/list")); assert!(RE_LEAVE.is_match("/leave")); - assert!(RE_LEAVE.is_match("/quit")); - assert!(RE_LEAVE.is_match("x /quit")); - assert!(RE_LEAVE.is_match("/quit z")); + assert!(RE_LEAVE.is_match("/leave")); + assert!(RE_LEAVE.is_match("x /leave")); + assert!(RE_LEAVE.is_match("/leave z")); + } + + #[test] + fn test_announce() { + assert!(!RE_ANNOUNCE.is_match("/list")); + assert!(RE_ANNOUNCE.is_match("sdfsdffsd /announce b")); + assert!(RE_ANNOUNCE.is_match("/announce bla bla bla")); + assert!(RE_ANNOUNCE.is_match("sdfsdffsd /announce bla bla bla")); + assert_eq!("bla bla bla", RE_ANNOUNCE.captures("sdfsdffsd /announce bla bla bla").unwrap().get(1).unwrap().as_str()); } #[test] @@ -429,8 +449,12 @@ mod test { assert_eq!(Vec::::new(), parse_status("Hello there is nothing here /fake command")); assert_eq!(vec![StatusCommand::Help], parse_status("lets see some \\help and /ban @lain")); assert_eq!(vec![StatusCommand::Ignore], parse_status("lets see some /ignore and /ban @lain")); - assert_eq!(vec![StatusCommand::BanUser("lain".to_string()), StatusCommand::BanServer("soykaf.com".to_string())], - parse_status("let's /ban @lain! and also /ban soykaf.com")); + assert_eq!(vec![ + StatusCommand::BanUser("lain".to_string()), + StatusCommand::BanUser("piggo@piggo.space".to_string()), + StatusCommand::BanServer("soykaf.com".to_string()) + ], + parse_status("let's /ban @lain! /ban @piggo@piggo.space and also /ban soykaf.com")); } #[test] diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..619070d --- /dev/null +++ b/src/error.rs @@ -0,0 +1,35 @@ +use std::borrow::Cow; + +#[derive(Debug, Error)] +pub enum GroupError { + #[error("User is admin")] + UserIsAdmin, + #[error("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("Config error: {0}")] + BadConfig(Cow<'static, str>), + #[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, + (Self::BadConfig(_), Self::BadConfig(_)) => true, + _ => false, + } + } +} diff --git a/src/group_handle.rs b/src/group_handle.rs index f2a73b4..215f16d 100644 --- a/src/group_handle.rs +++ b/src/group_handle.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -10,10 +11,10 @@ use elefren::status_builder::Visibility; use futures::StreamExt; use crate::command::StatusCommand; -use crate::store::{ConfigStore, GroupError}; +use crate::error::GroupError; +use crate::store::ConfigStore; use crate::store::data::GroupConfig; -use crate::utils::LogError; -use std::collections::HashSet; +use crate::utils::{LogError, normalize_acct}; /// This is one group's config store capable of persistence #[derive(Debug)] @@ -23,6 +24,12 @@ pub struct GroupHandle { pub(crate) store: Arc, } +const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(250); +const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(1000); +const MAX_CATCHUP_NOTIFS: usize = 25; +const PERIODIC_SAVE: Duration = Duration::from_secs(60); +const PING_INTERVAL: Duration = Duration::from_secs(15); // must be < periodic save! + impl GroupHandle { pub async fn save(&mut self) -> Result<(), GroupError> { debug!("Saving group config & status"); @@ -60,9 +67,6 @@ impl NotifTimestamp for Notification { 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 @@ -102,7 +106,8 @@ impl GroupHandle { match event { Event::Update(_status) => {} Event::Notification(n) => { - self.handle_notification(n).await; + self.handle_notification(n).await + .log_error("Error handling a notification"); } Event::Delete(_id) => {} Event::FiltersChanged => {} @@ -122,100 +127,112 @@ impl GroupHandle { } warn!("Notif stream closed, will reopen"); - tokio::time::sleep(Duration::from_millis(1000)).await; + tokio::time::sleep(DELAY_REOPEN_STREAM).await; } } - async fn handle_notification(&mut self, n: Notification) { - const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(500); - + async fn handle_notification(&mut self, n: Notification) -> Result<(), GroupError> { debug!("Handling notif #{}", n.id); let ts = n.timestamp_millis(); self.config.set_last_notif(ts); - let can_write = self.config.can_write(&n.account.acct); - let is_admin = self.config.is_admin(&n.account.acct); + let group_acct = self.config.get_acct().to_string(); + let notif_acct = normalize_acct(&n.account.acct, &group_acct)?; - if self.config.is_banned(&n.account.acct) { - warn!("Notification actor {} is banned!", n.account.acct); - return; + let can_write = self.config.can_write(¬if_acct); + let is_admin = self.config.is_admin(¬if_acct); + + if self.config.is_banned(¬if_acct) { + warn!("Notification actor {} is banned!", notif_acct); + return Ok(()); } match n.notification_type { NotificationType::Mention => { if let Some(status) = n.status { - if self.config.is_banned(&status.account.acct) { - warn!("Status author {} is banned!", status.account.acct); - return; + let status_acct = normalize_acct(&status.account.acct, &group_acct)?; + + if self.config.is_banned(&status_acct) { + warn!("Status author {} is banned!", status_acct); + return Ok(()); } let commands = crate::command::parse_status(&status.content); + let mut replies = vec![]; + let mut announcements = vec![]; + let mut do_boost_prev_post = false; + // let mut new_members = vec![]; + // let mut new_admins = vec![]; + // let mut removed_admins = vec![]; + // let mut instance_ban_announcements = vec![]; + // let mut instance_unban_announcements = vec![]; + let mut any_admin_cmd = false; + if commands.is_empty() { debug!("No commands in post"); - if !can_write { - warn!("User {} not allowed to post in group", n.account.acct); - return; - } - if status.in_reply_to_id.is_none() { - // Someone tagged the group in OP, boost it. - info!("Boosting OP mention"); - tokio::time::sleep(DELAY_BEFORE_ACTION).await; - self.client.reblog(&status.id).await - .log_error("Failed to boost"); + if can_write { + // Someone tagged the group in OP, boost it. + info!("Boosting OP mention"); + tokio::time::sleep(DELAY_BEFORE_ACTION).await; + self.client.reblog(&status.id).await + .log_error("Failed to boost"); + } else { + replies.push(format!("You are not allowed to post to this group")); + } } else { - debug!("Not OP, ignore mention") + debug!("Not OP, ignore mention"); } } else { - let mut reply = vec![]; - let mut boost_prev = false; - let mut new_members = vec![]; - let mut new_admins = vec![]; - let mut removed_admins = vec![]; - let mut instance_ban_announcements = vec![]; - let mut instance_unban_announcements = vec![]; - - // TODO normalize local user handles - let mut any_admin_cmd = false; - for cmd in commands { match cmd { + // ignore is first on purpose StatusCommand::Ignore => { debug!("Notif ignored because of ignore command"); - return; + return Ok(()); + } + StatusCommand::Announce(a) => { + debug!("Sending PSA"); + announcements.push(a); } StatusCommand::Boost => { - if !can_write { - warn!("User {} not allowed to boost to group", n.account.acct); + if can_write { + do_boost_prev_post = status.in_reply_to_id.is_some(); } else { - boost_prev = status.in_reply_to_id.is_some(); + replies.push(format!("You are not allowed to share to this group")); } } StatusCommand::BanUser(u) => { + let u = normalize_acct(&u, &group_acct)?; if is_admin { if !self.config.is_banned(&u) { match self.config.ban_user(&u, true) { Ok(_) => { any_admin_cmd = true; - reply.push(format!("User {} banned from group!", u)); + replies.push(format!("User {} banned from group!", u)); + + // no announcement here } Err(e) => { - reply.push(format!("Failed to ban user {}: {}", u, e)); + replies.push(format!("Failed to ban user {}: {}", u, e)); } } } } else { - warn!("Not admin, can't manage bans"); + replies.push(format!("Only admins can manage user bans")); } } StatusCommand::UnbanUser(u) => { + let u = normalize_acct(&u, &group_acct)?; if is_admin { if self.config.is_banned(&u) { match self.config.ban_user(&u, false) { Ok(_) => { any_admin_cmd = true; - reply.push(format!("User {} un-banned!", u)); + replies.push(format!("User {} un-banned!", u)); + + // no announcement here } Err(e) => { unreachable!() @@ -223,7 +240,7 @@ impl GroupHandle { } } } else { - warn!("Not admin, can't manage bans"); + replies.push(format!("Only admins can manage user bans")); } } StatusCommand::BanServer(s) => { @@ -232,16 +249,16 @@ impl GroupHandle { match self.config.ban_server(&s, true) { Ok(_) => { any_admin_cmd = true; - instance_ban_announcements.push(s.clone()); - reply.push(format!("Instance {} banned from group!", s)); + announcements.push(format!("Server \"{}\" has been banned.", s)); + replies.push(format!("Server {} banned from group!", s)); } Err(e) => { - reply.push(format!("Failed to ban instance {}: {}", s, e)); + replies.push(format!("Failed to ban server {}: {}", s, e)); } } } } else { - warn!("Not admin, can't manage bans"); + replies.push(format!("Only admins can manage server bans")); } } StatusCommand::UnbanServer(s) => { @@ -250,8 +267,8 @@ impl GroupHandle { match self.config.ban_server(&s, false) { Ok(_) => { any_admin_cmd = true; - instance_unban_announcements.push(s.clone()); - reply.push(format!("Instance {} un-banned!", s)); + announcements.push(format!("Server \"{}\" has been un-banned.", s)); + replies.push(format!("Server {} un-banned!", s)); } Err(e) => { unreachable!() @@ -259,34 +276,39 @@ impl GroupHandle { } } } else { - warn!("Not admin, can't manage bans"); + replies.push(format!("Only admins can manage server bans")); } } StatusCommand::AddMember(u) => { + let u = normalize_acct(&u, &group_acct)?; if is_admin { if !self.config.is_member(&u) { match self.config.set_member(&u, true) { Ok(_) => { any_admin_cmd = true; - reply.push(format!("User {} added to group!", u)); - new_members.push(u); + replies.push(format!("User {} added to the group!", u)); + + if self.config.is_member_only() { + announcements.push(format!("Welcome new member @{} to the group!", u)); + } } Err(e) => { - reply.push(format!("Failed to add user {} to group: {}", u, e)); + replies.push(format!("Failed to add user {} to group: {}", u, e)); } } } } else { - warn!("Not admin, can't manage members"); + replies.push(format!("Only admins can manage members")); } } StatusCommand::RemoveMember(u) => { + let u = normalize_acct(&u, &group_acct)?; if is_admin { if self.config.is_member(&u) { match self.config.set_member(&u, false) { Ok(_) => { any_admin_cmd = true; - reply.push(format!("User {} removed from group.", u)); + replies.push(format!("User {} removed from the group.", u)); } Err(e) => { unreachable!() @@ -294,43 +316,45 @@ impl GroupHandle { } } } else { - warn!("Not admin, can't manage members"); + replies.push(format!("Only admins can manage members")); } } StatusCommand::GrantAdmin(u) => { + let u = normalize_acct(&u, &group_acct)?; if is_admin { if !self.config.is_admin(&u) { match self.config.set_admin(&u, true) { Ok(_) => { any_admin_cmd = true; - reply.push(format!("User {} is now a group admin!", u)); - new_admins.push(u); + replies.push(format!("User {} is now a group admin!", u)); + announcements.push(format!("User @{} can now manage this group!", u)); } Err(e) => { - reply.push(format!("Failed to make user {} a group admin: {}", u, e)); + replies.push(format!("Failed to make user {} a group admin: {}", u, e)); } } } } else { - warn!("Not admin, can't manage admin rights"); + replies.push(format!("Only admins can manage admins")); } } StatusCommand::RemoveAdmin(u) => { + let u = normalize_acct(&u, &group_acct)?; if is_admin { if self.config.is_admin(&u) { match self.config.set_admin(&u, false) { Ok(_) => { any_admin_cmd = true; - reply.push(format!("User {} is no longer a group admin!", u)); - removed_admins.push(u) + replies.push(format!("User {} is no longer a group admin!", u)); + announcements.push(format!("User @{} no longer manages this group.", u)); } Err(e) => { - reply.push(format!("Failed to revoke {}'s group admin: {}", u, e)); + replies.push(format!("Failed to revoke {}'s group admin: {}", u, e)); } } } } else { - warn!("Not admin, can't manage admin rights"); + replies.push(format!("Only admins can manage admins")); } } StatusCommand::OpenGroup => { @@ -338,10 +362,11 @@ impl GroupHandle { if self.config.is_member_only() { any_admin_cmd = true; self.config.set_member_only(false); - reply.push(format!("Group changed to open-access")); + replies.push(format!("Group changed to open-access")); + announcements.push(format!("This group is now open-access!")); } } else { - warn!("Not admin, can't manage group mode"); + replies.push(format!("Only admins can set group options")); } } StatusCommand::CloseGroup => { @@ -349,76 +374,100 @@ impl GroupHandle { if !self.config.is_member_only() { any_admin_cmd = true; self.config.set_member_only(true); - reply.push(format!("Group changed to member-only")); + replies.push(format!("Group changed to member-only")); + announcements.push(format!("This group is now member-only!")); } } else { - warn!("Not admin, can't manage group mode"); + replies.push(format!("Only admins can set group options")); } } StatusCommand::Help => { - reply.push("Mention the group user in a top-level post to share it with the group's members.".to_string()); - reply.push("Posts with commands won't be shared. Supported commands:".to_string()); - reply.push("/ignore, /ign, /i - don't run any commands in the post".to_string()); - reply.push("/boost, /b - boost the replied-to post into the group".to_string()); - reply.push("/leave - leave the group as a member".to_string()); + replies.push("Mention the group user in a top-level post to share it with the group's members.".to_string()); + replies.push("Posts with commands won't be shared. Supported commands:".to_string()); + replies.push("/ignore, /ign, /i - don't run any commands in the post".to_string()); + replies.push("/boost, /b - boost the replied-to post into the group".to_string()); + replies.push("/leave - leave the group as a member".to_string()); if is_admin { - reply.push("/members".to_string()); - reply.push("/kick, /remove user - kick a member".to_string()); - reply.push("/add user - add a member".to_string()); - reply.push("/ban x - ban a user or a server".to_string()); - reply.push("/unban x - lift a ban".to_string()); - reply.push("/op, /admin user - give admin rights".to_string()); - reply.push("/unop, /unadmin user - remove admin rights".to_string()); - reply.push("/opengroup, /closegroup - control posting access".to_string()); + replies.push("/members".to_string()); + replies.push("/kick, /remove user - kick a member".to_string()); + replies.push("/add user - add a member".to_string()); + replies.push("/ban x - ban a user or a server".to_string()); + replies.push("/unban x - lift a ban".to_string()); + replies.push("/op, /admin user - give admin rights".to_string()); + replies.push("/unop, /unadmin user - remove admin rights".to_string()); + replies.push("/opengroup, /closegroup - control posting access".to_string()); } } StatusCommand::ListMembers => { if is_admin { - reply.push("Member list:".to_string()); - let admins = self.config.get_admins().collect::>(); - let members = self.config.get_members().collect::>(); - for m in members { - if admins.contains(&m) { - reply.push(format!("{} [admin]", m)); - } else { - reply.push(format!("{}", m)); + if self.config.is_member_only() { + replies.push("Group members:".to_string()); + let admins = self.config.get_admins().collect::>(); + let mut members = self.config.get_members().collect::>(); + members.extend(admins.iter()); + members.sort(); + members.dedup(); + for m in members { + if admins.contains(&m) { + replies.push(format!("{} [admin]", m)); + } else { + replies.push(format!("{}", m)); + } + } + } else { + replies.push("Group admins:".to_string()); + let mut admins = self.config.get_admins().collect::>(); + admins.sort(); + for a in admins { + replies.push(format!("{}", a)); } } } } StatusCommand::Leave => { - if self.config.is_member(&n.account.acct) { + if self.config.is_member(¬if_acct) { any_admin_cmd = true; - let _ = self.config.set_member(&n.account.acct, false); - reply.push("You left the group.".to_string()); + let _ = self.config.set_member(¬if_acct, false); + replies.push("You left the group.".to_string()); } } } } tokio::time::sleep(DELAY_BEFORE_ACTION).await; + } - if boost_prev { - self.client.reblog(&status.in_reply_to_id.as_ref().unwrap()).await - .log_error("Failed to boost"); - } + if do_boost_prev_post { + self.client.reblog(&status.in_reply_to_id.as_ref().unwrap()).await + .log_error("Failed to boost"); + } - if !reply.is_empty() { - let r = reply.join("\n"); + if !replies.is_empty() { + let r = replies.join("\n"); - let post = StatusBuilder::new() - .status(format!("@{user}\n{msg}", user=n.account.acct, msg=r)) - .content_type("text/markdown") - .visibility(Visibility::Direct) - .build().expect("error build status"); + let post = StatusBuilder::new() + .status(format!("@{user}\n{msg}", user = notif_acct, msg = r)) + .content_type("text/markdown") + .visibility(Visibility::Direct) + .build().expect("error build status"); - let _ = self.client.new_status(post).await.log_error("Failed to post"); - } + let _ = self.client.new_status(post).await.log_error("Failed to post"); + } - if any_admin_cmd { - self.save_if_needed().await.log_error("Failed to save"); - } + if !announcements.is_empty() { + let msg = announcements.join("\n"); + let post = StatusBuilder::new() + .status(format!("**📢 Group announcement**\n{msg}", msg = msg)) + .content_type("text/markdown") + .visibility(Visibility::Unlisted) + .build().expect("error build status"); + + let _ = self.client.new_status(post).await.log_error("Failed to post"); + } + + if any_admin_cmd { + self.save_if_needed().await.log_error("Failed to save"); } } } @@ -434,11 +483,11 @@ impl GroupHandle { format!( "@{user} welcome to the group! This is a member-only group, you won't be \ able to post. Ask the group admins if you wish to join!\n\n\ - Admins: {admins}", user = &n.account.acct, admins = admins.join(", ")) + Admins: {admins}", user = notif_acct, admins = admins.join(", ")) } else { format!( "@{user} welcome to the group! This is a public-access group. \ - To share a post, tag the group user. Use /help for more info.", user = &n.account.acct) + To share a post, tag the group user. Use /help for more info.", user = notif_acct) }; let post = StatusBuilder::new() @@ -451,11 +500,12 @@ impl GroupHandle { } _ => {} } + + Ok(()) } /// Catch up with missed notifications, returns true if any were handled async fn catch_up_with_missed_notifications(&mut self) -> Result { - const MAX_CATCHUP_NOTIFS: usize = 25; let last_notif = self.config.get_last_notif(); let notifications = self.client.notifications().await?; @@ -489,7 +539,8 @@ impl GroupHandle { for n in notifs_to_handle { debug!("Handling missed notification: {}", NotificationDisplay(&n)); - self.handle_notification(n).await; + self.handle_notification(n).await + .log_error("Error handling a notification"); } return Ok(true); diff --git a/src/main.rs b/src/main.rs index 555a038..0db72a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,76 +1,116 @@ #![deny(unused_must_use)] +extern crate elefren; #[macro_use] extern crate log; #[macro_use] -extern crate smart_default; -#[macro_use] extern crate serde; #[macro_use] +extern crate smart_default; +#[macro_use] extern crate thiserror; -extern crate elefren; -use elefren::entities::event::Event; -use log::LevelFilter; -use elefren::entities::notification::NotificationType; -use elefren::entities::account::Account; -use elefren::status_builder::Visibility; -use elefren::{Registration, Scopes, StatusBuilder, FediClient}; -use tokio_stream::{Stream, StreamExt}; -use elefren::scopes; -use crate::store::{NewGroupOptions, StoreOptions}; +use clap::Arg; +use elefren::{FediClient, Registration, Scopes, StatusBuilder}; use elefren::debug::NotificationDisplay; +use elefren::entities::account::Account; +use elefren::entities::event::Event; +use elefren::entities::notification::NotificationType; +use elefren::scopes; +use elefren::status_builder::Visibility; +use log::LevelFilter; +use tokio_stream::{Stream, StreamExt}; + +use crate::store::{NewGroupOptions, StoreOptions}; +use crate::utils::acct_to_server; mod store; mod group_handle; mod utils; mod command; +mod error; #[tokio::main] async fn main() -> anyhow::Result<()> { + let args = clap::App::new("groups") + .arg(Arg::with_name("verbose") + .short("v") + .multiple(true) + .help("increase logging, can be repeated")) + .arg(Arg::with_name("config") + .short("c") + .long("config") + .takes_value(true) + .help("set custom storage file, defaults to groups.json")) + .arg(Arg::with_name("auth") + .short("a") + .long("auth") + .takes_value(true) + .value_name("HANDLE") + .help("authenticate to a new server (always using https)")) + .arg(Arg::with_name("reauth") + .short("A") + .long("reauth") + .takes_value(true) + .value_name("HANDLE") + .help("authenticate to a new server (always using https)")) + .get_matches(); + + const LEVELS : [LevelFilter; 5] = [ + /// Corresponds to the `Error` log level. + LevelFilter::Error, + /// Corresponds to the `Warn` log level. + LevelFilter::Warn, + /// Corresponds to the `Info` log level. + LevelFilter::Info, + /// Corresponds to the `Debug` log level. + LevelFilter::Debug, + /// Corresponds to the `Trace` log level. + LevelFilter::Trace, + ]; + + let default_level = 2; + + let level = (default_level + args.occurrences_of("verbose") as usize).min(LEVELS.len()); + env_logger::Builder::new() - .filter_level(LevelFilter::Debug) + .filter_level(LEVELS[level]) .write_style(env_logger::WriteStyle::Always) .filter_module("rustls", LevelFilter::Warn) .filter_module("reqwest", LevelFilter::Warn) .init(); let store = store::ConfigStore::new(StoreOptions { - store_path: "groups.json".to_string(), - save_pretty: true + store_path: args.value_of("config").unwrap_or("groups.json").to_string(), + save_pretty: true, }).await?; - /* - let mut new_group = store.auth_new_group(NewGroupOptions { - server: "https://botsin.space".to_string(), - acct: "betty@botsin.space".to_string() - }).await?; - // */ + if let Some(handle) = args.value_of("auth") { + let acct = handle.trim_start_matches('@'); + if let Some(server) = acct_to_server(acct) { + let g = store.auth_new_group(NewGroupOptions { + server: format!("https://{}", server), + acct: acct.to_string() + }).await?; - let mut groups = store.spawn_groups().await; - //groups.push(new_group); - - -/* - let mut betty = groups.remove(0); - - let notifications = betty.client.notifications().await?; - let mut iter = notifications.items_iter(); - - let mut num = 0; - while let Some(n) = iter.next_item().await { - debug!("A notification: {}", NotificationDisplay(&n)); - num += 1; - if num > 10 { - break; + eprintln!("New group @{} added to config!", g.config.get_acct()); + return Ok(()); + } else { + anyhow::bail!("--auth handle must be username@server, got: \"{}\"", handle); } } - return Ok(()); - */ + if let Some(acct) = args.value_of("reauth") { + let acct = acct.trim_start_matches('@'); + let _ = store.reauth_group(acct).await?; + eprintln!("Group @{} re-authed!", acct); + return Ok(()); + } + + // Start + let mut groups = store.spawn_groups().await; let mut handles = vec![]; - for mut g in groups { handles.push(tokio::spawn(async move { g.run().await @@ -79,7 +119,6 @@ async fn main() -> anyhow::Result<()> { futures::future::join_all(handles).await; - println!("Main loop ended!"); - + eprintln!("Main loop ended!"); Ok(()) } diff --git a/src/store/data.rs b/src/store/data.rs index 5f2babf..fc5a4a3 100644 --- a/src/store/data.rs +++ b/src/store/data.rs @@ -1,9 +1,10 @@ use std::collections::{HashMap, HashSet}; -use crate::store; -use crate::store::GroupError; use elefren::AppData; +use crate::error::GroupError; +use crate::store; + /// This is the inner data struct holding the config #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub(crate) struct Config { @@ -239,8 +240,8 @@ fn acct_to_server(acct: &str) -> &str { #[cfg(test)] mod tests { - use crate::store::{GroupError}; - use crate::store::data::{GroupConfig, acct_to_server}; + use crate::error::GroupError; + use crate::store::data::{acct_to_server, GroupConfig}; #[test] fn test_acct_to_server() { diff --git a/src/store/mod.rs b/src/store/mod.rs index ac6a61b..37a2257 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -10,6 +10,7 @@ use tokio::sync::RwLock; use data::{Config, GroupConfig}; +use crate::error::GroupError; use crate::group_handle::GroupHandle; pub(crate) mod data; @@ -168,37 +169,6 @@ impl ConfigStore { } } -#[derive(Debug, Error)] -pub enum GroupError { - #[error("User is admin")] - UserIsAdmin, - #[error("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) diff --git a/src/utils.rs b/src/utils.rs index a3fbf25..57e155d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,6 @@ use std::error::Error; +use std::borrow::Cow; +use crate::error::GroupError; pub trait LogError { fn log_error>(self, msg: S); @@ -14,3 +16,46 @@ impl LogError for Result { } } } + +pub(crate) fn acct_to_server(acct : &str) -> Option<&str> { + acct.trim_start_matches('@').split('@').nth(1) +} + +pub(crate) fn normalize_acct<'a, 'g>(acct : &'a str, group: &'g str) -> Result, GroupError> { + let acct = acct.trim_start_matches('@'); + if acct_to_server(acct).is_some() { + Ok(Cow::Borrowed(acct)) + } else { + if let Some(gs) = acct_to_server(group) { + Ok(Cow::Owned(format!("{}@{}", acct, gs))) + } else { + Err(GroupError::BadConfig(format!("Group acct {} is missing server!", group).into())) + } + } +} + +#[cfg(test)] +mod test { + use crate::utils::{acct_to_server, normalize_acct}; + use crate::error::GroupError; + use std::borrow::Cow; + + #[test] + fn test_acct_to_server() { + assert_eq!(Some("novak"), acct_to_server("pepa@novak")); + assert_eq!(Some("banana.co.uk"), acct_to_server("@pepa@banana.co.uk")); + assert_eq!(None, acct_to_server("probably_local")); + } + + #[test] + fn test_normalize_acct() { + assert_eq!(Ok("piggo@piggo.space".into()), normalize_acct("piggo", "betty@piggo.space")); + assert_eq!(Ok("piggo@piggo.space".into()), normalize_acct("@piggo", "betty@piggo.space")); + assert_eq!(Ok("piggo@piggo.space".into()), normalize_acct("@piggo@piggo.space", "betty@piggo.space")); + assert_eq!(Ok("piggo@piggo.space".into()), normalize_acct("@piggo@piggo.space", "oggip@mastodon.social")); + assert_eq!(Ok("piggo@piggo.space".into()), normalize_acct("piggo@piggo.space", "oggip@mastodon.social")); + assert_eq!(Ok("piggo@mastodon.social".into()), normalize_acct("@piggo", "oggip@mastodon.social")); + assert_eq!(Ok("piggo@piggo.space".into()), normalize_acct("piggo@piggo.space", "uhh")); + assert_eq!(Err(GroupError::BadConfig("_".into())), normalize_acct("piggo", "uhh")); + } +}