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)
+}