From 3b7700a4b1da676a20867f621048bac4a0a389e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Tue, 24 Aug 2021 22:13:44 +0200 Subject: [PATCH] untested hashtag boost --- Cargo.lock | 1 - Cargo.toml | 4 +- src/command.rs | 178 +++++++++++++++++++--- src/group_handle.rs | 354 ++++++++++++++++++++++++++++++++++++-------- src/store/data.rs | 43 +++++- src/store/mod.rs | 2 + 6 files changed, 494 insertions(+), 88 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9a1f5ee..bb2bd64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,7 +276,6 @@ checksum = "ea57b42383d091c85abcc2706240b94ab2a8fa1fc81c10ff23c4de06e2a90b5e" [[package]] name = "elefren" version = "0.22.0" -source = "git+https://git.ondrovo.com/MightyPork/elefren-fork.git#de38639fd178ae8ae47adb880ed965437a3d608e" dependencies = [ "chrono", "doc-comment", diff --git a/Cargo.toml b/Cargo.toml index c924c39..8fec97f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,8 +10,8 @@ build = "build.rs" [dependencies] #elefren = { version = "0.22.0", features = ["toml"] } -#elefren = { path = "../elefren22-fork" } -elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git" } +elefren = { path = "../elefren22-fork" } +#elefren = { git = "https://git.ondrovo.com/MightyPork/elefren-fork.git" } env_logger = "0.9.0" diff --git a/src/command.rs b/src/command.rs index cba20f0..394f73e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -3,22 +3,54 @@ use regex::Regex; #[derive(Debug, Clone, PartialEq)] pub enum StatusCommand { - Boost, + /// Ignore this post Ignore, + /// Boost the previous post in the thread + Boost, + /// Admin: Ban a user BanUser(String), + /// Admin: Un-ban a server UnbanUser(String), + /// Admin: Ban a server BanServer(String), + /// Admin: Un-ban a server UnbanServer(String), + /// Admin: Add a member to a closed group (or force join) AddMember(String), + /// Admin: Remove a user from the group, also unfollow RemoveMember(String), + /// Admin: Add a hashtag to the group + AddTag(String), + /// Admin: Remove a hashtag from the group + RemoveTag(String), + /// Admin: Give admin to a user GrantAdmin(String), + /// Admin: Revoke admin to a user RemoveAdmin(String), + /// Admin: Send a public announcement Announce(String), + /// Admin: Make the group open-access OpenGroup, + /// Admin: Make the group member-only, this effectively disables posting from non-members + /// and disables /join and follow-back CloseGroup, + /// Show help. The content varies by group params (open/closed access), the user's privileges + /// and membership status. Help, + /// Show members. Non-admins will only see a list of admins. ListMembers, + /// Show tags. + ListTags, + /// Leave the group, this asks the group to unfollow the user and also revokes their membership. Leave, + /// Join a public group. This is normally not needed, as the group follows back and adds followers as members. + /// Manual join is useful when the follow somehow fails, or when the user wants to be able to + /// post without receiving the group's posts (naughty!) + /// + /// In member-only groups, this will just DM the user some info on how to get added. + Join, + /// The group will DM "Pong" back, this is to test that the daemon is running and also that the + /// user is not banned and federates. Ping, } @@ -27,12 +59,19 @@ macro_rules! p_user { r"(@?[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+\.[a-z0-9_-]+|@[a-zA-Z0-9_.-]+)" }; } + macro_rules! p_server { () => { r"([a-zA-Z0-9_.-]+\.[a-zA-Z0-9_-]+)" }; } +macro_rules! p_hashtag { + () => { + r"#(\w+)" + }; +} + macro_rules! command { ($($val:expr),+) => { Regex::new(concat!(r"(?:^|\s|>|\n)[\\/]", $($val,)+ r"(?:$|[!,]|\W)")).unwrap() @@ -55,6 +94,10 @@ static RE_ADD_MEMBER: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?: static RE_REMOVE_MEMBER: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:kick|remove)\s+", p_user!())); +static RE_ADD_TAG: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:add)\s+", p_hashtag!())); + +static RE_REMOVE_TAG: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:remove)\s+", p_hashtag!())); + static RE_GRANT_ADMIN: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:op|admin)\s+", p_user!())); static RE_REVOKE_ADMIN: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:deop|deadmin)\s+", p_user!())); @@ -67,14 +110,37 @@ static RE_HELP: once_cell::sync::Lazy = Lazy::new(|| command!(r"help")); static RE_MEMBERS: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:members|who)")); +static RE_TAGS: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:hashtags|tags)")); + 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_PING: once_cell::sync::Lazy = Lazy::new(|| command!(r"(?:ping)")); static RE_ANNOUNCE: once_cell::sync::Lazy = Lazy::new(|| Regex::new(concat!(r"(?:^|\s|>|\n)[\\/]announce\s+(.*)$")).unwrap()); -pub fn parse_status(content: &str) -> Vec { +static RE_A_HASHTAG: once_cell::sync::Lazy = + Lazy::new(|| Regex::new(concat!(r"\b#(\w+)")).unwrap()); + +pub fn parse_status_tags(content: &str) -> Vec { + debug!("Raw content: {}", content); + let content = content.replace("
", " "); + let content = voca_rs::strip::strip_tags(&content); + debug!("Stripped tags: {}", content); + + let mut tags = vec![]; + for c in RE_A_HASHTAG.captures_iter(&content) { + if let Some(s) = c.get(1) { + tags.push(s.as_str().to_string()) + } + } + + tags +} + +pub fn parse_slash_commands(content: &str) -> Vec { debug!("Raw content: {}", content); let content = content.replace("
", " "); @@ -111,6 +177,9 @@ pub fn parse_status(content: &str) -> Vec { if RE_LEAVE.is_match(&content) { debug!("LEAVE"); commands.push(StatusCommand::Leave); + } else if RE_JOIN.is_match(&content) { + debug!("JOIN"); + commands.push(StatusCommand::Join); } if RE_PING.is_match(&content) { @@ -123,6 +192,11 @@ pub fn parse_status(content: &str) -> Vec { commands.push(StatusCommand::ListMembers); } + if RE_TAGS.is_match(&content) { + debug!("TAGS"); + commands.push(StatusCommand::ListTags); + } + if RE_OPEN_GROUP.is_match(&content) { debug!("OPEN GROUP"); commands.push(StatusCommand::OpenGroup); @@ -188,11 +262,27 @@ pub fn parse_status(content: &str) -> Vec { if let Some(s) = c.get(1) { let s = s.as_str(); let s = s.trim_start_matches('@'); - debug!("UNBAN USER: {}", s); + debug!("REMOVE USER: {}", s); commands.push(StatusCommand::RemoveMember(s.to_owned())); } } + for c in RE_ADD_TAG.captures_iter(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + debug!("ADD TAG: {}", s); + commands.push(StatusCommand::AddTag(s.to_owned())); + } + } + + for c in RE_REMOVE_TAG.captures_iter(&content) { + if let Some(s) = c.get(1) { + let s = s.as_str(); + debug!("REMOVE TAG: {}", s); + commands.push(StatusCommand::RemoveTag(s.to_owned())); + } + } + for c in RE_GRANT_ADMIN.captures_iter(&content) { if let Some(s) = c.get(1) { let s = s.as_str(); @@ -216,11 +306,11 @@ pub fn parse_status(content: &str) -> Vec { #[cfg(test)] mod test { - use crate::command::{parse_status, StatusCommand}; + use crate::command::{parse_slash_commands, StatusCommand, RE_JOIN, RE_ADD_TAG, RE_A_HASHTAG}; 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_IGNORE, RE_LEAVE, RE_MEMBERS, RE_TAGS, RE_OPEN_GROUP, RE_REMOVE_MEMBER, RE_REVOKE_ADMIN, }; #[test] @@ -276,23 +366,18 @@ mod test { assert!(RE_BAN_USER.is_match("/ban @LAIN@PleromA.soykaf.com")); let c = RE_BAN_USER.captures("/ban lain@pleroma.soykaf.com"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "lain@pleroma.soykaf.com"); let c = RE_BAN_USER.captures("/ban lain@pleroma.soykaf.com xx"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "lain@pleroma.soykaf.com"); let c = RE_BAN_USER.captures("/ban @lain@pleroma.soykaf.com"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain@pleroma.soykaf.com"); let c = RE_BAN_USER.captures("/ban @lain"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); let c = RE_BAN_USER.captures("/ban @lain xx"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); } @@ -306,11 +391,9 @@ mod test { assert!(!RE_BAN_SERVER.is_match("/ban @pleroma.soykaf.com")); let c = RE_BAN_SERVER.captures("/ban pleroma.soykaf.com"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "pleroma.soykaf.com"); let c = RE_BAN_SERVER.captures("/ban pleroma.soykaf.com xx"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "pleroma.soykaf.com"); } @@ -321,7 +404,6 @@ mod test { assert!(RE_ADD_MEMBER.is_match("\\add @lain")); let c = RE_ADD_MEMBER.captures("/add @lain"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); } @@ -337,10 +419,34 @@ mod test { assert!(RE_REMOVE_MEMBER.is_match("/remove @lain")); let c = RE_REMOVE_MEMBER.captures("/kick lain@pleroma.soykaf.com"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "lain@pleroma.soykaf.com"); } + #[test] + fn test_add_member() { + assert!(RE_ADD_TAG.is_match("/add #breadposting")); + assert!(RE_ADD_TAG.is_match("/add #čučkaři")); + 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)")); + + let c = RE_ADD_TAG.captures("/add #breadposting"); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "breadposting"); + + let c = RE_ADD_TAG.captures("/add #χαλβάς"); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "χαλβάς"); + + let c = RE_ADD_TAG.captures("/add #ласточка"); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "ласточка"); + + let c = RE_ADD_TAG.captures("#nya."); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "nya"); + + let c = RE_ADD_TAG.captures("#nya)"); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "nya"); + } + #[test] fn test_add_admin() { assert!(!RE_GRANT_ADMIN.is_match("/expel lain@pleroma.soykaf.com")); @@ -350,7 +456,6 @@ mod test { assert!(RE_GRANT_ADMIN.is_match("\\op @lain")); let c = RE_GRANT_ADMIN.captures("/op @lain@pleroma.soykaf.com"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain@pleroma.soykaf.com"); } @@ -362,7 +467,6 @@ mod test { assert!(RE_REVOKE_ADMIN.is_match("/deadmin @lain")); let c = RE_REVOKE_ADMIN.captures("/deadmin @lain"); - assert!(c.is_some()); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "@lain"); } @@ -405,6 +509,27 @@ mod test { assert!(RE_MEMBERS.is_match("/who")); } + #[test] + fn test_members() { + assert!(!RE_TAGS.is_match("/members")); + assert!(RE_TAGS.is_match("/hashtags")); + assert!(RE_TAGS.is_match("dsfsd /tags dfgd d")); + } + + #[test] + fn test_match_tag() { + assert!(!RE_A_HASHTAG.is_match("banana sdfsdf sdfsd fdsf sd")); + assert!(RE_A_HASHTAG.is_match("#banana")); + assert!(RE_A_HASHTAG.is_match("#ласточка")); + assert!(RE_A_HASHTAG.is_match("#χαλβάς")); + assert!(RE_A_HASHTAG.is_match("foo #banana gfdfgd")); + + let c = RE_GRANT_ADMIN.captures("foo #banana #χαλβάς #ласточка."); + assert_eq!(c.unwrap().get(1).unwrap().as_str(), "banana"); + assert_eq!(c.unwrap().get(2).unwrap().as_str(), "χαλβάς"); + assert_eq!(c.unwrap().get(3).unwrap().as_str(), "ласточка"); + } + #[test] fn test_leave() { assert!(!RE_LEAVE.is_match("/list")); @@ -414,6 +539,15 @@ mod test { assert!(RE_LEAVE.is_match("/leave z")); } + #[test] + fn test_leave() { + assert!(!RE_JOIN.is_match("/list")); + assert!(RE_JOIN.is_match("/join")); + assert!(RE_JOIN.is_match("/join")); + assert!(RE_JOIN.is_match("x /join")); + assert!(RE_JOIN.is_match("/join z")); + } + #[test] fn test_announce() { assert!(!RE_ANNOUNCE.is_match("/list")); @@ -435,15 +569,15 @@ mod test { fn test_real_post() { assert_eq!( Vec::::new(), - parse_status("Hello there is nothing here /fake command") + parse_slash_commands("Hello there is nothing here /fake command") ); assert_eq!( vec![StatusCommand::Help], - parse_status("lets see some \\help and /ban @lain") + parse_slash_commands("lets see some \\help and /ban @lain") ); assert_eq!( vec![StatusCommand::Ignore], - parse_status("lets see some /ignore and /ban @lain") + parse_slash_commands("lets see some /ignore and /ban @lain") ); assert_eq!( vec![ @@ -451,7 +585,7 @@ mod test { StatusCommand::BanUser("piggo@piggo.space".to_string()), StatusCommand::BanServer("soykaf.com".to_string()) ], - parse_status("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") ); } @@ -459,13 +593,13 @@ mod test { fn test_strip() { assert_eq!( vec![StatusCommand::BanUser("betty".to_string())], - parse_status( + parse_slash_commands( r#"Let's bad the naughty bot: /ban @betty"# ) ); assert_eq!( vec![StatusCommand::BanUser("betty@abstarbauze.com".to_string())], - parse_status( + parse_slash_commands( r#"Let's bad the naughty bot: /ban @betty@abstarbauze.com"# ) ); diff --git a/src/group_handle.rs b/src/group_handle.rs index 1f262f4..23d935c 100644 --- a/src/group_handle.rs +++ b/src/group_handle.rs @@ -2,19 +2,21 @@ use std::collections::HashSet; use std::sync::Arc; use std::time::{Duration, Instant}; +use elefren::{FediClient, StatusBuilder}; use elefren::debug::EventDisplay; use elefren::debug::NotificationDisplay; +use elefren::debug::StatusDisplay; use elefren::entities::event::Event; use elefren::entities::notification::{Notification, NotificationType}; +use elefren::entities::status::Status; use elefren::status_builder::Visibility; -use elefren::{FediClient, StatusBuilder}; use futures::StreamExt; use crate::command::StatusCommand; use crate::error::GroupError; -use crate::store::data::GroupConfig; use crate::store::ConfigStore; -use crate::utils::{normalize_acct, LogError}; +use crate::store::data::GroupConfig; +use crate::utils::{LogError, normalize_acct}; /// This is one group's config store capable of persistence #[derive(Debug)] @@ -27,6 +29,9 @@ pub struct GroupHandle { const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(250); const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(1000); const MAX_CATCHUP_NOTIFS: usize = 25; +// also statuses +const MAX_CATCHUP_STATUSES: usize = 100; +// higher because we can expect a lot of non-hashtag statuses here const PERIODIC_SAVE: Duration = Duration::from_secs(60); const PING_INTERVAL: Duration = Duration::from_secs(15); // must be < periodic save! @@ -45,6 +50,7 @@ impl GroupHandle { Ok(()) } + /* pub async fn reload(&mut self) -> Result<(), GroupError> { if let Some(g) = self.store.get_group_config(self.config.get_acct()).await { self.config = g; @@ -53,6 +59,7 @@ impl GroupHandle { Err(GroupError::GroupNotExist) } } + */ } trait NotifTimestamp { @@ -65,6 +72,14 @@ impl NotifTimestamp for Notification { } } +impl NotifTimestamp for Status { + fn timestamp_millis(&self) -> u64 { + // this may not work well for unseen status tracking, + // if ancient statuses were to appear in the timeline :( + self.created_at.timestamp_millis().max(0) as u64 + } +} + impl GroupHandle { pub async fn run(&mut self) -> Result<(), GroupError> { assert!(PERIODIC_SAVE >= PING_INTERVAL); @@ -89,6 +104,20 @@ impl GroupHandle { } } + match self.catch_up_with_missed_statuses().await { + Ok(true) => { + debug!("Some missed statuses handled"); + // Save asap! + next_save = Instant::now() - PERIODIC_SAVE + } + Ok(false) => { + debug!("No statuses missed"); + } + Err(e) => { + error!("Failed to handle missed statuses: {}", e); + } + } + loop { if next_save < Instant::now() { self.save_if_needed().await.log_error("Failed to save group"); @@ -104,7 +133,9 @@ impl GroupHandle { Ok(Some(event)) => { debug!("(@{}) Event: {}", self.config.get_acct(), EventDisplay(&event)); match event { - Event::Update(_status) => {} + Event::Update(status) => { + self.handle_status(status).await.log_error("Error handling a status"); + } Event::Notification(n) => { self.handle_notification(n).await.log_error("Error handling a notification"); } @@ -156,7 +187,7 @@ impl GroupHandle { return Ok(()); } - let commands = crate::command::parse_status(&status.content); + let commands = crate::command::parse_slash_commands(&status.content); let mut replies = vec![]; let mut announcements = vec![]; @@ -205,6 +236,9 @@ impl GroupHandle { any_admin_cmd = true; replies.push(format!("User {} banned from group!", u)); + self.unfollow_user(&u).await + .log_error("Failed to unfollow"); + // no announcement here } Err(e) => { @@ -280,11 +314,7 @@ impl GroupHandle { Ok(_) => { any_admin_cmd = true; replies.push(format!("User {} added to the group!", u)); - - if self.config.is_member_only() { - announcements - .push(format!("Welcome new member @{} to the group!", u)); - } + self.follow_user(&u).await.log_error("Failed to follow"); } Err(e) => { replies.push(format!("Failed to add user {} to group: {}", u, e)); @@ -303,6 +333,9 @@ impl GroupHandle { Ok(_) => { any_admin_cmd = true; replies.push(format!("User {} removed from the group.", u)); + + self.unfollow_user(&u).await + .log_error("Failed to unfollow"); } Err(_) => { unreachable!() @@ -313,12 +346,33 @@ impl GroupHandle { replies.push("Only admins can manage members".to_string()); } } + StatusCommand::AddTag(tag) => { + if is_admin { + any_admin_cmd = true; + self.config.add_tag(&tag); + replies.push(format!("Tag #{} added to the group!", tag)); + } else { + replies.push("Only admins can manage group tags".to_string()); + } + } + StatusCommand::RemoveTag(tag) => { + if is_admin { + any_admin_cmd = true; + self.config.remove_tag(&tag); + replies.push(format!("Tag #{} removed from the group!", tag)); + } else { + replies.push("Only admins can manage group tags".to_string()); + } + } StatusCommand::GrantAdmin(u) => { let u = normalize_acct(&u, &group_acct)?; if is_admin { if !self.config.is_admin(&u) { match self.config.set_admin(&u, true) { Ok(_) => { + // try to make the config a little more sane, admins should be members + let _ = self.config.set_member(&u, true); + any_admin_cmd = true; replies.push(format!("User {} is now a group admin!", u)); announcements @@ -409,12 +463,13 @@ impl GroupHandle { **Supported commands:**\n\ `/boost, /b` - boost the replied-to post into the group\n\ `/ignore, /i` - make the group completely ignore the post\n\ - `/ping` - check that the service is alive".to_string(), + `/ping` - check that the service is alive\n\ + `/join` - join the group\n\ + `/leave` - leave the group".to_string(), ); if self.config.is_member_only() { replies.push("`/members, /who` - show group members / admins".to_string()); - replies.push("`/leave` - leave the group".to_string()); } else { replies.push("`/members, /who` - show group admins".to_string()); } @@ -441,18 +496,7 @@ impl GroupHandle { if is_admin { if self.config.is_member_only() { replies.push("Group members:".to_string()); - let admins = self.config.get_admins().collect::>(); - let mut members = self.config.get_members().collect::>(); - members.extend(admins.iter()); - members.sort(); - members.dedup(); - for m in members { - if admins.contains(&m) { - replies.push(format!("{} [admin]", m)); - } else { - replies.push(m.to_string()); - } - } + self.list_members(&mut replies); } else { show_admins = true; } @@ -462,18 +506,55 @@ impl GroupHandle { if show_admins { replies.push("Group admins:".to_string()); - let mut admins = self.config.get_admins().collect::>(); - admins.sort(); - for a in admins { - replies.push(a.to_string()); - } + self.list_admins(&mut replies); + } + } + StatusCommand::ListTags => { + replies.push("Group tags:".to_string()); + let mut tags = self.config.get_tags().collect::>(); + tags.sort(); + for t in tags { + replies.push(format!("#{}", t)); } } StatusCommand::Leave => { - if self.config.is_member(¬if_acct) { + if self.config.is_member_or_admin(¬if_acct) { + // admin can leave but that's a bad idea + any_admin_cmd = true; let _ = self.config.set_member(¬if_acct, false); - replies.push("You left the group.".to_string()); + replies.push("You're no longer a group member. Unfollow the group user to stop receiving group messages.".to_string()); + + self.unfollow_user(¬if_acct).await + .log_error("Failed to unfollow"); + } + } + StatusCommand::Join => { + if self.config.is_member_or_admin(¬if_acct) { + // Already a member, so let's try to follow the user + // again, maybe first time it failed + self.follow_user(¬if_acct).await + .log_error("Failed to follow"); + } else { + // Not a member yet + if self.config.is_member_only() { + // No you can't + replies.push(format!( + "Hi, this group is closed to new sign-ups.\n\ + Please ask one of the group admins to add you:")); + self.list_admins(&mut replies); + } else { + // Open access + self.follow_user(¬if_acct).await + .log_error("Failed to follow"); + + // This only fails if the user is banned, but that is filtered above + let _ = self.config.set_member(¬if_acct, true); + replies.push(format!("\ + Thanks for joining, you are now a member and the group user will \ + follow you so you can use group hashtags. Make sure you follow the \ + group user to receive group messages.")); + } } } StatusCommand::Ping => { @@ -524,43 +605,106 @@ impl GroupHandle { } NotificationType::Follow => { info!("New follower!"); - tokio::time::sleep(Duration::from_millis(500)).await; - - let text = if self.config.is_member_only() { - // Admins are listed without @, so they won't become handles here. - // Tagging all admins would be annoying. - let mut admins = self.config.get_admins().cloned().collect::>(); - admins.sort(); - format!( - "@{user} welcome to the group! This is a member-only group, you won't be \ - able to post. Ask the group admins if you wish to join!\n\n\ - Admins: {admins}", - user = notif_acct, - admins = admins.join(", ") - ) + + if self.config.is_member_or_admin(¬if_acct) { + // Already joined, just doing something silly, ignore this + debug!("User already a member, ignoring"); } else { - format!( - "@{user} welcome to the group! \ - To share a post, tag the group user. Use /help for more info.", - user = notif_acct - ) - }; - - let post = StatusBuilder::new() - .status(text) - .content_type("text/markdown") - .visibility(Visibility::Direct) - .build() - .expect("error build status"); - - let _ = self.client.new_status(post).await.log_error("Failed to post"); + let text = if self.config.is_member_only() { + // Admins are listed without @, so they won't become handles here. + // Tagging all admins would be annoying. + let mut admins = self.config.get_admins().cloned().collect::>(); + admins.sort(); + format!( + "@{user} Hi, this is a member-only group, you won't be \ + able to post. You can still receive group posts though. If you'd like to join, \ + please ask one of the group admins to add you:\n\n\ + {admins}", + user = notif_acct, + admins = admins.join(", ") + ) + } else { + self.follow_user(¬if_acct).await + .log_error("Failed to follow"); + make_welcome_text(¬if_acct) + }; + + let post = StatusBuilder::new() + .status(text) + .content_type("text/markdown") + .visibility(Visibility::Direct) + .build() + .expect("error build status"); + + tokio::time::sleep(Duration::from_millis(500)).await; + let _ = self.client.new_status(post).await.log_error("Failed to post"); + } } - _ => {} + NotificationType::Favourite => {} + NotificationType::Reblog => {} } Ok(()) } + /// Handle a non-mention status + async fn handle_status(&mut self, s: Status) -> Result<(), GroupError> { + debug!("Handling status #{}", s.id); + let ts = s.timestamp_millis(); + self.config.set_last_status(ts); + + if !s.content.contains('#') { + debug!("No tags in status"); + return Ok(()); + } + + if s.visibility.is_private() { + debug!("Status is direct/private, not boosting"); + return Ok(()); + } + + if s.content.contains("/add ") || s.content.contains("/remove ") { + debug!("Discard, looks like a hashtag manipulation command"); + return Ok(()); + } + + let gu = self.config.get_acct(); + let su = normalize_acct(&s.account.acct, gu)?; + + if self.config.is_banned(&su) { + debug!("Status author @{} is banned.", su); + return Ok(()); + } + + if !self.config.is_member_or_admin(&su) { + debug!("Status author @{} is not a member.", su); + return Ok(()); + } + + let tags = crate::command::parse_status_tags(&s.content); + debug!("Tags in status: {:?}", tags); + + for t in tags { + if self.config.is_tag_followed(&t) { + self.client.reblog(&s.id).await + .log_error("Failed to reblog"); + break; + } + } + + Ok(()) + } + + async fn follow_user(&mut self, acct: &str) -> Result<(), GroupError> { + self.client.follow(acct).await?; + Ok(()) + } + + async fn unfollow_user(&mut self, acct: &str) -> Result<(), GroupError> { + self.client.unfollow(acct).await?; + Ok(()) + } + /// Catch up with missed notifications, returns true if any were handled async fn catch_up_with_missed_notifications(&mut self) -> Result { let last_notif = self.config.get_last_notif(); @@ -601,4 +745,92 @@ impl GroupHandle { Ok(true) } + + /// Catch up with missed statuses, returns true if any were handled + async fn catch_up_with_missed_statuses(&mut self) -> Result { + let last_status = self.config.get_last_status(); + + let notifications = self.client.get_home_timeline().await?; + let mut iter = notifications.items_iter(); + + let mut statuses_to_handle = vec![]; + + // They are retrieved newest first, but we want oldest first for chronological handling + + let mut num = 0; + while let Some(s) = iter.next_item().await { + let ts = s.timestamp_millis(); + + if ts <= last_status { + break; // reached our last seen status (hopefully there arent any retro-bumped) + } + if s.content.contains('#') && !s.visibility.is_private() { + statuses_to_handle.push(s); + } + num += 1; + if num > MAX_CATCHUP_STATUSES { + warn!("Too many statuses missed to catch up!"); + break; + } + } + + if statuses_to_handle.is_empty() { + return Ok(false); + } + + statuses_to_handle.reverse(); + + debug!("{} statuses to catch up!", statuses_to_handle.len()); + + for s in statuses_to_handle { + debug!("Handling missed status: {}", StatusDisplay(&s)); + self.handle_status(s).await + .log_error("Error handling a status"); + } + + Ok(true) + } + + fn list_admins(&self, replies: &mut Vec) { + let mut admins = self.config.get_admins().collect::>(); + admins.sort(); + for a in admins { + replies.push(a.to_string()); + } + } + + fn list_members(&self, replies: &mut Vec) { + let admins = self.config.get_admins().collect::>(); + let mut members = self.config.get_members().collect::>(); + members.extend(admins.iter()); + members.sort(); + members.dedup(); + for m in members { + if admins.contains(&m) { + replies.push(format!("{} [admin]", m)); + } else { + replies.push(m.to_string()); + } + } + } +} + +fn make_welcome_text(user: &str) -> String { + format!( + "@{user} Welcome to the group! To share a post, tag the group user \ + or use one of the group hashtags. Use /help for more info.", + user = user + ) +} + + +trait VisExt: Copy { + /// Check if is private or direct + fn is_private(self) -> bool; +} + +impl VisExt for Visibility { + fn is_private(self) -> bool { + self == Visibility::Direct || self == Visibility::Private + } } diff --git a/src/store/data.rs b/src/store/data.rs index db90519..495fa6a 100644 --- a/src/store/data.rs +++ b/src/store/data.rs @@ -33,6 +33,8 @@ pub(crate) struct GroupConfig { acct: String, /// elefren data appdata: AppData, + /// Hashtags the group will auto-boost from it's members + group_tags: HashSet, /// List of admin account "acct" names, e.g. piggo@piggo.space admin_users: HashSet, /// List of users allowed to post to the group, if it is member-only @@ -43,9 +45,10 @@ pub(crate) struct GroupConfig { member_only: bool, /// Banned domain names, e.g. kiwifarms.cc banned_servers: HashSet, - /// Last seen notification timestamp + /// Last seen notification timestamp (millis) last_notif_ts: u64, - + /// Last seen status timestamp (millis) + last_status_ts: u64, #[serde(skip)] dirty: bool, } @@ -62,12 +65,14 @@ impl Default for GroupConfig { redirect: Default::default(), token: Default::default(), }, + group_tags: Default::default(), admin_users: Default::default(), member_users: Default::default(), banned_users: Default::default(), member_only: false, banned_servers: Default::default(), last_notif_ts: 0, + last_status_ts: 0, dirty: false, } } @@ -86,10 +91,12 @@ impl GroupConfig { self.enabled } + /* pub(crate) fn set_enabled(&mut self, ena: bool) { self.enabled = ena; self.mark_dirty(); } + */ pub(crate) fn get_appdata(&self) -> &AppData { &self.appdata @@ -108,6 +115,10 @@ impl GroupConfig { self.member_users.iter() } + pub(crate) fn get_tags(&self) -> impl Iterator { + self.group_tags.iter() + } + pub(crate) fn set_last_notif(&mut self, ts: u64) { self.last_notif_ts = self.last_notif_ts.max(ts); self.mark_dirty(); @@ -117,6 +128,15 @@ impl GroupConfig { self.last_notif_ts } + pub(crate) fn set_last_status(&mut self, ts: u64) { + self.last_status_ts = self.last_status_ts.max(ts); + self.mark_dirty(); + } + + pub(crate) fn get_last_status(&self) -> u64 { + self.last_status_ts + } + pub(crate) fn get_acct(&self) -> &str { &self.acct } @@ -129,6 +149,11 @@ impl GroupConfig { self.member_users.contains(acct) } + pub(crate) fn is_member_or_admin(&self, acct: &str) -> bool { + self.is_member(acct) + || self.is_admin(acct) + } + pub(crate) fn is_banned(&self, acct: &str) -> bool { self.banned_users.contains(acct) || self.is_users_server_banned(acct) } @@ -205,6 +230,20 @@ impl GroupConfig { Ok(()) } + pub(crate) fn add_tag(&mut self, tag: &str) { + self.group_tags.insert(tag.to_string()); + self.mark_dirty(); + } + + pub(crate) fn remove_tag(&mut self, tag: &str) { + self.group_tags.remove(tag); + self.mark_dirty(); + } + + pub(crate) fn is_tag_followed(&self, tag: &str) -> bool { + self.group_tags.contains(tag) + } + pub(crate) fn set_member_only(&mut self, member_only: bool) { self.member_only = member_only; self.mark_dirty(); diff --git a/src/store/mod.rs b/src/store/mod.rs index f6d1e03..9d03306 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -148,10 +148,12 @@ impl ConfigStore { .collect() } + /* pub(crate) async fn get_group_config(&self, group: &str) -> Option { let c = self.data.read().await; c.get_group_config(group).cloned() } + */ //noinspection RsSelfConvention /// Set group config to the store. The store then saved.