diff --git a/Cargo.lock b/Cargo.lock index def3ea7..21b8d23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -530,12 +530,15 @@ dependencies = [ "futures 0.3.16", "log 0.4.14", "native-tls", + "once_cell", + "regex", "serde", "serde_json", "smart-default", "thiserror", "tokio", "tokio-stream", + "voca_rs", "websocket", ] @@ -1777,6 +1780,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dba1a27d3efae4351c8051072d619e3ade2820635c3958d826bfea39d59b54c8" +[[package]] +name = "stfu8" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf70433e3300a3c395d06606a700cdf4205f4f14dbae2c6833127c6bb22db77" +dependencies = [ + "lazy_static", + "regex", +] + [[package]] name = "strsim" version = "0.8.0" @@ -2144,6 +2157,12 @@ dependencies = [ "smallvec 0.6.10", ] +[[package]] +name = "unicode-segmentation" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b" + [[package]] name = "unicode-width" version = "0.1.6" @@ -2215,6 +2234,17 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" +[[package]] +name = "voca_rs" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec29ce40c253a1579092852bbea5cb4fbcf34c04b91d8127300202aa17c998fc" +dependencies = [ + "regex", + "stfu8", + "unicode-segmentation", +] + [[package]] name = "want" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index d6d169d..d72d7f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ tokio = { version = "1", features = ["full"] } tokio-stream = "0.1.7" thiserror = "1.0.26" futures = "0.3" +voca_rs = "1.13.0" +regex = "1.5.4" +once_cell = "1.8.0" native-tls = "0.2.8" websocket = "0.26.2" diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..d33f7ba --- /dev/null +++ b/src/command.rs @@ -0,0 +1,443 @@ +use once_cell::sync::Lazy; +use regex::{Regex, RegexSetBuilder}; + +#[derive(Debug, Clone, PartialEq)] +pub enum StatusCommand { + Boost, + Ignore, + BanUser(String), + UnbanUser(String), + BanServer(String), + UnbanServer(String), + AddMember(String), + RemoveMember(String), + GrantAdmin(String), + RemoveAdmin(String), + OpenGroup, + CloseGroup, + Help, + ListMembers, + Leave, +} + +macro_rules! p_user { + () => { + r"(@?[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+\.[a-z0-9_-]+|@[a-zA-Z0-9_.-]+)" + } +} +macro_rules! p_server { + () => { + r"([a-zA-Z0-9_.-]+\.[a-zA-Z0-9_-]+)" + } +} + +macro_rules! command { + ($($val:expr),+) => { + Regex::new(concat!(r"(?:^|\s|>|\n)[\\/]", $($val,)+ r"(?:$|[!,]|\W)")).unwrap() + } +} + +static RE_BOOST: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"b(?:oost)?") +}); + +static RE_IGNORE: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"i(?:gn(?:ore)?)?") +}); + +static RE_BAN_USER: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"ban\s+", p_user!()) +}); + +static RE_UNBAN_USER: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"unban\s+", p_user!()) +}); + +static RE_BAN_SERVER: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"ban\s+", p_server!()) +}); + +static RE_UNBAN_SERVER: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"unban\s+", p_server!()) +}); + +static RE_ADD_MEMBER: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"(?:accept|invite|member|add)\s+", p_user!()) +}); + +static RE_REMOVE_MEMBER: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"(?:expel|kick|remove)\s+", p_user!()) +}); + +static RE_GRANT_ADMIN: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"(?:op|admin|grant)\s+", p_user!()) +}); + +static RE_REVOKE_ADMIN: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"(?:deop|unop|deadmin|unadmin|ungrant|revoke)\s+", p_user!()) +}); + +static RE_OPEN_GROUP: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"opengroup") +}); + +static RE_CLOSE_GROUP: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"closegroup") +}); + +static RE_HELP: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"help") +}); + +static RE_MEMBERS: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"(?:list|members)") +}); + +static RE_LEAVE: once_cell::sync::Lazy = Lazy::new(|| { + command!(r"(?:leave|quit)") +}); + +pub fn parse_status(content: &str) -> Vec { + debug!("Raw content: {}", content); + + let content = content.replace("
", " "); + // let content = content.replace("
", " "); + // let content = content.replace("
", " "); + // let content = content.replace("
", " "); + + let content = voca_rs::strip::strip_tags(&content); + debug!("Stripped tags: {}", content); + + // short-circuiting commands + + if RE_IGNORE.is_match(&content) { + debug!("IGNORE"); + return vec![StatusCommand::Ignore]; + } + + if RE_HELP.is_match(&content) { + debug!("HELP"); + return vec![StatusCommand::Help]; + } + + // additive commands + + let mut commands = vec![]; + + if RE_BOOST.is_match(&content) { + debug!("BOOST"); + commands.push(StatusCommand::Boost); + } + + if RE_LEAVE.is_match(&content) { + debug!("LEAVE"); + commands.push(StatusCommand::Leave); + } + + if RE_MEMBERS.is_match(&content) { + debug!("MEMBERS"); + commands.push(StatusCommand::ListMembers); + } + + if RE_OPEN_GROUP.is_match(&content) { + debug!("OPEN GROUP"); + commands.push(StatusCommand::OpenGroup); + } + + if RE_CLOSE_GROUP.is_match(&content) { + debug!("CLOSE GROUP"); + commands.push(StatusCommand::CloseGroup); + } + + if let Some(c) = RE_BAN_USER.captures(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + let s = s.trim_start_matches('@'); + debug!("BAN USER: {}", s); + commands.push(StatusCommand::BanUser(s.to_owned())); + } + } + + if let Some(c) = RE_UNBAN_USER.captures(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + let s = s.trim_start_matches('@'); + debug!("UNBAN USER: {}", s); + commands.push(StatusCommand::UnbanUser(s.to_owned())); + } + } + + if let Some(c) = RE_BAN_SERVER.captures(&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) { + 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) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + let s = s.trim_start_matches('@'); + debug!("ADD MEMBER: {}", s); + commands.push(StatusCommand::AddMember(s.to_owned())); + } + } + + if let Some(c) = RE_REMOVE_MEMBER.captures(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + let s = s.trim_start_matches('@'); + debug!("UNBAN USER: {}", s); + commands.push(StatusCommand::RemoveMember(s.to_owned())); + } + } + + if let Some(c) = RE_GRANT_ADMIN.captures(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + let s = s.trim_start_matches('@'); + debug!("ADD ADMIN: {}", s); + commands.push(StatusCommand::GrantAdmin(s.to_owned())); + } + } + + if let Some(c) = RE_REVOKE_ADMIN.captures(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + let s = s.trim_start_matches('@'); + debug!("REMOVE ADMIN: {}", s); + commands.push(StatusCommand::RemoveAdmin(s.to_owned())); + } + } + + commands +} + +#[cfg(test)] +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}; + + #[test] + fn test_boost() { + assert!(RE_BOOST.is_match("/b")); + assert!(RE_BOOST.is_match(">/b")); + assert!(RE_BOOST.is_match("/b mm")); + assert!(RE_BOOST.is_match("/b.")); + assert!(RE_BOOST.is_match("\\b")); + assert!(!RE_BOOST.is_match("boo/b")); + assert!(RE_BOOST.is_match("bla\n/b")); + assert!(RE_BOOST.is_match("/boost")); + assert!(RE_BOOST.is_match("/boost\n")); + assert!(RE_BOOST.is_match("/boost dfdfg")); + assert!(!RE_BOOST.is_match("/boosty")); + assert!(RE_BOOST.is_match("/b\nxxx")); + assert!(!RE_BOOST.is_match("/bleble\n")); + } + + #[test] + fn test_ignore() { + assert!(RE_IGNORE.is_match("/i")); + 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")); + assert!(RE_IGNORE.is_match("/ignore dfdfg")); + assert!(!RE_IGNORE.is_match("/ignorey")); + assert!(RE_IGNORE.is_match("/i\nxxx")); + assert!(!RE_IGNORE.is_match("/ileble\n")); + } + + #[test] + fn test_ban_user() { + assert!(RE_BAN_USER.is_match("/ban lain@pleroma.soykaf.com")); + assert!(RE_BAN_USER.is_match("/ban lain@stupidname.uk")); + assert!(RE_BAN_USER.is_match("bababababa /ban lain@stupidname.uk lala")); + assert!(!RE_BAN_USER.is_match("/ban stupidname.uk")); + + assert!(RE_BAN_USER.is_match("/ban @lain")); + assert!(RE_BAN_USER.is_match("/ban @lain aaa")); + + assert!(RE_BAN_USER.is_match("/ban \t lain@pleroma.soykaf.com")); + assert!(RE_BAN_USER.is_match("/ban @lain@pleroma.soykaf.com")); + assert!(RE_BAN_USER.is_match("/ban @l-a_i.n9@xn--999pleroma-weirdname.com")); + assert!(RE_BAN_USER.is_match("/ban @LAIN@PleromA.soykaf.com")); + + let c = RE_BAN_USER.captures("/ban lain@pleroma.soykaf.com"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "lain@pleroma.soykaf.com"); + + let c = RE_BAN_USER.captures("/ban lain@pleroma.soykaf.com xx"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "lain@pleroma.soykaf.com"); + + let c = RE_BAN_USER.captures("/ban @lain@pleroma.soykaf.com"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain@pleroma.soykaf.com"); + + let c = RE_BAN_USER.captures("/ban @lain"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); + + let c = RE_BAN_USER.captures("/ban @lain xx"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); + } + + #[test] + fn test_ban_server() { + assert!(!RE_BAN_SERVER.is_match("/ban lain@pleroma.soykaf.com")); + assert!(RE_BAN_SERVER.is_match("/ban pleroma.soykaf.com")); + assert!(RE_BAN_SERVER.is_match("/ban xn--999pleroma-weirdname.com")); + assert!(RE_BAN_SERVER.is_match("/ban \t xn--999pleroma-weirdname.com")); + assert!(RE_BAN_SERVER.is_match("mamama /ban pleroma.soykaf.com momomo")); + assert!(!RE_BAN_SERVER.is_match("/ban @pleroma.soykaf.com")); + + let c = RE_BAN_SERVER.captures("/ban pleroma.soykaf.com"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "pleroma.soykaf.com"); + + let c = RE_BAN_SERVER.captures("/ban pleroma.soykaf.com xx"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "pleroma.soykaf.com"); + } + + #[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")); + + let c = RE_ADD_MEMBER.captures("/add @lain"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); + } + + #[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("/kick @lain")); + assert!(RE_REMOVE_MEMBER.is_match("/remove @lain")); + + let c = RE_REMOVE_MEMBER.captures("/kick lain@pleroma.soykaf.com"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "lain@pleroma.soykaf.com"); + } + + #[test] + fn test_add_admin() { + 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")); + + let c = RE_GRANT_ADMIN.captures("/op @lain@pleroma.soykaf.com"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain@pleroma.soykaf.com"); + } + + #[test] + fn test_remove_admin() { + assert!(!RE_REVOKE_ADMIN.is_match("/admin lain@pleroma.soykaf.com")); + + 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"); + assert!(c.is_some()); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); + } + + #[test] + fn test_opengroup() { + assert!(!RE_OPEN_GROUP.is_match("/admin lain@pleroma.soykaf.com")); + assert!(RE_OPEN_GROUP.is_match("/opengroup")); + assert!(RE_OPEN_GROUP.is_match("x /opengroup")); + assert!(RE_OPEN_GROUP.is_match("/opengroup dfgdfg")); + assert!(RE_OPEN_GROUP.is_match("\n\n/opengroup\n dfgdfg\n\n")); + } + + #[test] + fn test_closegroup() { + assert!(!RE_CLOSE_GROUP.is_match("/admin lain@pleroma.soykaf.com")); + assert!(RE_CLOSE_GROUP.is_match("/closegroup")); + assert!(RE_CLOSE_GROUP.is_match("x /closegroup")); + assert!(RE_CLOSE_GROUP.is_match("/closegroup dfgdfg")); + assert!(RE_CLOSE_GROUP.is_match("\n\n/closegroup\n dfgdfg\n\n")); + } + + #[test] + fn test_help() { + assert!(!RE_HELP.is_match("/admin lain@pleroma.soykaf.com")); + assert!(RE_HELP.is_match("/help")); + + assert!(!RE_HELP.is_match("/helpx")); + assert!(!RE_HELP.is_match("a/help")); + assert!(!RE_HELP.is_match("help")); + + assert!(RE_HELP.is_match("x /help")); + assert!(RE_HELP.is_match("/help dfgdfg")); + assert!(RE_HELP.is_match("\n\n/help\n dfgdfg\n\n")); + } + + #[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")); + } + + #[test] + fn test_real_post() { + 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")); + } + + #[test] + fn test_strip() { + assert_eq!(vec![StatusCommand::BanUser("betty".to_string())], + parse_status(r#"Let's bad the naughty bot: /ban @betty"#)); + assert_eq!(vec![StatusCommand::BanUser("betty@abstarbauze.com".to_string())], + parse_status(r#"Let's bad the naughty bot: /ban @betty@abstarbauze.com"#)); + } +} diff --git a/src/group_handle.rs b/src/group_handle.rs index 6029187..f2a73b4 100644 --- a/src/group_handle.rs +++ b/src/group_handle.rs @@ -9,9 +9,11 @@ use elefren::entities::notification::{Notification, NotificationType}; use elefren::status_builder::Visibility; use futures::StreamExt; +use crate::command::StatusCommand; use crate::store::{ConfigStore, GroupError}; use crate::store::data::GroupConfig; use crate::utils::LogError; +use std::collections::HashSet; /// This is one group's config store capable of persistence #[derive(Debug)] @@ -125,25 +127,298 @@ impl GroupHandle { } async fn handle_notification(&mut self, n: Notification) { + const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(500); + 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); + + if self.config.is_banned(&n.account.acct) { + warn!("Notification actor {} is banned!", n.account.acct); + return; + } + 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"); + if self.config.is_banned(&status.account.acct) { + warn!("Status author {} is banned!", status.account.acct); + return; + } + + let commands = crate::command::parse_status(&status.content); + + 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"); + } else { + debug!("Not OP, ignore mention") } } else { - info!("Boosting mention"); - tokio::time::sleep(Duration::from_millis(500)).await; -// self.client.reblog(&status.id).await.log_error("Failed to boost"); + 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 { + StatusCommand::Ignore => { + debug!("Notif ignored because of ignore command"); + return; + } + StatusCommand::Boost => { + if !can_write { + warn!("User {} not allowed to boost to group", n.account.acct); + } else { + boost_prev = status.in_reply_to_id.is_some(); + } + } + StatusCommand::BanUser(u) => { + 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)); + } + Err(e) => { + reply.push(format!("Failed to ban user {}: {}", u, e)); + } + } + } + } else { + warn!("Not admin, can't manage bans"); + } + } + StatusCommand::UnbanUser(u) => { + 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)); + } + Err(e) => { + unreachable!() + } + } + } + } else { + warn!("Not admin, can't manage bans"); + } + } + StatusCommand::BanServer(s) => { + if is_admin { + if !self.config.is_server_banned(&s) { + 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)); + } + Err(e) => { + reply.push(format!("Failed to ban instance {}: {}", s, e)); + } + } + } + } else { + warn!("Not admin, can't manage bans"); + } + } + StatusCommand::UnbanServer(s) => { + if is_admin { + if self.config.is_server_banned(&s) { + 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)); + } + Err(e) => { + unreachable!() + } + } + } + } else { + warn!("Not admin, can't manage bans"); + } + } + StatusCommand::AddMember(u) => { + 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); + } + Err(e) => { + reply.push(format!("Failed to add user {} to group: {}", u, e)); + } + } + } + } else { + warn!("Not admin, can't manage members"); + } + } + StatusCommand::RemoveMember(u) => { + 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)); + } + Err(e) => { + unreachable!() + } + } + } + } else { + warn!("Not admin, can't manage members"); + } + } + StatusCommand::GrantAdmin(u) => { + 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); + } + Err(e) => { + reply.push(format!("Failed to make user {} a group admin: {}", u, e)); + } + } + } + } else { + warn!("Not admin, can't manage admin rights"); + } + } + StatusCommand::RemoveAdmin(u) => { + 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) + } + Err(e) => { + reply.push(format!("Failed to revoke {}'s group admin: {}", u, e)); + } + } + } + } else { + warn!("Not admin, can't manage admin rights"); + } + } + StatusCommand::OpenGroup => { + if is_admin { + if self.config.is_member_only() { + any_admin_cmd = true; + self.config.set_member_only(false); + reply.push(format!("Group changed to open-access")); + } + } else { + warn!("Not admin, can't manage group mode"); + } + } + StatusCommand::CloseGroup => { + if is_admin { + if !self.config.is_member_only() { + any_admin_cmd = true; + self.config.set_member_only(true); + reply.push(format!("Group changed to member-only")); + } + } else { + warn!("Not admin, can't manage group mode"); + } + } + 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()); + + 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()); + } + } + 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)); + } + } + } + } + StatusCommand::Leave => { + if self.config.is_member(&n.account.acct) { + any_admin_cmd = true; + let _ = self.config.set_member(&n.account.acct, false); + reply.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 !reply.is_empty() { + let r = reply.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 _ = 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"); + } } } } @@ -151,15 +426,28 @@ impl GroupHandle { info!("New follower!"); tokio::time::sleep(Duration::from_millis(500)).await; - /* + let text = if self.config.is_member_only() { + // Admins are listed without @, so they won't become handles here. + // Tagging all admins would be annoying. + let mut admins = self.config.get_admins().cloned().collect::>(); + admins.sort(); + 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(", ")) + } 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) + }; + let post = StatusBuilder::new() - .status(format!("@{} welcome to the group!", &n.account.acct)) + .status(text) .content_type("text/markdown") .visibility(Visibility::Unlisted) .build().expect("error build status"); let _ = self.client.new_status(post).await.log_error("Failed to post"); - */ } _ => {} } diff --git a/src/main.rs b/src/main.rs index 00fe785..555a038 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,6 +24,7 @@ use elefren::debug::NotificationDisplay; mod store; mod group_handle; mod utils; +mod command; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -78,104 +79,7 @@ async fn main() -> anyhow::Result<()> { futures::future::join_all(handles).await; - /* - - let client = if let Ok(data) = elefren::helpers::toml::from_file("group-actor-data.toml") { - FediClient::from(data) - } else { - register().await? - }; - - let you = client.verify_credentials().await?; - println!("{:#?}", you); - - let mut events = client.streaming_user().await?; - - while let Some(event) = events.next().await { - match event { - Event::Update(status) => { - info!("Status: {:?}", status); - }, - Event::Notification(notification) => { - info!("Notification: {:?}", notification.notification_type); - debug!("{:?}", notification); - - /* - match notification.notification_type { - NotificationType::Mention => { - if let Some(status) = notification.status { - if status.content.contains("/gi") { - debug!("GRPIGNORE!"); - } else if status.content.contains("/gb") { - debug!("GROUP BOOST PREV!"); - if let Some(id) = status.in_reply_to_id { - let _ = client.reblog(&id); - } - } - // else if status.content.contains("/gping") { - // let post = StatusBuilder::new() - // .status(format!("@{} hello", ¬ification.account.acct)) - // .content_type("text/markdown") - // //.in_reply_to(status.id) - // .visibility(Visibility::Unlisted) - // .build().expect("error build status"); - // - // let _ = client.new_status(post); - // } - else { - info!("BOOSTING"); - let _ = client.reblog(&status.id); - } - } - } - NotificationType::Follow => { - info!("New follower!"); - - let post = StatusBuilder::new() - .status(format!("@{} welcome to the group!", ¬ification.account.acct)) - .content_type("text/markdown") - .visibility(Visibility::Unlisted) - .build().expect("error build status"); - - let _ = client.new_status(post).await; - } - _ => {} - } - */ - }, - Event::Delete(id) => { - info!("Delete: {}", id); - }, - Event::FiltersChanged => { - info!("FiltersChanged"); - // ??? - }, - } - } - - */ - println!("Main loop ended!"); Ok(()) } - -/* -async fn register() -> anyhow::Result { - let registration = Registration::new("https://piggo.space") - .client_name("group-actor") - .force_login(true) - .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) - ) - .build().await?; - let client = elefren::helpers::cli::authenticate(registration).await?; - elefren::helpers::toml::to_file(&*client, "group-actor-data.toml")?; - Ok(client) -} -*/ diff --git a/src/store/data.rs b/src/store/data.rs index e8eaf55..5f2babf 100644 --- a/src/store/data.rs +++ b/src/store/data.rs @@ -101,6 +101,14 @@ impl GroupConfig { self.mark_dirty(); } + pub(crate) fn get_admins(&self) -> impl Iterator { + self.admin_users.iter() + } + + pub(crate) fn get_members(&self) -> impl Iterator { + self.member_users.iter() + } + pub(crate) fn set_last_notif(&mut self, ts: u64) { self.last_notif_ts = self.last_notif_ts.max(ts); self.mark_dirty(); @@ -127,10 +135,14 @@ impl GroupConfig { || self.is_users_server_banned(acct) } + pub(crate) fn is_server_banned(&self, server: &str) -> bool { + self.banned_servers.contains(server) + } + /// 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) + self.is_server_banned(server) } pub(crate) fn can_write(&self, acct: &str) -> bool { diff --git a/src/store/mod.rs b/src/store/mod.rs index c87edc5..ac6a61b 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -170,9 +170,9 @@ impl ConfigStore { #[derive(Debug, Error)] pub enum GroupError { - #[error("Operation refused because the user is admin")] + #[error("User is admin")] UserIsAdmin, - #[error("Operation refused because the user is banned")] + #[error("User is banned")] UserIsBanned, #[error("Server could not be banned because there are admin users on it")] AdminsOnServer,