diff --git a/README.md b/README.md index ab602da..a1f3408 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,12 @@ When a *group member* posts one of the group hashtags, the group will reblog it. For group hashtags to work, the group user must follow all its members; otherwise the posts might not federate to the group's server. +### Opting-out and #nobot + +The group service respects the `#nobot` tag in users' profiles. When it's detected, the user's posts can't be shared to the group using the `/boost` command, unless they explicitly join. + +To prevent individual groups from boosting your posts, use the `/optout` command. + ### List of commands *Note on command arguments:* @@ -165,6 +171,8 @@ For group hashtags to work, the group user must follow all its members; otherwis - `/ping` - ping the group service to check it's running, it will reply - `/join` - join the group - `/leave` - leave the group +- `/optout` - forbid sharing of your posts to the group (no effect for admins and members) +- `/optin` - reverse an opt-out - `/undo` - undo a boost of your post into the group, e.g. when you triggered it unintentionally. Use in a reply to the boosted post, tagging the group user. You can also un-boost your status when someone else shared it into the group using `/boost`, this works even if you're not a member. **For admins** diff --git a/src/command.rs b/src/command.rs index b0cff03..7612e3e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,5 +1,6 @@ use once_cell::sync::Lazy; use regex::Regex; +use crate::utils; #[derive(Debug, Clone, PartialEq)] pub enum StatusCommand { @@ -31,6 +32,10 @@ pub enum StatusCommand { RemoveAdmin(String), /// Admin: Send a public announcement Announce(String), + /// Opt out of boosts + OptOut, + /// Opt in to boosts + OptIn, /// Admin: Make the group open-access OpenGroup, /// Admin: Make the group member-only, this effectively disables posting from non-members @@ -120,6 +125,10 @@ static RE_LEAVE: once_cell::sync::Lazy = Lazy::new(|| command!(r"leave")) static RE_JOIN: once_cell::sync::Lazy = Lazy::new(|| command!(r"join")); +static RE_OPTOUT: once_cell::sync::Lazy = Lazy::new(|| command!(r"optout")); + +static RE_OPTIN: once_cell::sync::Lazy = Lazy::new(|| command!(r"optin")); + static RE_PING: once_cell::sync::Lazy = Lazy::new(|| command!(r"ping")); static RE_ANNOUNCE: once_cell::sync::Lazy = @@ -128,14 +137,15 @@ static RE_ANNOUNCE: once_cell::sync::Lazy = static RE_A_HASHTAG: once_cell::sync::Lazy = Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#(\w+)").unwrap()); +pub static RE_NOBOT_TAG: once_cell::sync::Lazy = + Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#nobot(?:\b|$)").unwrap()); + pub static RE_HASHTAG_TRIGGERING_PLEROMA_BUG: once_cell::sync::Lazy = Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#\w+[^\s]*$").unwrap()); pub fn parse_status_tags(content: &str) -> Vec { debug!("Raw content: {}", content); - let content = content.replace("
", "
"); - let content = content.replace("

", "

"); - let content = voca_rs::strip::strip_tags(&content); + let content = utils::strip_html(content); debug!("Stripped tags: {}", content); let mut tags = vec![]; @@ -150,11 +160,7 @@ pub fn parse_status_tags(content: &str) -> Vec { pub fn parse_slash_commands(content: &str) -> Vec { debug!("Raw content: {}", content); - - let content = content.replace("
", "
"); - let content = content.replace("

", "

"); - - let content = voca_rs::strip::strip_tags(&content); + let content = utils::strip_html(content); debug!("Stripped tags: {}", content); if !content.contains('/') && !content.contains('\\') { @@ -198,6 +204,14 @@ pub fn parse_slash_commands(content: &str) -> Vec { commands.push(StatusCommand::Join); } + if RE_OPTOUT.is_match(&content) { + debug!("OPT-OUT"); + commands.push(StatusCommand::OptOut); + } else if RE_OPTIN.is_match(&content) { + debug!("OPT-IN"); + commands.push(StatusCommand::OptIn); + } + if RE_PING.is_match(&content) { debug!("PING"); commands.push(StatusCommand::Ping); @@ -322,11 +336,11 @@ pub fn parse_slash_commands(content: &str) -> Vec { #[cfg(test)] mod test { - use crate::command::{parse_slash_commands, RE_A_HASHTAG, RE_HASHTAG_TRIGGERING_PLEROMA_BUG, RE_ADD_TAG, RE_JOIN, StatusCommand}; + use crate::command::{parse_slash_commands, RE_A_HASHTAG, RE_HASHTAG_TRIGGERING_PLEROMA_BUG, RE_NOBOT_TAG, RE_ADD_TAG, RE_JOIN, StatusCommand}; 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, RE_TAGS, RE_UNDO, + RE_IGNORE, RE_LEAVE, RE_OPTOUT, RE_OPTIN, RE_MEMBERS, RE_OPEN_GROUP, RE_REMOVE_MEMBER, RE_REVOKE_ADMIN, RE_TAGS, RE_UNDO, }; #[test] @@ -554,6 +568,7 @@ mod test { } } } + #[test] fn test_match_tag_at_end() { assert!(!RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match("banana #tag sdfsd")); @@ -564,6 +579,17 @@ mod test { assert!(RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match("#tag...")); } + #[test] + fn test_match_tag_nobot() { + assert!(!RE_NOBOT_TAG.is_match("banana #tag sdfsd")); + assert!(!RE_NOBOT_TAG.is_match("banana #nobotanicals sdfsd")); + assert!(RE_NOBOT_TAG.is_match("#nobot")); + assert!(RE_NOBOT_TAG.is_match("aaa#nobot")); + assert!(RE_NOBOT_TAG.is_match("aaa #nobot")); + assert!(RE_NOBOT_TAG.is_match("#nobot xxx")); + assert!(RE_NOBOT_TAG.is_match("#nobot\nxxx")); + } + #[test] fn test_leave() { assert!(!RE_LEAVE.is_match("/list")); @@ -573,6 +599,24 @@ mod test { assert!(RE_LEAVE.is_match("/leave z")); } + #[test] + fn test_optout() { + assert!(!RE_OPTOUT.is_match("/list")); + assert!(!RE_OPTOUT.is_match("/optoutaaa")); + assert!(RE_OPTOUT.is_match("/optout")); + assert!(RE_OPTOUT.is_match("x /optout")); + assert!(RE_OPTOUT.is_match("/optout z")); + } + + #[test] + fn test_optin() { + assert!(!RE_OPTIN.is_match("/list")); + assert!(!RE_OPTIN.is_match("/optinaaa")); + assert!(RE_OPTIN.is_match("/optin")); + assert!(RE_OPTIN.is_match("x /optin")); + assert!(RE_OPTIN.is_match("/optin z")); + } + #[test] fn test_undo() { assert!(!RE_UNDO.is_match("/list")); diff --git a/src/error.rs b/src/error.rs index 3e0095d..952ba59 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,6 +6,8 @@ pub enum GroupError { UserIsAdmin, #[error("User is banned")] UserIsBanned, + #[error("User opted out from the group")] + UserOptedOut, #[error("Server could not be banned because there are admin users on it")] AdminsOnServer, #[error("Group config is missing in the config store")] diff --git a/src/group_handler/handle_mention.rs b/src/group_handler/handle_mention.rs index a47eef1..d45c446 100644 --- a/src/group_handler/handle_mention.rs +++ b/src/group_handler/handle_mention.rs @@ -2,15 +2,16 @@ use std::collections::HashSet; use std::time::Duration; use elefren::{FediClient, SearchType, StatusBuilder}; +use elefren::entities::account::Account; use elefren::entities::prelude::Status; use elefren::status_builder::Visibility; -use crate::command::StatusCommand; +use crate::command::{RE_NOBOT_TAG, StatusCommand}; use crate::error::GroupError; use crate::group_handler::GroupHandle; use crate::store::data::GroupConfig; use crate::utils::{LogError, normalize_acct}; -use elefren::entities::account::Account; +use crate::utils; pub struct ProcessMention<'a> { status: Status, @@ -52,7 +53,7 @@ impl<'a> ProcessMention<'a> { let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?; if acct_normalized == acct { debug!("Search done, account found: {}", item.acct); - return Ok(Some(item.id)) + return Ok(Some(item.id)); } else { warn!("Found wrong account: {}", item.acct); } @@ -175,6 +176,12 @@ impl<'a> ProcessMention<'a> { self.cmd_unban_user(&u).await .log_error("Error handling unban-user cmd"); } + StatusCommand::OptOut => { + self.cmd_optout().await; + } + StatusCommand::OptIn => { + self.cmd_optin().await; + } StatusCommand::BanServer(s) => { self.cmd_ban_server(&s).await; } @@ -232,10 +239,19 @@ impl<'a> ProcessMention<'a> { } if self.do_boost_prev_post { - self.client - .reblog(self.status.in_reply_to_id.as_ref().unwrap()) - .await - .log_error("Failed to boost"); + if let (Some(prev_acct_id), Some(prev_status_id)) = (self.status.in_reply_to_account_id.as_ref(), self.status.in_reply_to_id.as_ref()) { + match self.id_to_acct_check_boostable(prev_acct_id).await { + Ok(_acct) => { + self.client + .reblog(prev_status_id) + .await + .log_error("Failed to boost"); + } + Err(e) => { + warn!("Can't reblog: {}", e); + } + } + } } if !self.replies.is_empty() { @@ -313,6 +329,28 @@ impl<'a> ProcessMention<'a> { } } + async fn cmd_optout(&mut self) { + if self.is_admin { + self.add_reply("Group admins can't opt-out."); + } else if self.config.is_member(&self.status_acct) { + self.add_reply("Group members can't opt-out. You have to leave first."); + } else { + self.config.set_optout(&self.status_acct, true); + self.add_reply("Your posts will no longer be shared to the group."); + } + } + + async fn cmd_optin(&mut self) { + if self.is_admin { + self.add_reply("Opt-in has no effect for admins."); + } else if self.config.is_member(&self.status_acct) { + self.add_reply("Opt-in has no effect for members."); + } else { + self.config.set_optout(&self.status_acct, false); + self.add_reply("Your posts can now be shared to the group."); + } + } + async fn cmd_undo(&mut self) -> Result<(), GroupError> { if let (Some(ref parent_account_id), Some(ref parent_status_id)) = (&self.status.in_reply_to_account_id, &self.status.in_reply_to_id) { if parent_account_id == &self.group_account.id { @@ -589,7 +627,8 @@ impl<'a> ProcessMention<'a> { `/ignore`, `/i` - make the group ignore the post\n\ `/tags` - show group hashtags\n\ `/join` - (re-)join the group\n\ - `/leave` - leave the group"); + `/leave` - leave the group\n\ + `/optout` - forbid sharing of your posts"); if self.is_admin { self.add_reply("`/members`, `/who` - show group members / admins"); @@ -702,6 +741,26 @@ impl<'a> ProcessMention<'a> { } Ok(()) } + + /// Convert ID to account, checking if the user is boostable + async fn id_to_acct_check_boostable(&self, id: &str) -> Result { + // Try to unfollow + let account = self.client.get_account(id).await?; + let bio = utils::strip_html(&account.note); + if RE_NOBOT_TAG.is_match(&bio) { + // #nobot + Err(GroupError::UserOptedOut) + } else { + let normalized = normalize_acct(&account.acct, &self.group_acct)?; + if self.config.is_banned(&normalized) { + return Err(GroupError::UserIsBanned); + } else if self.config.is_optout(&normalized) { + return Err(GroupError::UserOptedOut); + } else { + Ok(normalized) + } + } + } } fn apply_trailing_hashtag_pleroma_bug_workaround(msg: &mut String) { diff --git a/src/group_handler/mod.rs b/src/group_handler/mod.rs index 013f75c..aaa7b7f 100644 --- a/src/group_handler/mod.rs +++ b/src/group_handler/mod.rs @@ -274,6 +274,8 @@ impl GroupHandle { return Ok(()); } + // optout does not work for members and admins, so don't check it + if !self.config.is_member_or_admin(&status_user) { debug!("Status author @{} is not a member, discard", status_user); return Ok(()); @@ -300,10 +302,12 @@ impl GroupHandle { 'tags: for t in tags { if self.config.is_tag_followed(&t) { - info!("REBLOG #{} STATUS", &t); + info!("REBLOG #{} STATUS", t); self.client.reblog(&s.id).await .log_error("Failed to reblog"); break 'tags; // do not reblog multiple times! + } else { + debug!("#{} is not a group tag", t); } } diff --git a/src/store/data.rs b/src/store/data.rs index 6e0ac01..fedab62 100644 --- a/src/store/data.rs +++ b/src/store/data.rs @@ -41,6 +41,8 @@ pub(crate) struct GroupConfig { member_users: HashSet, /// List of users banned from posting to the group banned_users: HashSet, + /// Users who decided they don't want to be shared to the group (does not apply to members) + optout_users: HashSet, /// True if only members should be allowed to write member_only: bool, /// Banned domain names, e.g. kiwifarms.cc @@ -69,6 +71,7 @@ impl Default for GroupConfig { admin_users: Default::default(), member_users: Default::default(), banned_users: Default::default(), + optout_users: Default::default(), member_only: false, banned_servers: Default::default(), last_notif_ts: 0, @@ -147,6 +150,10 @@ impl GroupConfig { &self.acct } + pub(crate) fn is_optout(&self, acct: &str) -> bool { + self.optout_users.contains(acct) + } + pub(crate) fn is_admin(&self, acct: &str) -> bool { self.admin_users.contains(acct) } @@ -212,6 +219,17 @@ impl GroupConfig { Ok(()) } + pub(crate) fn set_optout(&mut self, acct: &str, optout: bool) { + let change = if optout { + self.optout_users.insert(acct.to_owned()) + } else { + self.optout_users.remove(acct) + }; + if change { + self.mark_dirty(); + } + } + pub(crate) fn ban_user(&mut self, acct: &str, ban: bool) -> Result<(), GroupError> { let mut change = false; if ban { diff --git a/src/utils.rs b/src/utils.rs index c1f55db..1536812 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -112,3 +112,9 @@ impl VisExt for Visibility { } } } + +pub(crate) fn strip_html(content: &str) -> String { + let content = content.replace("
", "
"); + let content = content.replace("

", "

"); + voca_rs::strip::strip_tags(&content) +}