add nobot and optout/optin

This commit is contained in:
Ondřej Hruška 2021-08-30 20:03:03 +02:00
parent 37a9f323e6
commit 900132970d
No known key found for this signature in database
GPG key ID: 2C5FD5035250423D
7 changed files with 160 additions and 19 deletions

View file

@ -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. 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 ### List of commands
*Note on command arguments:* *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 - `/ping` - ping the group service to check it's running, it will reply
- `/join` - join the group - `/join` - join the group
- `/leave` - leave 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. - `/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** **For admins**

View file

@ -1,5 +1,6 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
use crate::utils;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum StatusCommand { pub enum StatusCommand {
@ -31,6 +32,10 @@ pub enum StatusCommand {
RemoveAdmin(String), RemoveAdmin(String),
/// Admin: Send a public announcement /// Admin: Send a public announcement
Announce(String), Announce(String),
/// Opt out of boosts
OptOut,
/// Opt in to boosts
OptIn,
/// Admin: Make the group open-access /// Admin: Make the group open-access
OpenGroup, OpenGroup,
/// Admin: Make the group member-only, this effectively disables posting from non-members /// Admin: Make the group member-only, this effectively disables posting from non-members
@ -120,6 +125,10 @@ static RE_LEAVE: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"leave"))
static RE_JOIN: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"join")); static RE_JOIN: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"join"));
static RE_OPTOUT: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"optout"));
static RE_OPTIN: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"optin"));
static RE_PING: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"ping")); static RE_PING: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"ping"));
static RE_ANNOUNCE: once_cell::sync::Lazy<Regex> = static RE_ANNOUNCE: once_cell::sync::Lazy<Regex> =
@ -128,14 +137,15 @@ static RE_ANNOUNCE: once_cell::sync::Lazy<Regex> =
static RE_A_HASHTAG: once_cell::sync::Lazy<Regex> = static RE_A_HASHTAG: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#(\w+)").unwrap()); Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#(\w+)").unwrap());
pub static RE_NOBOT_TAG: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#nobot(?:\b|$)").unwrap());
pub static RE_HASHTAG_TRIGGERING_PLEROMA_BUG: once_cell::sync::Lazy<Regex> = pub static RE_HASHTAG_TRIGGERING_PLEROMA_BUG: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#\w+[^\s]*$").unwrap()); Lazy::new(|| Regex::new(r"(?:^|\b|\s|>|\n)#\w+[^\s]*$").unwrap());
pub fn parse_status_tags(content: &str) -> Vec<String> { pub fn parse_status_tags(content: &str) -> Vec<String> {
debug!("Raw content: {}", content); debug!("Raw content: {}", content);
let content = content.replace("<br/>", "<br/> "); let content = utils::strip_html(content);
let content = content.replace("</p>", "</p> ");
let content = voca_rs::strip::strip_tags(&content);
debug!("Stripped tags: {}", content); debug!("Stripped tags: {}", content);
let mut tags = vec![]; let mut tags = vec![];
@ -150,11 +160,7 @@ pub fn parse_status_tags(content: &str) -> Vec<String> {
pub fn parse_slash_commands(content: &str) -> Vec<StatusCommand> { pub fn parse_slash_commands(content: &str) -> Vec<StatusCommand> {
debug!("Raw content: {}", content); debug!("Raw content: {}", content);
let content = utils::strip_html(content);
let content = content.replace("<br/>", "<br/> ");
let content = content.replace("</p>", "</p> ");
let content = voca_rs::strip::strip_tags(&content);
debug!("Stripped tags: {}", content); debug!("Stripped tags: {}", content);
if !content.contains('/') && !content.contains('\\') { if !content.contains('/') && !content.contains('\\') {
@ -198,6 +204,14 @@ pub fn parse_slash_commands(content: &str) -> Vec<StatusCommand> {
commands.push(StatusCommand::Join); 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) { if RE_PING.is_match(&content) {
debug!("PING"); debug!("PING");
commands.push(StatusCommand::Ping); commands.push(StatusCommand::Ping);
@ -322,11 +336,11 @@ pub fn parse_slash_commands(content: &str) -> Vec<StatusCommand> {
#[cfg(test)] #[cfg(test)]
mod 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::{ use super::{
RE_ADD_MEMBER, RE_ANNOUNCE, RE_BAN_SERVER, RE_BAN_USER, RE_BOOST, RE_CLOSE_GROUP, RE_GRANT_ADMIN, RE_HELP, 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] #[test]
@ -554,6 +568,7 @@ mod test {
} }
} }
} }
#[test] #[test]
fn test_match_tag_at_end() { fn test_match_tag_at_end() {
assert!(!RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match("banana #tag sdfsd")); 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...")); 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] #[test]
fn test_leave() { fn test_leave() {
assert!(!RE_LEAVE.is_match("/list")); assert!(!RE_LEAVE.is_match("/list"));
@ -573,6 +599,24 @@ mod test {
assert!(RE_LEAVE.is_match("/leave z")); 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] #[test]
fn test_undo() { fn test_undo() {
assert!(!RE_UNDO.is_match("/list")); assert!(!RE_UNDO.is_match("/list"));

View file

@ -6,6 +6,8 @@ pub enum GroupError {
UserIsAdmin, UserIsAdmin,
#[error("User is banned")] #[error("User is banned")]
UserIsBanned, UserIsBanned,
#[error("User opted out from the group")]
UserOptedOut,
#[error("Server could not be banned because there are admin users on it")] #[error("Server could not be banned because there are admin users on it")]
AdminsOnServer, AdminsOnServer,
#[error("Group config is missing in the config store")] #[error("Group config is missing in the config store")]

View file

@ -2,15 +2,16 @@ use std::collections::HashSet;
use std::time::Duration; use std::time::Duration;
use elefren::{FediClient, SearchType, StatusBuilder}; use elefren::{FediClient, SearchType, StatusBuilder};
use elefren::entities::account::Account;
use elefren::entities::prelude::Status; use elefren::entities::prelude::Status;
use elefren::status_builder::Visibility; use elefren::status_builder::Visibility;
use crate::command::StatusCommand; use crate::command::{RE_NOBOT_TAG, StatusCommand};
use crate::error::GroupError; use crate::error::GroupError;
use crate::group_handler::GroupHandle; use crate::group_handler::GroupHandle;
use crate::store::data::GroupConfig; use crate::store::data::GroupConfig;
use crate::utils::{LogError, normalize_acct}; use crate::utils::{LogError, normalize_acct};
use elefren::entities::account::Account; use crate::utils;
pub struct ProcessMention<'a> { pub struct ProcessMention<'a> {
status: Status, status: Status,
@ -52,7 +53,7 @@ impl<'a> ProcessMention<'a> {
let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?; let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?;
if acct_normalized == acct { if acct_normalized == acct {
debug!("Search done, account found: {}", item.acct); debug!("Search done, account found: {}", item.acct);
return Ok(Some(item.id)) return Ok(Some(item.id));
} else { } else {
warn!("Found wrong account: {}", item.acct); warn!("Found wrong account: {}", item.acct);
} }
@ -175,6 +176,12 @@ impl<'a> ProcessMention<'a> {
self.cmd_unban_user(&u).await self.cmd_unban_user(&u).await
.log_error("Error handling unban-user cmd"); .log_error("Error handling unban-user cmd");
} }
StatusCommand::OptOut => {
self.cmd_optout().await;
}
StatusCommand::OptIn => {
self.cmd_optin().await;
}
StatusCommand::BanServer(s) => { StatusCommand::BanServer(s) => {
self.cmd_ban_server(&s).await; self.cmd_ban_server(&s).await;
} }
@ -232,11 +239,20 @@ impl<'a> ProcessMention<'a> {
} }
if self.do_boost_prev_post { if self.do_boost_prev_post {
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 self.client
.reblog(self.status.in_reply_to_id.as_ref().unwrap()) .reblog(prev_status_id)
.await .await
.log_error("Failed to boost"); .log_error("Failed to boost");
} }
Err(e) => {
warn!("Can't reblog: {}", e);
}
}
}
}
if !self.replies.is_empty() { if !self.replies.is_empty() {
let mut msg = self.replies.join("\n"); let mut msg = self.replies.join("\n");
@ -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> { 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 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 { 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\ `/ignore`, `/i` - make the group ignore the post\n\
`/tags` - show group hashtags\n\ `/tags` - show group hashtags\n\
`/join` - (re-)join the group\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 { if self.is_admin {
self.add_reply("`/members`, `/who` - show group members / admins"); self.add_reply("`/members`, `/who` - show group members / admins");
@ -702,6 +741,26 @@ impl<'a> ProcessMention<'a> {
} }
Ok(()) Ok(())
} }
/// Convert ID to account, checking if the user is boostable
async fn id_to_acct_check_boostable(&self, id: &str) -> Result<String, GroupError> {
// 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) { fn apply_trailing_hashtag_pleroma_bug_workaround(msg: &mut String) {

View file

@ -274,6 +274,8 @@ impl GroupHandle {
return Ok(()); return Ok(());
} }
// optout does not work for members and admins, so don't check it
if !self.config.is_member_or_admin(&status_user) { if !self.config.is_member_or_admin(&status_user) {
debug!("Status author @{} is not a member, discard", status_user); debug!("Status author @{} is not a member, discard", status_user);
return Ok(()); return Ok(());
@ -300,10 +302,12 @@ impl GroupHandle {
'tags: for t in tags { 'tags: for t in tags {
if self.config.is_tag_followed(&t) { if self.config.is_tag_followed(&t) {
info!("REBLOG #{} STATUS", &t); info!("REBLOG #{} STATUS", t);
self.client.reblog(&s.id).await self.client.reblog(&s.id).await
.log_error("Failed to reblog"); .log_error("Failed to reblog");
break 'tags; // do not reblog multiple times! break 'tags; // do not reblog multiple times!
} else {
debug!("#{} is not a group tag", t);
} }
} }

View file

@ -41,6 +41,8 @@ pub(crate) struct GroupConfig {
member_users: HashSet<String>, member_users: HashSet<String>,
/// List of users banned from posting to the group /// List of users banned from posting to the group
banned_users: HashSet<String>, banned_users: HashSet<String>,
/// Users who decided they don't want to be shared to the group (does not apply to members)
optout_users: HashSet<String>,
/// True if only members should be allowed to write /// True if only members should be allowed to write
member_only: bool, member_only: bool,
/// Banned domain names, e.g. kiwifarms.cc /// Banned domain names, e.g. kiwifarms.cc
@ -69,6 +71,7 @@ impl Default for GroupConfig {
admin_users: Default::default(), admin_users: Default::default(),
member_users: Default::default(), member_users: Default::default(),
banned_users: Default::default(), banned_users: Default::default(),
optout_users: Default::default(),
member_only: false, member_only: false,
banned_servers: Default::default(), banned_servers: Default::default(),
last_notif_ts: 0, last_notif_ts: 0,
@ -147,6 +150,10 @@ impl GroupConfig {
&self.acct &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 { pub(crate) fn is_admin(&self, acct: &str) -> bool {
self.admin_users.contains(acct) self.admin_users.contains(acct)
} }
@ -212,6 +219,17 @@ impl GroupConfig {
Ok(()) 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> { pub(crate) fn ban_user(&mut self, acct: &str, ban: bool) -> Result<(), GroupError> {
let mut change = false; let mut change = false;
if ban { if ban {

View file

@ -112,3 +112,9 @@ impl VisExt for Visibility {
} }
} }
} }
pub(crate) fn strip_html(content: &str) -> String {
let content = content.replace("<br/>", "<br/> ");
let content = content.replace("</p>", "</p> ");
voca_rs::strip::strip_tags(&content)
}