9 Commits
11 changed files with 494 additions and 150 deletions
+22
View File
@@ -1,5 +1,27 @@
# Changelog # Changelog
## v0.2.7
- Fix some wrong responses to admin commands
- Remove automatic announcements from some admin commands
- Send no reply to unauthorized admin commands
- Fix swapped `/opengroup` and `/closegroup` descriptions in help
- Add `/optout` and `/optin`
- Add `#nobot` checking when using the `/boost` command
## v0.2.6
- Allow boosting group hashtags when they are in a reply, except when it is private/DM
or contains actionable commands
- `/follow` and `/unfollow` are now aliases to `/add` and `/remove` (for users and tags)
- Add workaround for pleroma markdown processor eating trailing hashtags
- Command replies are now always DM again so we don't spam timelines
## v0.2.5
- Add `/undo` command
- Fix users joining via follow not marked as members
## v0.2.4
- make account lookup try harder
## v0.2.3 ## v0.2.3
- `/add user` will now try to follow even if already a member - `/add user` will now try to follow even if already a member
Generated
+2 -2
View File
@@ -276,7 +276,7 @@ checksum = "ea57b42383d091c85abcc2706240b94ab2a8fa1fc81c10ff23c4de06e2a90b5e"
[[package]] [[package]]
name = "elefren" name = "elefren"
version = "0.22.0" version = "0.22.0"
source = "git+https://git.ondrovo.com/MightyPork/elefren-fork.git?rev=a0ebb46#a0ebb46542ede2d235ca6094135a6d6d01d0ecb8" source = "git+https://git.ondrovo.com/MightyPork/elefren-fork.git?rev=7847df0#7847df0e2b2c0f6b9a089e2db2f9b10dfe070a45"
dependencies = [ dependencies = [
"chrono", "chrono",
"doc-comment", "doc-comment",
@@ -328,7 +328,7 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed"
[[package]] [[package]]
name = "fedigroups" name = "fedigroups"
version = "0.2.3" version = "0.2.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "fedigroups" name = "fedigroups"
version = "0.2.3" version = "0.2.7"
authors = ["Ondřej Hruška <ondra@ondrovo.com>"] authors = ["Ondřej Hruška <ondra@ondrovo.com>"]
edition = "2018" edition = "2018"
publish = false publish = false
@@ -10,7 +10,7 @@ build = "build.rs"
[dependencies] [dependencies]
#elefren = { path = "../elefren22-fork" } #elefren = { path = "../elefren22-fork" }
elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git", rev = "a0ebb46" } elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git", rev = "7847df0" }
env_logger = "0.9.0" env_logger = "0.9.0"
+51 -13
View File
@@ -1,4 +1,5 @@
# Fedi Groups # Fedi Groups
## How it works ## How it works
This is an approximation of groups you can use right now with existing fedi software that implements the Mastodon client API. This is an approximation of groups you can use right now with existing fedi software that implements the Mastodon client API.
@@ -8,6 +9,8 @@ Groups implement moderation (banning users and instances, member-only mode with
Group admins can issue group announcements that are posted poublicly by the group user, such as when there is a planned maintenance. The group will attempt to catch up with posts missed during the outage. Group admins can issue group announcements that are posted poublicly by the group user, such as when there is a planned maintenance. The group will attempt to catch up with posts missed during the outage.
*Note: In this document, "reblog" and "boost" are used interchangeably.*
### Advantages of emulated groups ### Advantages of emulated groups
Unlike some other attempts at group implementation (namely gup.pe or the mythical WIP Pleroma Groups), this works with current Pleroma and Mastodon. There's no need for interoperability in different server implementations, since it uses existing follow/mention/reblog features that are already cross-compatible. Mastodon users can join a group running on Pleroma and vice-versa. Unlike some other attempts at group implementation (namely gup.pe or the mythical WIP Pleroma Groups), this works with current Pleroma and Mastodon. There's no need for interoperability in different server implementations, since it uses existing follow/mention/reblog features that are already cross-compatible. Mastodon users can join a group running on Pleroma and vice-versa.
@@ -93,6 +96,29 @@ An example systemd service file is included in the repository as well. Make sure
## Group usage ## Group usage
### Sharing into the group
The group will boost any status meeting these criteria:
- The visiblity is public or unlisted
- It's not a command request (i.e. mentions the group user and contains valid command(s))
- Either:
- it mentions the group user and is not a reply
- or, it contains one of the group hashtags
Examples of posts that will be shared:
- `@group Look at this duck` (public or unlisted)
- `Look at this duck @group` (public or unlisted)
- `I love #ducks` (public or unlisted, if #ducks is a group hashtag)
- `@otheruser tell me about #ducks` (in a thread, public or unlisted, if #ducks is a group hashtag)
- `Ducks are cool @otheruser @group` (original post)
These won't be shared:
- `ducks suck`
- `@group #ducks /i` (anything with the "ignore" command is ignored)
- `@group /remove #ducks` (admin command, even if it includes a group hashtag)
- `@otheruser tell me about ducks` (in a thread)
- `@otheruser @group tell me about ducks` (in a thread)
### Commands ### Commands
Commands are simple text lines you use when mentioning the group user. DMs work well for this. Commands are simple text lines you use when mentioning the group user. DMs work well for this.
@@ -122,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:*
@@ -132,23 +164,29 @@ For group hashtags to work, the group user must follow all its members; otherwis
**Basic commands** **Basic commands**
- `/help` - show help - `/help` - show help
- `/ignore`, `/i` - make the group completely ignore the post - `/ignore` (alias `/i`) - make the group completely ignore the post
- `/members`, `/who` - show group members / admins - `/members` (alias `/who`) - show group members / admins
- `/tags` - show group hashtags - `/tags` - show group hashtags
- `/boost`, `/b` - boost the replied-to post into the group - `/boost` (alias `/b`) - boost the replied-to post into the group
- `/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.
**For admins** **For admins**
- `/announce x` - make a public announcement from the rest of the status. Note: this does not preserve any formatting! - `/announce x` - make a public announcement from the rest of the status. Note: this does not preserve any formatting!
- `/ban x` - ban a user or a server from the group - `/ban user@domain` - ban a user from interacting with the group or having their statuses shared
- `/unban x` - lift a ban - `/unban user@domain` - lift a user ban
- `/op user`, `/admin user` - grant admin rights to the group - `/ban domain.tld` - ban a server (works similar to instance mute)
- `/deop user`, `/deadmin user` - revoke admin rights - `/unban domain.tld` - lift a server ban
- `/opengroup` - make member-only - `/op user@domain` (alias `/admin`) - grant admin rights to a user
- `/closegroup` - make public-access - `/deop user@domain` (alias `/deadmin`) - revoke admin rights
- `/add user` - add a member (use e-mail style address) - `/closegroup` - make the group member-only
- `/kick user, /remove user` - kick a member - `/opengroup` - make the group public-access
- `/add #hashtag` - add a hasgtag to the group - `/add user@domain` (alias `/follow`) - add a member
- `/remove #hashtag` - remove a hasgtag from the group - `/remove user@domain` (alias `/remove`) - remove a member
- `/add #hashtag` (alias `/follow`) - add a hashtag to the group
- `/remove #hashtag` (alias `/unfollow`) - remove a hashtag from the group
- `/undo` (alias `/delete`) - when used by an admin, this command can un-boost any status. It can also delete an announcement made in error.
+101 -25
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 {
@@ -7,6 +8,8 @@ pub enum StatusCommand {
Ignore, Ignore,
/// Boost the previous post in the thread /// Boost the previous post in the thread
Boost, Boost,
/// Un-reblog parent post, or delete an announcement
Undo,
/// Admin: Ban a user /// Admin: Ban a user
BanUser(String), BanUser(String),
/// Admin: Un-ban a server /// Admin: Un-ban a server
@@ -29,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
@@ -80,6 +87,8 @@ macro_rules! command {
static RE_BOOST: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"b(?:oost)?")); static RE_BOOST: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"b(?:oost)?"));
static RE_UNDO: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:delete|undo)"));
static RE_IGNORE: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"i(?:g(?:n(?:ore)?)?)?")); static RE_IGNORE: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"i(?:g(?:n(?:ore)?)?)?"));
static RE_BAN_USER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"ban\s+", p_user!())); static RE_BAN_USER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"ban\s+", p_user!()));
@@ -90,13 +99,13 @@ static RE_BAN_SERVER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"ban
static RE_UNBAN_SERVER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"unban\s+", p_server!())); static RE_UNBAN_SERVER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"unban\s+", p_server!()));
static RE_ADD_MEMBER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:add)\s+", p_user!())); static RE_ADD_MEMBER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:add|follow)\s+", p_user!()));
static RE_REMOVE_MEMBER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:kick|remove)\s+", p_user!())); static RE_REMOVE_MEMBER: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:kick|unfollow|remove)\s+", p_user!()));
static RE_ADD_TAG: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:add)\s+", p_hashtag!())); static RE_ADD_TAG: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:add|follow)\s+", p_hashtag!()));
static RE_REMOVE_TAG: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:remove)\s+", p_hashtag!())); static RE_REMOVE_TAG: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:remove|unfollow)\s+", p_hashtag!()));
static RE_GRANT_ADMIN: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:op|admin)\s+", p_user!())); static RE_GRANT_ADMIN: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:op|admin)\s+", p_user!()));
@@ -108,27 +117,35 @@ static RE_CLOSE_GROUP: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"cl
static RE_HELP: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"help")); static RE_HELP: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"help"));
static RE_MEMBERS: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:members|who)")); static RE_MEMBERS: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:members|who|admins)"));
static RE_TAGS: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:hashtags|tags)")); static RE_TAGS: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:hashtags|tags)"));
static RE_LEAVE: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:leave)")); 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_PING: once_cell::sync::Lazy<Regex> = Lazy::new(|| command!(r"(?:ping)")); 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_ANNOUNCE: once_cell::sync::Lazy<Regex> = static RE_ANNOUNCE: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(concat!(r"(?:^|\s|>|\n)[\\/]announce\s+(.*)$")).unwrap()); Lazy::new(|| Regex::new(r"(?:^|\s|>|\n)[\\/]announce\s+(.*)$").unwrap());
static RE_A_HASHTAG: once_cell::sync::Lazy<Regex> = static RE_A_HASHTAG: once_cell::sync::Lazy<Regex> =
Lazy::new(|| Regex::new(concat!(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> =
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![];
@@ -143,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('\\') {
@@ -167,6 +180,11 @@ pub fn parse_slash_commands(content: &str) -> Vec<StatusCommand> {
return vec![StatusCommand::Help]; return vec![StatusCommand::Help];
} }
if RE_UNDO.is_match(&content) {
debug!("UNDO");
return vec![StatusCommand::Undo];
}
// additive commands // additive commands
let mut commands = vec![]; let mut commands = vec![];
@@ -186,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);
@@ -310,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, StatusCommand, RE_JOIN, RE_ADD_TAG, RE_A_HASHTAG}; 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_TAGS, RE_OPEN_GROUP, RE_REMOVE_MEMBER, RE_REVOKE_ADMIN, 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]
@@ -405,6 +431,7 @@ mod test {
fn test_add_member() { fn test_add_member() {
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@pleroma.soykaf.com")); assert!(RE_ADD_MEMBER.is_match("/add @lain@pleroma.soykaf.com"));
assert!(RE_ADD_MEMBER.is_match("/follow @lain@pleroma.soykaf.com"));
assert!(RE_ADD_MEMBER.is_match("\\add @lain")); assert!(RE_ADD_MEMBER.is_match("\\add @lain"));
let c = RE_ADD_MEMBER.captures("/add @lain"); let c = RE_ADD_MEMBER.captures("/add @lain");
@@ -434,6 +461,7 @@ mod test {
assert!(RE_ADD_TAG.is_match("\\add #ласточка")); assert!(RE_ADD_TAG.is_match("\\add #ласточка"));
assert!(RE_ADD_TAG.is_match("/add #nya.")); assert!(RE_ADD_TAG.is_match("/add #nya."));
assert!(RE_ADD_TAG.is_match("/add #nya)")); assert!(RE_ADD_TAG.is_match("/add #nya)"));
assert!(RE_ADD_TAG.is_match("/follow #nya)"));
assert!(RE_ADD_TAG.is_match("/add #nya and more)")); assert!(RE_ADD_TAG.is_match("/add #nya and more)"));
let c = RE_ADD_TAG.captures("/add #breadposting"); let c = RE_ADD_TAG.captures("/add #breadposting");
@@ -512,6 +540,7 @@ mod test {
assert!(!RE_MEMBERS.is_match("/admin lain@pleroma.soykaf.com")); assert!(!RE_MEMBERS.is_match("/admin lain@pleroma.soykaf.com"));
assert!(RE_MEMBERS.is_match("/members")); assert!(RE_MEMBERS.is_match("/members"));
assert!(RE_MEMBERS.is_match("/who")); assert!(RE_MEMBERS.is_match("/who"));
assert!(RE_MEMBERS.is_match("/admins"));
} }
#[test] #[test]
@@ -532,16 +561,35 @@ mod test {
for (i, c) in RE_A_HASHTAG.captures_iter("foo #banana #χαλβάς #ласточка").enumerate() { for (i, c) in RE_A_HASHTAG.captures_iter("foo #banana #χαλβάς #ласточка").enumerate() {
if i == 0 { if i == 0 {
assert_eq!(c.get(1).unwrap().as_str(), "banana"); assert_eq!(c.get(1).unwrap().as_str(), "banana");
} } else if i == 1 {
else if i == 1 {
assert_eq!(c.get(1).unwrap().as_str(), "χαλβάς"); assert_eq!(c.get(1).unwrap().as_str(), "χαλβάς");
} } else if i == 2 {
else if i == 2 {
assert_eq!(c.get(1).unwrap().as_str(), "ласточка"); assert_eq!(c.get(1).unwrap().as_str(), "ласточка");
} }
} }
} }
#[test]
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 ."));
assert!(RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match("banana #tag"));
assert!(RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match("banana #tag."));
assert!(RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match("banana #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"));
@@ -551,6 +599,34 @@ 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]
fn test_undo() {
assert!(!RE_UNDO.is_match("/list"));
assert!(RE_UNDO.is_match("/undo"));
assert!(RE_UNDO.is_match("/delete"));
assert!(RE_UNDO.is_match("/undo"));
assert!(RE_UNDO.is_match("x /undo"));
assert!(RE_UNDO.is_match("/undo z"));
}
#[test] #[test]
fn test_join() { fn test_join() {
assert!(!RE_JOIN.is_match("/list")); assert!(!RE_JOIN.is_match("/list"));
@@ -595,7 +671,7 @@ mod test {
vec![ vec![
StatusCommand::BanUser("lain".to_string()), StatusCommand::BanUser("lain".to_string()),
StatusCommand::BanUser("piggo@piggo.space".to_string()), StatusCommand::BanUser("piggo@piggo.space".to_string()),
StatusCommand::BanServer("soykaf.com".to_string()) StatusCommand::BanServer("soykaf.com".to_string()),
], ],
parse_slash_commands("let's /ban @lain! /ban @piggo@piggo.space and also /ban soykaf.com") parse_slash_commands("let's /ban @lain! /ban @piggo@piggo.space and also /ban soykaf.com")
); );
+2
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")]
+214 -81
View File
@@ -2,17 +2,20 @@ 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 crate::utils;
pub struct ProcessMention<'a> { pub struct ProcessMention<'a> {
status: Status, status: Status,
group_account: &'a Account,
config: &'a mut GroupConfig, config: &'a mut GroupConfig,
client: &'a mut FediClient, client: &'a mut FediClient,
group_acct: String, group_acct: String,
@@ -46,21 +49,18 @@ impl<'a> ProcessMention<'a> {
Err(e.into()) Err(e.into())
} }
Ok(Ok(res)) => { Ok(Ok(res)) => {
debug!("Result: {:#?}", res); for item in res.accounts {
if let Some(item) = res.accounts.into_iter().next() {
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);
Ok(Some(item.id)) return Ok(Some(item.id));
} else { } else {
warn!("Search done but found wrong account: {}", item.acct); warn!("Found wrong account: {}", item.acct);
Ok(None)
} }
} else {
debug!("Search done, nothing found");
Ok(None)
} }
debug!("Search done, nothing found");
Ok(None)
} }
} }
} }
@@ -69,7 +69,7 @@ impl<'a> ProcessMention<'a> {
let mut admins = self.config.get_admins().collect::<Vec<_>>(); let mut admins = self.config.get_admins().collect::<Vec<_>>();
admins.sort(); admins.sort();
for a in admins { for a in admins {
self.replies.push(a.to_string()); self.replies.push(format!("- {}", a));
} }
} }
@@ -81,9 +81,9 @@ impl<'a> ProcessMention<'a> {
members.dedup(); members.dedup();
for m in members { for m in members {
self.replies.push(if admins.contains(&m) { self.replies.push(if admins.contains(&m) {
format!("{} [admin]", m) format!("- {} [admin]", m)
} else { } else {
m.to_string() format!("- {}", m)
}); });
} }
} }
@@ -110,6 +110,7 @@ impl<'a> ProcessMention<'a> {
} }
let pm = Self { let pm = Self {
group_account: &gh.group_account,
status_user_id: status.account.id.to_string(), status_user_id: status.account.id.to_string(),
client: &mut gh.client, client: &mut gh.client,
can_write: gh.config.can_write(&status_acct), can_write: gh.config.can_write(&status_acct),
@@ -133,12 +134,12 @@ impl<'a> ProcessMention<'a> {
.log_error("Failed to reblog status") .log_error("Failed to reblog status")
} }
fn add_reply(&mut self, line: impl ToString) { fn add_reply(&mut self, line: impl AsRef<str>) {
self.replies.push(line.to_string()) self.replies.push(line.as_ref().trim_matches(' ').to_string())
} }
fn add_announcement(&mut self, line: impl ToString) { fn add_announcement<'t>(&mut self, line: impl AsRef<str>) {
self.announcements.push(line.to_string()) self.announcements.push(line.as_ref().trim_matches(' ').to_string())
} }
async fn handle(mut self) -> Result<(), GroupError> { async fn handle(mut self) -> Result<(), GroupError> {
@@ -154,6 +155,10 @@ impl<'a> ProcessMention<'a> {
for cmd in commands { for cmd in commands {
match cmd { match cmd {
StatusCommand::Undo => {
self.cmd_undo().await
.log_error("Error handling undo cmd");
}
StatusCommand::Ignore => { StatusCommand::Ignore => {
unreachable!(); // Handled above unreachable!(); // Handled above
} }
@@ -171,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;
} }
@@ -192,7 +203,7 @@ impl<'a> ProcessMention<'a> {
self.cmd_remove_tag(tag).await; self.cmd_remove_tag(tag).await;
} }
StatusCommand::GrantAdmin(u) => { StatusCommand::GrantAdmin(u) => {
self.cmd_grant_member(&u).await self.cmd_grant_admin(&u).await
.log_error("Error handling grant-admin cmd"); .log_error("Error handling grant-admin cmd");
} }
StatusCommand::RemoveAdmin(u) => { StatusCommand::RemoveAdmin(u) => {
@@ -228,25 +239,37 @@ impl<'a> ProcessMention<'a> {
} }
if self.do_boost_prev_post { if self.do_boost_prev_post {
self.client 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()) {
.reblog(self.status.in_reply_to_id.as_ref().unwrap()) match self.id_to_acct_check_boostable(prev_acct_id).await {
.await Ok(_acct) => {
.log_error("Failed to boost"); self.client
.reblog(prev_status_id)
.await
.log_error("Failed to boost");
}
Err(e) => {
warn!("Can't reblog: {}", e);
}
}
}
} }
if !self.replies.is_empty() { if !self.replies.is_empty() {
debug!("replies={:?}", self.replies); let mut msg = self.replies.join("\n");
let r = self.replies.join("\n"); debug!("r={}", msg);
debug!("r={}", r);
if self.want_markdown {
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
}
if let Ok(post) = StatusBuilder::new() if let Ok(post) = StatusBuilder::new()
.status(format!("@{user}\n{msg}", user = self.status_acct, msg = r)) .status(format!("@{user} {msg}", user = self.status_acct, msg = msg))
.content_type(if self.want_markdown { .content_type(if self.want_markdown {
"text/markdown" "text/markdown"
} else { } else {
"text/plain" "text/plain"
}) })
.visibility(self.status.visibility) // Copy visibility .visibility(Visibility::Direct)
.build() .build()
{ {
let _ = self.client.new_status(post) let _ = self.client.new_status(post)
@@ -255,7 +278,13 @@ impl<'a> ProcessMention<'a> {
} }
if !self.announcements.is_empty() { if !self.announcements.is_empty() {
let msg = self.announcements.join("\n"); let mut msg = self.announcements.join("\n");
debug!("a={}", msg);
if self.want_markdown {
apply_trailing_hashtag_pleroma_bug_workaround(&mut msg);
}
let post = StatusBuilder::new() let post = StatusBuilder::new()
.status(format!("**📢 Group announcement**\n{msg}", msg = msg)) .status(format!("**📢 Group announcement**\n{msg}", msg = msg))
.content_type("text/markdown") .content_type("text/markdown")
@@ -279,8 +308,9 @@ impl<'a> ProcessMention<'a> {
// tokio::time::sleep(DELAY_BEFORE_ACTION).await; // tokio::time::sleep(DELAY_BEFORE_ACTION).await;
self.reblog_status().await; self.reblog_status().await;
} else { } else {
self.add_reply("You are not allowed to post to this group"); warn!("User @{} can't post to group!", self.status_acct);
} }
// Otherwise, don't react
} else { } else {
debug!("Not OP, ignore mention"); debug!("Not OP, ignore mention");
} }
@@ -295,10 +325,59 @@ impl<'a> ProcessMention<'a> {
if self.can_write { if self.can_write {
self.do_boost_prev_post = self.status.in_reply_to_id.is_some(); self.do_boost_prev_post = self.status.in_reply_to_id.is_some();
} else { } else {
self.add_reply("You are not allowed to share to this group"); warn!("User @{} can't share to group!", self.status_acct);
} }
} }
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 {
// This is a post sent by the group user, likely an announcement.
// Undo here means delete it.
if self.is_admin {
info!("Deleting group post #{}", parent_status_id);
self.client.delete_status(parent_status_id).await?;
} else {
warn!("Only admin can delete posts made by the group user");
}
} else {
if self.is_admin || parent_account_id == &self.status_user_id {
info!("Un-reblogging post #{}", parent_status_id);
// User unboosting own post boosted by accident, or admin doing it
self.client.unreblog(parent_status_id).await?;
} else {
warn!("Only the author and admins can undo reblogs");
// XXX this means when someone /b's someone else's post to a group,
// they then can't reverse that (only admin or the post's author can)
}
}
}
Ok(())
}
async fn cmd_ban_user(&mut self, user: &str) -> Result<(), GroupError> { async fn cmd_ban_user(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?; let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin { if self.is_admin {
@@ -313,9 +392,11 @@ impl<'a> ProcessMention<'a> {
self.add_reply(format!("Failed to ban user {}: {}", u, e)); self.add_reply(format!("Failed to ban user {}: {}", u, e));
} }
} }
} else {
self.add_reply(format!("No action, user {} is already banned", u));
} }
} else { } else {
self.add_reply("Only admins can manage user bans"); warn!("Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -333,9 +414,11 @@ impl<'a> ProcessMention<'a> {
unreachable!() unreachable!()
} }
} }
} else {
self.add_reply(format!("No action, user {} is not banned", u));
} }
} else { } else {
self.add_reply("Only admins can manage user bans"); warn!("Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -345,16 +428,17 @@ impl<'a> ProcessMention<'a> {
if !self.config.is_server_banned(s) { if !self.config.is_server_banned(s) {
match self.config.ban_server(s, true) { match self.config.ban_server(s, true) {
Ok(_) => { Ok(_) => {
self.add_announcement(format!("Server \"{}\" has been banned.", s));
self.add_reply(format!("Server {} banned from group!", s)); self.add_reply(format!("Server {} banned from group!", s));
} }
Err(e) => { Err(e) => {
self.add_reply(format!("Failed to ban server {}: {}", s, e)); self.add_reply(format!("Failed to ban server {}: {}", s, e));
} }
} }
} else {
self.add_reply(format!("No action, server {} already banned", s));
} }
} else { } else {
self.add_reply("Only admins can manage server bans"); warn!("Ignore cmd, user not admin");
} }
} }
@@ -363,22 +447,24 @@ impl<'a> ProcessMention<'a> {
if self.config.is_server_banned(s) { if self.config.is_server_banned(s) {
match self.config.ban_server(s, false) { match self.config.ban_server(s, false) {
Ok(_) => { Ok(_) => {
self.add_announcement(format!("Server \"{}\" has been un-banned.", s));
self.add_reply(format!("Server {} un-banned!", s)); self.add_reply(format!("Server {} un-banned!", s));
} }
Err(_) => { Err(e) => {
unreachable!() self.add_reply(format!("Unexpected error occured: {}", e));
} }
} }
} else {
self.add_reply(format!("No action, server {} is not banned", s));
} }
} else { } else {
self.add_reply("Only admins can manage server bans"); warn!("Ignore cmd, user not admin");
} }
} }
async fn cmd_add_member(&mut self, user: &str) -> Result<(), GroupError> { async fn cmd_add_member(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?; let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin { if self.is_admin {
// Allow even if the user is already a member - that will trigger re-follow
match self.config.set_member(&u, true) { match self.config.set_member(&u, true) {
Ok(_) => { Ok(_) => {
self.add_reply(format!("User {} added to the group!", u)); self.add_reply(format!("User {} added to the group!", u));
@@ -390,7 +476,7 @@ impl<'a> ProcessMention<'a> {
} }
} }
} else { } else {
self.add_reply("Only admins can manage members"); warn!("Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -404,35 +490,43 @@ impl<'a> ProcessMention<'a> {
self.unfollow_by_acct(&u).await self.unfollow_by_acct(&u).await
.log_error("Failed to unfollow removed user"); .log_error("Failed to unfollow removed user");
} }
Err(_) => { Err(e) => {
unreachable!() self.add_reply(format!("Unexpected error occured: {}", e));
} }
} }
} else { } else {
self.add_reply("Only admins can manage members"); warn!("Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
async fn cmd_add_tag(&mut self, tag: String) { async fn cmd_add_tag(&mut self, tag: String) {
if self.is_admin { if self.is_admin {
self.config.add_tag(&tag); if !self.config.is_tag_followed(&tag) {
self.add_reply(format!("Tag #{} added to the group!", tag)); self.config.add_tag(&tag);
self.add_reply(format!("Tag \"{}\" added to the group!", tag));
} else {
self.add_reply(format!("No action, \"{}\" is already a group tag", tag));
}
} else { } else {
self.add_reply("Only admins can manage group tags"); warn!("Ignore cmd, user not admin");
} }
} }
async fn cmd_remove_tag(&mut self, tag: String) { async fn cmd_remove_tag(&mut self, tag: String) {
if self.is_admin { if self.is_admin {
self.config.remove_tag(&tag); if self.config.is_tag_followed(&tag) {
self.add_reply(format!("Tag #{} removed from the group!", tag)); self.config.remove_tag(&tag);
self.add_reply(format!("Tag \"{}\" removed from the group!", tag));
} else {
self.add_reply(format!("No action, \"{}\" is not a group tag", tag));
}
} else { } else {
self.add_reply("Only admins can manage group tags"); warn!("Ignore cmd, user not admin");
} }
} }
async fn cmd_grant_member(&mut self, user: &str) -> Result<(), GroupError> { async fn cmd_grant_admin(&mut self, user: &str) -> Result<(), GroupError> {
let u = normalize_acct(user, &self.group_acct)?; let u = normalize_acct(user, &self.group_acct)?;
if self.is_admin { if self.is_admin {
if !self.config.is_admin(&u) { if !self.config.is_admin(&u) {
@@ -442,7 +536,6 @@ impl<'a> ProcessMention<'a> {
let _ = self.config.set_member(&u, true); let _ = self.config.set_member(&u, true);
self.add_reply(format!("User {} is now a group admin!", u)); self.add_reply(format!("User {} is now a group admin!", u));
self.add_announcement(format!("User @{} can now manage this group!", u));
} }
Err(e) => { Err(e) => {
self.add_reply(format!( self.add_reply(format!(
@@ -451,9 +544,11 @@ impl<'a> ProcessMention<'a> {
)); ));
} }
} }
} else {
self.add_reply(format!("No action, \"{}\" is admin already", u));
} }
} else { } else {
self.add_reply("Only admins can manage admins"); warn!("Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -465,15 +560,16 @@ impl<'a> ProcessMention<'a> {
match self.config.set_admin(&u, false) { match self.config.set_admin(&u, false) {
Ok(_) => { Ok(_) => {
self.add_reply(format!("User {} is no longer a group admin!", u)); self.add_reply(format!("User {} is no longer a group admin!", u));
self.add_announcement(format!("User @{} no longer manages this group.", u));
} }
Err(e) => { Err(e) => {
self.add_reply(format!("Failed to revoke {}'s group admin: {}", u, e)); self.add_reply(format!("Failed to revoke {}'s group admin: {}", u, e));
} }
} }
} else {
self.add_reply(format!("No action, user {} is not admin", u));
} }
} else { } else {
self.add_reply("Only admins can manage admins"); warn!("Ignore cmd, user not admin");
} }
Ok(()) Ok(())
} }
@@ -483,10 +579,11 @@ impl<'a> ProcessMention<'a> {
if self.config.is_member_only() { if self.config.is_member_only() {
self.config.set_member_only(false); self.config.set_member_only(false);
self.add_reply("Group changed to open-access"); self.add_reply("Group changed to open-access");
self.add_announcement("This group is now open-access!"); } else {
self.add_reply("No action, group is open-access already");
} }
} else { } else {
self.add_reply("Only admins can set group options"); warn!("Ignore cmd, user not admin");
} }
} }
@@ -495,68 +592,75 @@ impl<'a> ProcessMention<'a> {
if !self.config.is_member_only() { if !self.config.is_member_only() {
self.config.set_member_only(true); self.config.set_member_only(true);
self.add_reply("Group changed to member-only"); self.add_reply("Group changed to member-only");
self.add_announcement("This group is now member-only!"); } else {
self.add_reply("No action, group is member-only already");
} }
} else { } else {
self.add_reply("Only admins can set group options"); warn!("Ignore cmd, user not admin");
} }
} }
async fn cmd_help(&mut self) { async fn cmd_help(&mut self) {
self.want_markdown = true; self.want_markdown = true;
if self.config.is_member_only() { let membership_line = if self.is_admin {
self.add_reply("This is a member-only group. "); "*You are an admin.*"
} else {
self.add_reply("This is a public-access group. ");
}
if self.is_admin {
self.add_reply("*You are an admin.*");
} else if self.config.is_member(&self.status_acct) { } else if self.config.is_member(&self.status_acct) {
self.add_reply("*You are a member.*"); "*You are a member.*"
} else if self.config.is_member_only() { } else if self.config.is_member_only() {
self.add_reply("*You are not a member, ask one of the admins to add you.*"); "*You are not a member, ask one of the admins to add you.*"
} else { } else {
self.add_reply("*You are not a member, follow or use /join to join the group.*"); "*You are not a member, follow or use /join to join the group.*"
};
if self.config.is_member_only() {
self.add_reply(format!("This is a member-only group. {}", membership_line));
} else {
self.add_reply(format!("This is a public-access group. {}", membership_line));
} }
self.add_reply("\n\ self.add_reply("\
To share a post, mention the group user or use one of the group hashtags. \ To share a post, @ the group user or use a group hashtag.\n\
Replies and mentions with commands won't be shared.\n\
\n\ \n\
**Supported commands:**\n\ **Supported commands:**\n\
`/boost`, `/b` - boost the replied-to post into the group\n\ `/boost`, `/b` - boost the replied-to post into the group\n\
`/ignore`, `/i` - make the group ignore the post\n\ `/ignore`, `/i` - make the group ignore the post\n\
`/ping` - check the service is alive\n\
`/tags` - show group hashtags\n\ `/tags` - show group hashtags\n\
`/join` - 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.config.is_member_only() { if self.is_admin {
self.add_reply("`/members`, `/who` - show group members / admins"); self.add_reply("`/members`, `/who` - show group members / admins");
// undo is listed as an admin command
} else { } else {
self.add_reply("`/members`, `/who` - show group admins"); self.add_reply("`/admins` - show group admins");
self.add_reply("`/undo` - un-boost your post (use in a reply)");
} }
// XXX when used on instance with small character limit, this won't fit!
if self.is_admin { if self.is_admin {
self.add_reply("\n\ self.add_reply("\n\
**Admin commands:**\n\ **Admin commands:**\n\
`/add user` - add a member (use e-mail style address)\n\ `/ping` - check the group works\n\
`/add user` - add a member (user@domain)\n\
`/remove user` - remove a member\n\ `/remove user` - remove a member\n\
`/add #hashtag` - add a group hashtag\n\ `/add #hashtag` - add a group hashtag\n\
`/remove #hashtag` - remove a group hashtag\n\ `/remove #hashtag` - remove a group hashtag\n\
`/undo` - un-boost a replied-to post, delete an announcement\n\
`/ban x` - ban a user or server\n\ `/ban x` - ban a user or server\n\
`/unban x` - lift a ban\n\ `/unban x` - lift a ban\n\
`/admin user` - grant admin rights\n\ `/admin user` - grant admin rights\n\
`/deadmin user` - revoke admin rights\n\ `/deadmin user` - revoke admin rights\n\
`/opengroup` - make member-only\n\ `/closegroup` - make member-only\n\
`/closegroup` - make public-access\n\ `/opengroup` - make public-access\n\
`/announce x` - make a public announcement from the rest of the status (without formatting)"); `/announce x` - make a public announcement");
} }
} }
async fn cmd_list_members(&mut self) { async fn cmd_list_members(&mut self) {
self.want_markdown = true;
if self.is_admin { if self.is_admin {
self.add_reply("Group members:"); self.add_reply("Group members:");
self.append_member_list_to_reply(); self.append_member_list_to_reply();
@@ -568,10 +672,11 @@ impl<'a> ProcessMention<'a> {
async fn cmd_list_tags(&mut self) { async fn cmd_list_tags(&mut self) {
self.add_reply("Group tags:"); self.add_reply("Group tags:");
self.want_markdown = true;
let mut tags = self.config.get_tags().collect::<Vec<_>>(); let mut tags = self.config.get_tags().collect::<Vec<_>>();
tags.sort(); tags.sort();
for t in tags { for t in tags {
self.replies.push(format!("#{}", t).to_string()); self.replies.push(format!("- {}", t).to_string());
} }
} }
@@ -636,4 +741,32 @@ 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) {
if crate::command::RE_HASHTAG_TRIGGERING_PLEROMA_BUG.is_match(&msg) {
// if a status ends with a hashtag, pleroma will fuck it up
debug!("Adding \" .\" to fix pleroma hashtag eating bug!");
msg.push_str(" .");
}
} }
+32 -25
View File
@@ -18,12 +18,14 @@ use crate::store::ConfigStore;
use crate::store::data::GroupConfig; use crate::store::data::GroupConfig;
use crate::utils::{LogError, normalize_acct, VisExt}; use crate::utils::{LogError, normalize_acct, VisExt};
use crate::command::StatusCommand; use crate::command::StatusCommand;
use elefren::entities::account::Account;
mod handle_mention; mod handle_mention;
/// This is one group's config store capable of persistence /// This is one group's config store capable of persistence
#[derive(Debug)] #[derive(Debug)]
pub struct GroupHandle { pub struct GroupHandle {
pub(crate) group_account: Account,
pub(crate) client: FediClient, pub(crate) client: FediClient,
pub(crate) config: GroupConfig, pub(crate) config: GroupConfig,
pub(crate) store: Arc<ConfigStore>, pub(crate) store: Arc<ConfigStore>,
@@ -248,27 +250,17 @@ impl GroupHandle {
let ts = s.timestamp_millis(); let ts = s.timestamp_millis();
self.config.set_last_status(ts); self.config.set_last_status(ts);
// Short circuit checks
if s.visibility.is_private() { if s.visibility.is_private() {
debug!("Status is direct/private, discard"); debug!("Status is direct/private, discard");
return Ok(()); return Ok(());
} }
if s.in_reply_to_id.is_some() {
debug!("Status is a reply, discard");
return Ok(());
}
if !s.content.contains('#') { if !s.content.contains('#') {
debug!("No tags in status, discard"); debug!("No tags in status, discard");
return Ok(()); return Ok(());
} }
let commands = crate::command::parse_slash_commands(&s.content);
if commands.contains(&StatusCommand::Ignore) {
debug!("Post has IGNORE command, discard");
return Ok(());
}
let group_user = self.config.get_acct(); let group_user = self.config.get_acct();
let status_user = normalize_acct(&s.account.acct, group_user)?; let status_user = normalize_acct(&s.account.acct, group_user)?;
@@ -277,34 +269,45 @@ impl GroupHandle {
return Ok(()); return Ok(());
} }
if s.content.contains("/add ") if self.config.is_banned(&status_user) {
|| s.content.contains("/remove ") debug!("Status author @{} is banned, discard", status_user);
|| s.content.contains("\\add ")
|| s.content.contains("\\remove ")
{
debug!("Looks like a hashtag manipulation command, discard");
return Ok(()); return Ok(());
} }
if self.config.is_banned(&status_user) { // optout does not work for members and admins, so don't check it
debug!("Status author @{} is banned.", status_user);
return Ok(());
}
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.", status_user); debug!("Status author @{} is not a member, discard", status_user);
return Ok(()); return Ok(());
} }
let commands = crate::command::parse_slash_commands(&s.content);
if commands.contains(&StatusCommand::Ignore) {
debug!("Post has IGNORE command, discard");
return Ok(());
}
for m in s.mentions {
let mentioned_user = normalize_acct(&m.acct, group_user)?;
if mentioned_user == group_user {
if !commands.is_empty() {
debug!("Detected commands for this group, tags dont apply; discard");
return Ok(());
}
}
}
let tags = crate::command::parse_status_tags(&s.content); let tags = crate::command::parse_status_tags(&s.content);
debug!("Tags in status: {:?}", tags); debug!("Tags in status: {:?}", tags);
'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);
} }
} }
@@ -440,7 +443,7 @@ impl GroupHandle {
admins.sort(); admins.sort();
format!("\ format!("\
@{user} Welcome! This group has posting restricted to members. \ @{user} Welcome to the group! This group has posting restricted to members. \
If you'd like to join, please ask one of the group admins:\n\ If you'd like to join, please ask one of the group admins:\n\
{admins}", {admins}",
user = notif_acct, user = notif_acct,
@@ -448,9 +451,13 @@ impl GroupHandle {
) )
} else { } else {
follow_back = true; follow_back = true;
self.config.set_member(notif_acct, true)
.log_error("Fail add a member");
format!("\ format!("\
@{user} Welcome to the group! The group user will now follow you back to complete the sign-up. \ @{user} Welcome to the group! The group user will now follow you back to complete the sign-up. \
To share a post, tag the group user or use one of the group hashtags.\n\n\ To share a post, @ the group user or use a group hashtag.\n\n\
Use /help for more info.", Use /help for more info.",
user = notif_acct user = notif_acct
) )
+18
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 {
+34 -2
View File
@@ -67,12 +67,27 @@ impl ConfigStore {
let client = elefren::helpers::cli::authenticate(registration).await?; let client = elefren::helpers::cli::authenticate(registration).await?;
let appdata = client.data.clone(); let appdata = client.data.clone();
let data = GroupConfig::new(opts.acct, appdata); let data = GroupConfig::new(opts.acct.clone(), appdata);
// save & persist // save & persist
self.set_group_config(data.clone()).await?; self.set_group_config(data.clone()).await?;
let group_account = match client.verify_credentials().await {
Ok(account) => {
info!(
"Group account verified: @{}, \"{}\"",
account.acct, account.display_name
);
account
}
Err(e) => {
error!("Group @{} auth error: {}", opts.acct, e);
return Err(e.into());
}
};
Ok(GroupHandle { Ok(GroupHandle {
group_account,
client, client,
config: data, config: data,
store: self.clone(), store: self.clone(),
@@ -101,7 +116,22 @@ impl ConfigStore {
config.set_appdata(appdata); config.set_appdata(appdata);
self.set_group_config(config.clone()).await?; self.set_group_config(config.clone()).await?;
let group_account = match client.verify_credentials().await {
Ok(account) => {
info!(
"Group account verified: @{}, \"{}\"",
account.acct, account.display_name
);
account
}
Err(e) => {
error!("Group @{} auth error: {}", acct, e);
return Err(e.into());
}
};
Ok(GroupHandle { Ok(GroupHandle {
group_account,
client, client,
config, config,
store: self.clone(), store: self.clone(),
@@ -125,12 +155,13 @@ impl ConfigStore {
let client = FediClient::from(gc.get_appdata().clone()); let client = FediClient::from(gc.get_appdata().clone());
match client.verify_credentials().await { let my_account = match client.verify_credentials().await {
Ok(account) => { Ok(account) => {
info!( info!(
"Group account verified: @{}, \"{}\"", "Group account verified: @{}, \"{}\"",
account.acct, account.display_name account.acct, account.display_name
); );
account
} }
Err(e) => { Err(e) => {
error!("Group @{} auth error: {}", gc.get_acct(), e); error!("Group @{} auth error: {}", gc.get_acct(), e);
@@ -139,6 +170,7 @@ impl ConfigStore {
}; };
Some(GroupHandle { Some(GroupHandle {
group_account: my_account,
client, client,
config: gc, config: gc,
store: self.clone(), store: self.clone(),
+16
View File
@@ -95,10 +95,26 @@ mod test {
pub trait VisExt: Copy { pub trait VisExt: Copy {
/// Check if is private or direct /// Check if is private or direct
fn is_private(self) -> bool; fn is_private(self) -> bool;
fn make_unlisted(self) -> Self;
} }
impl VisExt for Visibility { impl VisExt for Visibility {
fn is_private(self) -> bool { fn is_private(self) -> bool {
self == Visibility::Direct || self == Visibility::Private self == Visibility::Direct || self == Visibility::Private
} }
fn make_unlisted(self) -> Self {
match self {
Visibility::Public => {
Visibility::Unlisted
}
other => other,
}
}
}
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)
} }