From 8afc77dd60d46c65301468df94ba72b0869b0b48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Hru=C5=A1ka?= Date: Wed, 25 Aug 2021 21:06:12 +0200 Subject: [PATCH] tags wip --- .gitignore | 1 + Cargo.lock | 2 +- Cargo.toml | 2 +- src/command.rs | 37 +- src/group_handle.rs | 975 ++++++++++++++++++++++++-------------------- src/store/data.rs | 2 +- src/store/mod.rs | 21 +- 7 files changed, 567 insertions(+), 473 deletions(-) diff --git a/.gitignore b/.gitignore index 5a62f49..1758c24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ group-actor-data.toml .idea/ groups.json fedigroups +*.bak diff --git a/Cargo.lock b/Cargo.lock index bb2bd64..8cf9def 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,7 +327,7 @@ checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" [[package]] name = "fedigroups" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8fec97f..8449619 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fedigroups" -version = "0.1.0" +version = "0.2.0" authors = ["Ondřej Hruška "] edition = "2018" publish = false diff --git a/src/command.rs b/src/command.rs index 394f73e..97046bb 100644 --- a/src/command.rs +++ b/src/command.rs @@ -122,11 +122,12 @@ static RE_ANNOUNCE: once_cell::sync::Lazy = Lazy::new(|| Regex::new(concat!(r"(?:^|\s|>|\n)[\\/]announce\s+(.*)$")).unwrap()); static RE_A_HASHTAG: once_cell::sync::Lazy = - Lazy::new(|| Regex::new(concat!(r"\b#(\w+)")).unwrap()); + Lazy::new(|| Regex::new(concat!(r"(?:^|\b|\s|>|\n)#(\w+)")).unwrap()); pub fn parse_status_tags(content: &str) -> Vec { debug!("Raw content: {}", content); - let content = content.replace("
", " "); + let content = content.replace("
", "
"); + let content = content.replace("

", "

"); let content = voca_rs::strip::strip_tags(&content); debug!("Stripped tags: {}", content); @@ -143,10 +144,8 @@ pub fn parse_status_tags(content: &str) -> Vec { pub fn parse_slash_commands(content: &str) -> Vec { debug!("Raw content: {}", content); - let content = content.replace("
", " "); - // let content = content.replace("
", " "); - // let content = content.replace("
", " "); - // let content = content.replace("
", " "); + let content = content.replace("
", "
"); + let content = content.replace("

", "

"); let content = voca_rs::strip::strip_tags(&content); debug!("Stripped tags: {}", content); @@ -423,13 +422,14 @@ mod test { } #[test] - fn test_add_member() { + fn test_add_tag() { 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)")); + assert!(RE_ADD_TAG.is_match("/add #nya and more)")); let c = RE_ADD_TAG.captures("/add #breadposting"); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "breadposting"); @@ -440,10 +440,10 @@ mod test { let c = RE_ADD_TAG.captures("/add #ласточка"); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "ласточка"); - let c = RE_ADD_TAG.captures("#nya."); + let c = RE_ADD_TAG.captures("/add #nya."); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "nya"); - let c = RE_ADD_TAG.captures("#nya)"); + let c = RE_ADD_TAG.captures("/add #nya)"); assert_eq!(c.unwrap().get(1).unwrap().as_str(), "nya"); } @@ -510,7 +510,7 @@ mod test { } #[test] - fn test_members() { + fn test_tags() { assert!(!RE_TAGS.is_match("/members")); assert!(RE_TAGS.is_match("/hashtags")); assert!(RE_TAGS.is_match("dsfsd /tags dfgd d")); @@ -524,10 +524,17 @@ mod test { 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(), "ласточка"); + for (i, c) in RE_A_HASHTAG.captures_iter("foo #banana #χαλβάς #ласточка").enumerate() { + if i == 0 { + assert_eq!(c.get(1).unwrap().as_str(), "banana"); + } + else if i == 1 { + assert_eq!(c.get(1).unwrap().as_str(), "χαλβάς"); + } + else if i == 2 { + assert_eq!(c.get(1).unwrap().as_str(), "ласточка"); + } + } } #[test] @@ -540,7 +547,7 @@ mod test { } #[test] - fn test_leave() { + fn test_join() { assert!(!RE_JOIN.is_match("/list")); assert!(RE_JOIN.is_match("/join")); assert!(RE_JOIN.is_match("/join")); diff --git a/src/group_handle.rs b/src/group_handle.rs index 23d935c..82a2bce 100644 --- a/src/group_handle.rs +++ b/src/group_handle.rs @@ -27,10 +27,10 @@ pub struct GroupHandle { } const DELAY_BEFORE_ACTION: Duration = Duration::from_millis(250); -const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(1000); +const DELAY_REOPEN_STREAM: Duration = Duration::from_millis(500); const MAX_CATCHUP_NOTIFS: usize = 25; // also statuses -const MAX_CATCHUP_STATUSES: usize = 100; +const MAX_CATCHUP_STATUSES: usize = 50; // 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! @@ -39,6 +39,7 @@ impl GroupHandle { pub async fn save(&mut self) -> Result<(), GroupError> { debug!("Saving group config & status"); self.store.set_group_config(self.config.clone()).await?; + debug!("Saved"); self.config.clear_dirty_status(); Ok(()) } @@ -84,17 +85,17 @@ impl GroupHandle { pub async fn run(&mut self) -> Result<(), GroupError> { assert!(PERIODIC_SAVE >= PING_INTERVAL); - let mut next_save = Instant::now() + PERIODIC_SAVE; // so we save at start - loop { debug!("Opening streaming API socket"); + let mut next_save = Instant::now() + PERIODIC_SAVE; // so we save at start let mut events = self.client.streaming_user().await?; + let socket_open_time = Instant::now(); + let mut last_rx = Instant::now(); + let mut last_ping = Instant::now(); match self.catch_up_with_missed_notifications().await { Ok(true) => { debug!("Some missed notifs handled"); - // Save asap! - next_save = Instant::now() - PERIODIC_SAVE } Ok(false) => { debug!("No notifs missed"); @@ -107,8 +108,6 @@ 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"); @@ -118,12 +117,29 @@ impl GroupHandle { } } - loop { + if self.config.is_dirty() { + // save asap + next_save = Instant::now() - PERIODIC_SAVE + } + + 'rx: loop { if next_save < Instant::now() { + debug!("Save time elapsed, saving if needed"); self.save_if_needed().await.log_error("Failed to save group"); next_save = Instant::now() + PERIODIC_SAVE; } + if last_rx.elapsed() > PING_INTERVAL * 2 { + warn!("Socket idle too long, close"); + break 'rx; + } + + if socket_open_time.elapsed() > Duration::from_secs(120) { + debug!("Socket open too long, closing"); + break 'rx; + } + + debug!("Await msg"); let timeout = next_save .saturating_duration_since(Instant::now()) .min(PING_INTERVAL) @@ -131,6 +147,7 @@ impl GroupHandle { match tokio::time::timeout(timeout, events.next()).await { Ok(Some(event)) => { + last_rx = Instant::now(); debug!("(@{}) Event: {}", self.config.get_acct(), EventDisplay(&event)); match event { Event::Update(status) => { @@ -141,19 +158,26 @@ impl GroupHandle { } Event::Delete(_id) => {} Event::FiltersChanged => {} + Event::Heartbeat => {} } } Ok(None) => { warn!("Group @{} socket closed, restarting...", self.config.get_acct()); - break; + break 'rx; } Err(_) => { // Timeout so we can save if needed } } - trace!("Pinging"); - events.send_ping().await.log_error("Fail to send ping"); + if last_ping.elapsed() > PING_INTERVAL { + last_ping = Instant::now(); + debug!("Pinging"); + if events.send_ping() + .await.is_err() { + break 'rx; + } + } } warn!("Notif stream closed, will reopen"); @@ -167,10 +191,13 @@ impl GroupHandle { self.config.set_last_notif(ts); let group_acct = self.config.get_acct().to_string(); + let notif_user_id = &n.account.id; let notif_acct = normalize_acct(&n.account.acct, &group_acct)?; - let can_write = self.config.can_write(¬if_acct); - let is_admin = self.config.is_admin(¬if_acct); + if notif_acct == group_acct { + debug!("This is our post, ignore that"); + return Ok(()); + } if self.config.is_banned(¬if_acct) { warn!("Notification actor {} is banned!", notif_acct); @@ -180,427 +207,7 @@ impl GroupHandle { match n.notification_type { NotificationType::Mention => { if let Some(status) = n.status { - let status_acct = normalize_acct(&status.account.acct, &group_acct)?; - - if self.config.is_banned(&status_acct) { - warn!("Status author {} is banned!", status_acct); - return Ok(()); - } - - let commands = crate::command::parse_slash_commands(&status.content); - - let mut replies = vec![]; - let mut announcements = vec![]; - let mut do_boost_prev_post = false; - let mut any_admin_cmd = false; - - if commands.is_empty() { - debug!("No commands in post"); - if status.in_reply_to_id.is_none() { - if can_write { - // Someone tagged the group in OP, boost it. - info!("Boosting OP mention"); - tokio::time::sleep(DELAY_BEFORE_ACTION).await; - self.client.reblog(&status.id).await.log_error("Failed to boost"); - } else { - replies.push("You are not allowed to post to this group".to_string()); - } - } else { - debug!("Not OP, ignore mention"); - } - } else { - for cmd in commands { - match cmd { - // ignore is first on purpose - StatusCommand::Ignore => { - debug!("Notif ignored because of ignore command"); - return Ok(()); - } - StatusCommand::Announce(a) => { - info!("Sending PSA"); - announcements.push(a); - } - StatusCommand::Boost => { - if can_write { - do_boost_prev_post = status.in_reply_to_id.is_some(); - } else { - replies.push("You are not allowed to share to this group".to_string()); - } - } - StatusCommand::BanUser(u) => { - let u = normalize_acct(&u, &group_acct)?; - if is_admin { - if !self.config.is_banned(&u) { - match self.config.ban_user(&u, true) { - Ok(_) => { - 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) => { - replies.push(format!("Failed to ban user {}: {}", u, e)); - } - } - } - } else { - replies.push("Only admins can manage user bans".to_string()); - } - } - StatusCommand::UnbanUser(u) => { - let u = normalize_acct(&u, &group_acct)?; - if is_admin { - if self.config.is_banned(&u) { - match self.config.ban_user(&u, false) { - Ok(_) => { - any_admin_cmd = true; - replies.push(format!("User {} un-banned!", u)); - - // no announcement here - } - Err(_) => { - unreachable!() - } - } - } - } else { - replies.push("Only admins can manage user bans".to_string()); - } - } - StatusCommand::BanServer(s) => { - if is_admin { - if !self.config.is_server_banned(&s) { - match self.config.ban_server(&s, true) { - Ok(_) => { - any_admin_cmd = true; - announcements.push(format!("Server \"{}\" has been banned.", s)); - replies.push(format!("Server {} banned from group!", s)); - } - Err(e) => { - replies.push(format!("Failed to ban server {}: {}", s, e)); - } - } - } - } else { - replies.push("Only admins can manage server bans".to_string()); - } - } - StatusCommand::UnbanServer(s) => { - if is_admin { - if self.config.is_server_banned(&s) { - match self.config.ban_server(&s, false) { - Ok(_) => { - any_admin_cmd = true; - announcements.push(format!("Server \"{}\" has been un-banned.", s)); - replies.push(format!("Server {} un-banned!", s)); - } - Err(_) => { - unreachable!() - } - } - } - } else { - replies.push("Only admins can manage server bans".to_string()); - } - } - StatusCommand::AddMember(u) => { - let u = normalize_acct(&u, &group_acct)?; - if is_admin { - if !self.config.is_member(&u) { - match self.config.set_member(&u, true) { - Ok(_) => { - any_admin_cmd = true; - replies.push(format!("User {} added 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)); - } - } - } - } else { - replies.push("Only admins can manage members".to_string()); - } - } - StatusCommand::RemoveMember(u) => { - let u = normalize_acct(&u, &group_acct)?; - if is_admin { - if self.config.is_member(&u) { - match self.config.set_member(&u, false) { - 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!() - } - } - } - } else { - 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 - .push(format!("User @{} can now manage this group!", u)); - } - Err(e) => { - replies.push(format!( - "Failed to make user {} a group admin: {}", - u, e - )); - } - } - } - } else { - replies.push("Only admins can manage admins".to_string()); - } - } - StatusCommand::RemoveAdmin(u) => { - let u = normalize_acct(&u, &group_acct)?; - if is_admin { - if self.config.is_admin(&u) { - match self.config.set_admin(&u, false) { - Ok(_) => { - any_admin_cmd = true; - replies.push(format!("User {} is no longer a group admin!", u)); - announcements - .push(format!("User @{} no longer manages this group.", u)); - } - Err(e) => { - replies - .push(format!("Failed to revoke {}'s group admin: {}", u, e)); - } - } - } - } else { - replies.push("Only admins can manage admins".to_string()); - } - } - StatusCommand::OpenGroup => { - if is_admin { - if self.config.is_member_only() { - any_admin_cmd = true; - self.config.set_member_only(false); - replies.push("Group changed to open-access".to_string()); - announcements.push("This group is now open-access!".to_string()); - } - } else { - replies.push("Only admins can set group options".to_string()); - } - } - StatusCommand::CloseGroup => { - if is_admin { - if !self.config.is_member_only() { - any_admin_cmd = true; - self.config.set_member_only(true); - replies.push("Group changed to member-only".to_string()); - announcements.push("This group is now member-only!".to_string()); - } - } else { - replies.push("Only admins can set group options".to_string()); - } - } - StatusCommand::Help => { - if self.config.is_member_only() { - let mut s = "This is a member-only group. ".to_string(); - if self.config.can_write(¬if_acct) { - if is_admin { - s.push_str("*You are an admin.*"); - } else { - s.push_str("*You are a member.*"); - } - } else { - s.push_str("*You are not a member, ask one of the admins to add you.*"); - } - replies.push(s); - } else { - let mut s = "This is a public-access group. ".to_string(); - if is_admin { - s.push_str("*You are an admin.*"); - } - replies.push(s); - } - - replies.push( - "\nTo share an original post, mention the group user.\n\ - Replies and mentions with commands won't be shared.\n\ - \n\ - **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\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()); - } else { - replies.push("`/members, /who` - show group admins".to_string()); - } - - if is_admin { - replies.push( - "\n\ - **Admin commands:**\n\ - `/add user` - add a member (use e-mail style address)\n\ - `/kick, /remove user` - kick a member\n\ - `/ban x` - ban a user or a server\n\ - `/unban x` - lift a ban\n\ - `/op, /admin user` - grant admin rights\n\ - `/deop, /deadmin user` - revoke admin rights\n\ - `/opengroup` - make member-only\n\ - `/closegroup` - make public-access\n\ - `/announce x` - make a public announcement from the rest of the status" - .to_string(), - ); - } - } - StatusCommand::ListMembers => { - let mut show_admins = false; - if is_admin { - if self.config.is_member_only() { - replies.push("Group members:".to_string()); - self.list_members(&mut replies); - } else { - show_admins = true; - } - } else { - show_admins = true; - } - - if show_admins { - replies.push("Group admins:".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_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'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 => { - replies.push("Pong".to_string()); - } - } - } - - tokio::time::sleep(DELAY_BEFORE_ACTION).await; - } - - if do_boost_prev_post { - self.client - .reblog(&status.in_reply_to_id.as_ref().unwrap()) - .await - .log_error("Failed to boost"); - } - - if !replies.is_empty() { - let r = replies.join("\n"); - - let post = StatusBuilder::new() - .status(format!("@{user}\n{msg}", user = notif_acct, msg = r)) - .content_type("text/markdown") - .visibility(Visibility::Direct) - .build() - .expect("error build status"); - - let _ = self.client.new_status(post).await.log_error("Failed to post"); - } - - if !announcements.is_empty() { - let msg = announcements.join("\n"); - let post = StatusBuilder::new() - .status(format!("**📢 Group announcement**\n{msg}", msg = msg)) - .content_type("text/markdown") - .visibility(Visibility::Public) - .build() - .expect("error build status"); - - let _ = self.client.new_status(post).await.log_error("Failed to post"); - } - - if any_admin_cmd { - self.save_if_needed().await.log_error("Failed to save"); - } + self.handle_mention_status(status).await?; } } NotificationType::Follow => { @@ -624,7 +231,7 @@ impl GroupHandle { admins = admins.join(", ") ) } else { - self.follow_user(¬if_acct).await + self.follow_user(notif_user_id).await .log_error("Failed to follow"); make_welcome_text(¬if_acct) }; @@ -636,7 +243,7 @@ impl GroupHandle { .build() .expect("error build status"); - tokio::time::sleep(Duration::from_millis(500)).await; + // tokio::time::sleep(Duration::from_millis(500)).await; let _ = self.client.new_status(post).await.log_error("Failed to post"); } } @@ -653,6 +260,22 @@ impl GroupHandle { let ts = s.timestamp_millis(); self.config.set_last_status(ts); + let group_user = self.config.get_acct(); + let status_user = normalize_acct(&s.account.acct, group_user)?; + + if status_user == group_user { + debug!("This is our post, ignore that"); + return Ok(()); + } + + // for m in &s.mentions { + // let ma = normalize_acct(&m.acct, gu)?; + // if ma == gu { + // debug!("Mention detected, handle status as mention notification!"); + // return self.handle_mention_status(s).await; + // } + // } + if !s.content.contains('#') { debug!("No tags in status"); return Ok(()); @@ -668,16 +291,14 @@ impl GroupHandle { 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); + if self.config.is_banned(&status_user) { + debug!("Status author @{} is banned.", status_user); return Ok(()); } - if !self.config.is_member_or_admin(&su) { - debug!("Status author @{} is not a member.", su); + if !self.config.is_member_or_admin(&status_user) { + debug!("Status author @{} is not a member.", status_user); return Ok(()); } @@ -695,13 +316,13 @@ impl GroupHandle { Ok(()) } - async fn follow_user(&mut self, acct: &str) -> Result<(), GroupError> { - self.client.follow(acct).await?; + async fn follow_user(&mut self, id: &str) -> Result<(), GroupError> { + self.client.follow(id).await?; Ok(()) } - async fn unfollow_user(&mut self, acct: &str) -> Result<(), GroupError> { - self.client.unfollow(acct).await?; + async fn unfollow_user(&mut self, id: &str) -> Result<(), GroupError> { + self.client.unfollow(id).await?; Ok(()) } @@ -722,12 +343,17 @@ impl GroupHandle { if ts <= last_notif { break; // reached our last seen notif } + + debug!("Inspecting notif {}", NotificationDisplay(&n)); notifs_to_handle.push(n); num += 1; if num > MAX_CATCHUP_NOTIFS { warn!("Too many notifs missed to catch up!"); break; } + + // sleep so we dont make the api angry + tokio::time::sleep(Duration::from_millis(250)).await; } if notifs_to_handle.is_empty() { @@ -750,20 +376,28 @@ impl GroupHandle { 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 statuses = self.client.get_home_timeline().await?; + let mut iter = statuses.items_iter(); let mut statuses_to_handle = vec![]; // They are retrieved newest first, but we want oldest first for chronological handling + let mut newest_status = None; + 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) } + + debug!("Inspecting status {}", StatusDisplay(&s)); + + if newest_status.is_none() { + newest_status = Some(ts); + } + if s.content.contains('#') && !s.visibility.is_private() { statuses_to_handle.push(s); } @@ -772,6 +406,13 @@ impl GroupHandle { warn!("Too many statuses missed to catch up!"); break; } + + // sleep so we dont make the api angry + tokio::time::sleep(Duration::from_millis(250)).await; + } + + if let Some(ts) = newest_status { + self.config.set_last_status(ts); } if statuses_to_handle.is_empty() { @@ -813,6 +454,440 @@ impl GroupHandle { } } } + async fn handle_mention_status(&mut self, status: Status) -> Result<(), GroupError> { + let group_acct = self.config.get_acct().to_string(); + let status_acct = normalize_acct(&status.account.acct, &group_acct)?; + let status_user_id = &status.account.id; + + let can_write = self.config.can_write(&status_acct); + let is_admin = self.config.is_admin(&status_acct); + + if self.config.is_banned(&status_acct) { + warn!("Status author {} is banned!", status_acct); + return Ok(()); + } + + let commands = crate::command::parse_slash_commands(&status.content); + + let mut replies = vec![]; + let mut announcements = vec![]; + let mut do_boost_prev_post = false; + let mut any_admin_cmd = false; + let mut want_markdown = false; + + if commands.is_empty() { + debug!("No commands in post"); + if status.in_reply_to_id.is_none() { + if can_write { + // Someone tagged the group in OP, boost it. + info!("Boosting OP mention"); + // tokio::time::sleep(DELAY_BEFORE_ACTION).await; + self.client.reblog(&status.id).await.log_error("Failed to boost"); + } else { + replies.push("You are not allowed to post to this group".to_string()); + } + } else { + debug!("Not OP, ignore mention"); + } + } else { + for cmd in commands { + match cmd { + // ignore is first on purpose + StatusCommand::Ignore => { + debug!("Notif ignored because of ignore command"); + return Ok(()); + } + StatusCommand::Announce(a) => { + info!("Sending PSA"); + announcements.push(a); + } + StatusCommand::Boost => { + if can_write { + do_boost_prev_post = status.in_reply_to_id.is_some(); + } else { + replies.push("You are not allowed to share to this group".to_string()); + } + } + StatusCommand::BanUser(u) => { + let u = normalize_acct(&u, &group_acct)?; + if is_admin { + if !self.config.is_banned(&u) { + match self.config.ban_user(&u, true) { + Ok(_) => { + any_admin_cmd = true; + replies.push(format!("User {} banned from group!", u)); + + // FIXME we need user ID, not handle - get it via API? + // self.unfollow_user(&u).await + // .log_error("Failed to unfollow"); + + // no announcement here + } + Err(e) => { + replies.push(format!("Failed to ban user {}: {}", u, e)); + } + } + } + } else { + replies.push("Only admins can manage user bans".to_string()); + } + } + StatusCommand::UnbanUser(u) => { + let u = normalize_acct(&u, &group_acct)?; + if is_admin { + if self.config.is_banned(&u) { + match self.config.ban_user(&u, false) { + Ok(_) => { + any_admin_cmd = true; + replies.push(format!("User {} un-banned!", u)); + + // no announcement here + } + Err(_) => { + unreachable!() + } + } + } + } else { + replies.push("Only admins can manage user bans".to_string()); + } + } + StatusCommand::BanServer(s) => { + if is_admin { + if !self.config.is_server_banned(&s) { + match self.config.ban_server(&s, true) { + Ok(_) => { + any_admin_cmd = true; + announcements.push(format!("Server \"{}\" has been banned.", s)); + replies.push(format!("Server {} banned from group!", s)); + } + Err(e) => { + replies.push(format!("Failed to ban server {}: {}", s, e)); + } + } + } + } else { + replies.push("Only admins can manage server bans".to_string()); + } + } + StatusCommand::UnbanServer(s) => { + if is_admin { + if self.config.is_server_banned(&s) { + match self.config.ban_server(&s, false) { + Ok(_) => { + any_admin_cmd = true; + announcements.push(format!("Server \"{}\" has been un-banned.", s)); + replies.push(format!("Server {} un-banned!", s)); + } + Err(_) => { + unreachable!() + } + } + } + } else { + replies.push("Only admins can manage server bans".to_string()); + } + } + StatusCommand::AddMember(u) => { + let u = normalize_acct(&u, &group_acct)?; + if is_admin { + if !self.config.is_member(&u) { + match self.config.set_member(&u, true) { + Ok(_) => { + any_admin_cmd = true; + replies.push(format!("User {} added to the group!", u)); + self.follow_user(status_user_id).await.log_error("Failed to follow"); + } + Err(e) => { + replies.push(format!("Failed to add user {} to group: {}", u, e)); + } + } + } + } else { + replies.push("Only admins can manage members".to_string()); + } + } + StatusCommand::RemoveMember(u) => { + let u = normalize_acct(&u, &group_acct)?; + if is_admin { + if self.config.is_member(&u) { + match self.config.set_member(&u, false) { + Ok(_) => { + any_admin_cmd = true; + replies.push(format!("User {} removed from the group.", u)); + + // FIXME we need user ID, not handle - get it via API? + // self.unfollow_user(&u).await + // .log_error("Failed to unfollow"); + } + Err(_) => { + unreachable!() + } + } + } + } else { + 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 + .push(format!("User @{} can now manage this group!", u)); + } + Err(e) => { + replies.push(format!( + "Failed to make user {} a group admin: {}", + u, e + )); + } + } + } + } else { + replies.push("Only admins can manage admins".to_string()); + } + } + StatusCommand::RemoveAdmin(u) => { + let u = normalize_acct(&u, &group_acct)?; + if is_admin { + if self.config.is_admin(&u) { + match self.config.set_admin(&u, false) { + Ok(_) => { + any_admin_cmd = true; + replies.push(format!("User {} is no longer a group admin!", u)); + announcements + .push(format!("User @{} no longer manages this group.", u)); + } + Err(e) => { + replies + .push(format!("Failed to revoke {}'s group admin: {}", u, e)); + } + } + } + } else { + replies.push("Only admins can manage admins".to_string()); + } + } + StatusCommand::OpenGroup => { + if is_admin { + if self.config.is_member_only() { + any_admin_cmd = true; + self.config.set_member_only(false); + replies.push("Group changed to open-access".to_string()); + announcements.push("This group is now open-access!".to_string()); + } + } else { + replies.push("Only admins can set group options".to_string()); + } + } + StatusCommand::CloseGroup => { + if is_admin { + if !self.config.is_member_only() { + any_admin_cmd = true; + self.config.set_member_only(true); + replies.push("Group changed to member-only".to_string()); + announcements.push("This group is now member-only!".to_string()); + } + } else { + replies.push("Only admins can set group options".to_string()); + } + } + StatusCommand::Help => { + want_markdown = true; + + if self.config.is_member_only() { + replies.push("This is a member-only group. ".to_string()); + } else { + replies.push("This is a public-access group. ".to_string()); + } + + if self.config.can_write(&status_acct) { + if is_admin { + replies.push("*You are an admin.*".to_string()); + } else { + replies.push("*You are a member.*".to_string()); + } + } else { + if self.config.is_member_only() { + replies.push("*You are not a member, ask one of the admins to add you.*".to_string()); + } else { + replies.push("*You are not a member, follow or use /join to join the group.*".to_string()); + } + } + + replies.push( + "\nTo share an original post, mention the group user.\n\ + Replies and mentions with commands won't be shared.\n\ + \n\ + **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\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()); + } else { + replies.push("`/members, /who` - show group admins".to_string()); + } + + if is_admin { + replies.push( + "\n\ + **Admin commands:**\n\ + `/add user` - add a member (use e-mail style address)\n\ + `/kick, /remove user` - kick a member\n\ + `/ban x` - ban a user or a server\n\ + `/unban x` - lift a ban\n\ + `/op, /admin user` - grant admin rights\n\ + `/deop, /deadmin user` - revoke admin rights\n\ + `/opengroup` - make member-only\n\ + `/closegroup` - make public-access\n\ + `/announce x` - make a public announcement from the rest of the status" + .to_string(), + ); + } + } + StatusCommand::ListMembers => { + let mut show_admins = false; + if is_admin { + replies.push("Group members:".to_string()); + self.list_members(&mut replies); + } else { + replies.push("Group admins:".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_or_admin(&status_acct) { + // admin can leave but that's a bad idea + + any_admin_cmd = true; + let _ = self.config.set_member(&status_acct, false); + replies.push("You're no longer a group member. Unfollow the group user to stop receiving group messages.".to_string()); + + self.unfollow_user(&status_user_id).await + .log_error("Failed to unfollow"); + } + } + StatusCommand::Join => { + if self.config.is_member_or_admin(&status_acct) { + // Already a member, so let's try to follow the user + // again, maybe first time it failed + self.follow_user(status_user_id).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(status_user_id).await + .log_error("Failed to follow"); + + // This only fails if the user is banned, but that is filtered above + let _ = self.config.set_member(&status_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 => { + replies.push("Pong".to_string()); + } + } + } + + // tokio::time::sleep(DELAY_BEFORE_ACTION).await; + } + + if do_boost_prev_post { + self.client + .reblog(&status.in_reply_to_id.as_ref().unwrap()) + .await + .log_error("Failed to boost"); + } + + if !replies.is_empty() { + debug!("replies={:?}", replies); + let r = replies.join("\n"); + debug!("r={}", r); + + let post = StatusBuilder::new() + .status(format!("@{user}\n{msg}", user = status_acct, msg = r)) + .content_type(if want_markdown { + "text/markdown" + } else { + "text/plain" + }) + .visibility(Visibility::Direct) + .build() + .expect("error build status"); + + let _ = self.client.new_status(post).await.log_error("Failed to post"); + } + + if !announcements.is_empty() { + let msg = announcements.join("\n"); + let post = StatusBuilder::new() + .status(format!("**📢 Group announcement**\n{msg}", msg = msg)) + .content_type("text/markdown") + .visibility(Visibility::Public) + .build() + .expect("error build status"); + + let _ = self.client.new_status(post).await.log_error("Failed to post"); + } + + if any_admin_cmd { + debug!("Saving after admin cmd"); + self.save_if_needed().await.log_error("Failed to save"); + } + + Ok(()) + } } fn make_welcome_text(user: &str) -> String { diff --git a/src/store/data.rs b/src/store/data.rs index 495fa6a..c921a7f 100644 --- a/src/store/data.rs +++ b/src/store/data.rs @@ -7,7 +7,7 @@ use crate::error::GroupError; /// This is the inner data struct holding the config #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub(crate) struct Config { - groups: HashMap, + pub(crate) groups: HashMap, } impl Config { diff --git a/src/store/mod.rs b/src/store/mod.rs index 9d03306..123aab4 100644 --- a/src/store/mod.rs +++ b/src/store/mod.rs @@ -9,6 +9,7 @@ use data::{Config, GroupConfig}; use crate::error::GroupError; use crate::group_handle::GroupHandle; +use std::time::Duration; pub(crate) mod data; @@ -82,6 +83,7 @@ impl ConfigStore { pub async fn reauth_group(self: &Arc, acct: &str) -> Result { let groups = self.data.read().await; let mut config = groups.get_group_config(acct).ok_or(GroupError::GroupNotExist)?.clone(); + drop(groups); println!("--- Re-authenticating bot user @{} ---", acct); let registration = Registration::new(config.get_appdata().base.to_string()) @@ -92,6 +94,8 @@ impl ConfigStore { .await?; let client = elefren::helpers::cli::authenticate(registration).await?; + println!("Auth complete"); + let appdata = client.data.clone(); config.set_appdata(appdata); @@ -106,8 +110,8 @@ impl ConfigStore { /// Spawn existing group using saved creds pub async fn spawn_groups(self: Arc) -> Vec { - let groups = self.data.read().await; - let groups_iter = groups.iter_groups().cloned(); + let groups = self.data.read().await.clone(); + let groups_iter = groups.groups.into_values(); // Connect in parallel futures::stream::iter(groups_iter) @@ -158,9 +162,15 @@ impl ConfigStore { //noinspection RsSelfConvention /// Set group config to the store. The store then saved. pub(crate) async fn set_group_config(&self, config: GroupConfig) -> Result<(), GroupError> { - let mut data = self.data.write().await; - data.set_group_config(config); - self.persist(&data).await?; + debug!("Locking mutex"); + if let Ok(mut data) = tokio::time::timeout(Duration::from_secs(1), self.data.write()).await { + debug!("Locked"); + data.set_group_config(config); + debug!("Writing file"); + self.persist(&data).await?; + } else { + error!("DEADLOCK? Timeout waiting for data RW Lock in settings store"); + } Ok(()) } @@ -187,6 +197,7 @@ fn make_scopes() -> Scopes { | Scopes::read(scopes::Read::Follows) | Scopes::write(scopes::Write::Statuses) | Scopes::write(scopes::Write::Media) + | Scopes::write(scopes::Write::Follows) } // trait TapOk {