use std::collections::HashSet; use std::time::Duration; use elefren::{FediClient, SearchType, StatusBuilder}; use elefren::entities::account::Account; use elefren::entities::prelude::Status; use elefren::status_builder::Visibility; use crate::command::{RE_NOBOT_TAG, StatusCommand}; use crate::error::GroupError; use crate::group_handler::GroupHandle; use crate::store::data::GroupConfig; use crate::utils::{LogError, normalize_acct}; use crate::utils; pub struct ProcessMention<'a> { status: Status, group_account: &'a Account, config: &'a mut GroupConfig, client: &'a mut FediClient, group_acct: String, status_acct: String, status_user_id: String, can_write: bool, is_admin: bool, replies: Vec, announcements: Vec, do_boost_prev_post: bool, want_markdown: bool, } impl<'a> ProcessMention<'a> { async fn lookup_acct_id(&self, acct: &str, followed: bool) -> Result, GroupError> { debug!("Looking up user ID by acct: {}", acct); match tokio::time::timeout(Duration::from_secs(5), self.client.search_v2( acct, !followed, Some(SearchType::Accounts), Some(1), followed, )).await { Err(_) => { warn!("Account lookup timeout!"); Err(GroupError::ApiTimeout) } Ok(Err(e)) => { // Elefren error Err(e.into()) } Ok(Ok(res)) => { for item in res.accounts { let acct_normalized = normalize_acct(&item.acct, &self.group_acct)?; if acct_normalized == acct { debug!("Search done, account found: {}", item.acct); return Ok(Some(item.id)); } else { warn!("Found wrong account: {}", item.acct); } } debug!("Search done, nothing found"); Ok(None) } } } fn append_admin_list_to_reply(&mut self) { let mut admins = self.config.get_admins().collect::>(); admins.sort(); for a in admins { self.replies.push(format!("- {}", a)); } } fn append_member_list_to_reply(&mut self) { 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 { self.replies.push(if admins.contains(&m) { format!("- {} [admin]", m) } else { format!("- {}", m) }); } } async fn follow_user_by_id(&self, id: &str) -> Result<(), GroupError> { debug!("Trying to follow user #{}", id); self.client.follow(id).await?; Ok(()) } async fn unfollow_user_by_id(&self, id: &str) -> Result<(), GroupError> { debug!("Trying to unfollow user #{}", id); self.client.unfollow(id).await?; Ok(()) } pub(crate) async fn run(gh: &'a mut GroupHandle, status: Status) -> Result<(), GroupError> { let group_acct = gh.config.get_acct().to_string(); let status_acct = normalize_acct(&status.account.acct, &group_acct)?.to_string(); if gh.config.is_banned(&status_acct) { warn!("Status author {} is banned!", status_acct); return Ok(()); } let pm = Self { group_account: &gh.group_account, status_user_id: status.account.id.to_string(), client: &mut gh.client, can_write: gh.config.can_write(&status_acct), is_admin: gh.config.is_admin(&status_acct), replies: vec![], announcements: vec![], do_boost_prev_post: false, want_markdown: false, group_acct, status_acct, status, config: &mut gh.config, }; pm.handle().await } async fn reblog_status(&self) { self.client.reblog(&self.status.id) .await .log_error("Failed to reblog status") } fn add_reply(&mut self, line: impl AsRef) { self.replies.push(line.as_ref().trim_matches(' ').to_string()) } fn add_announcement<'t>(&mut self, line: impl AsRef) { self.announcements.push(line.as_ref().trim_matches(' ').to_string()) } async fn handle(mut self) -> Result<(), GroupError> { let commands = crate::command::parse_slash_commands(&self.status.content); if commands.is_empty() { self.handle_post_with_no_commands().await; } else { if commands.contains(&StatusCommand::Ignore) { debug!("Notif ignored because of ignore command"); return Ok(()); } for cmd in commands { match cmd { StatusCommand::Undo => { self.cmd_undo().await .log_error("Error handling undo cmd"); } StatusCommand::Ignore => { unreachable!(); // Handled above } StatusCommand::Announce(a) => { self.cmd_announce(a).await; } StatusCommand::Boost => { self.cmd_boost().await; } StatusCommand::BanUser(u) => { self.cmd_ban_user(&u).await .log_error("Error handling ban-user cmd"); } StatusCommand::UnbanUser(u) => { self.cmd_unban_user(&u).await .log_error("Error handling unban-user cmd"); } StatusCommand::OptOut => { self.cmd_optout().await; } StatusCommand::OptIn => { self.cmd_optin().await; } StatusCommand::BanServer(s) => { self.cmd_ban_server(&s).await; } StatusCommand::UnbanServer(s) => { self.cmd_unban_server(&s).await; } StatusCommand::AddMember(u) => { self.cmd_add_member(&u).await .log_error("Error handling add-member cmd"); } StatusCommand::RemoveMember(u) => { self.cmd_remove_member(&u).await .log_error("Error handling remove-member cmd"); } StatusCommand::AddTag(tag) => { self.cmd_add_tag(tag).await; } StatusCommand::RemoveTag(tag) => { self.cmd_remove_tag(tag).await; } StatusCommand::GrantAdmin(u) => { self.cmd_grant_admin(&u).await .log_error("Error handling grant-admin cmd"); } StatusCommand::RemoveAdmin(u) => { self.cmd_revoke_member(&u).await .log_error("Error handling grant-admin cmd"); } StatusCommand::OpenGroup => { self.cmd_open_group().await; } StatusCommand::CloseGroup => { self.cmd_close_group().await; } StatusCommand::Help => { self.cmd_help().await; } StatusCommand::ListMembers => { self.cmd_list_members().await; } StatusCommand::ListTags => { self.cmd_list_tags().await; } StatusCommand::Leave => { self.cmd_leave().await; } StatusCommand::Join => { self.cmd_join().await; } StatusCommand::Ping => { self.cmd_ping().await; } } } } if self.do_boost_prev_post { if let (Some(prev_acct_id), Some(prev_status_id)) = (self.status.in_reply_to_account_id.as_ref(), self.status.in_reply_to_id.as_ref()) { match self.id_to_acct_check_boostable(prev_acct_id).await { Ok(_acct) => { self.client .reblog(prev_status_id) .await .log_error("Failed to boost"); } Err(e) => { warn!("Can't reblog: {}", e); } } } } if !self.replies.is_empty() { let mut msg = self.replies.join("\n"); debug!("r={}", msg); if self.want_markdown { apply_trailing_hashtag_pleroma_bug_workaround(&mut msg); } let mention = format!("@{user}", user = self.status_acct); self.send_reply_multipart(mention, msg).await?; } if !self.announcements.is_empty() { let mut msg = self.announcements.join("\n"); debug!("a={}", msg); if self.want_markdown { apply_trailing_hashtag_pleroma_bug_workaround(&mut msg); } let msg = format!("**📢 Group announcement**\n{msg}", msg = msg); self.send_announcement_multipart(&msg).await?; } Ok(()) } async fn send_reply_multipart(&self, mention : String, msg : String) -> Result<(), GroupError> { let parts = smart_split(&msg, Some(mention), self.config.get_character_limit()); for p in parts { if let Ok(post) = StatusBuilder::new() .status(p) .content_type(if self.want_markdown { "text/markdown" } else { "text/plain" }) .visibility(Visibility::Direct) .build() { self.client.new_status(post).await?; } // Sleep a bit to avoid throttling tokio::time::sleep(Duration::from_secs(1)).await; } Ok(()) } async fn send_announcement_multipart(&self, msg : &str) -> Result<(), GroupError> { let parts = smart_split(msg, None, self.config.get_character_limit()); for p in parts { let post = StatusBuilder::new() .status(p) .content_type("text/markdown") .visibility(Visibility::Public) .build() .expect("error build status"); self.client.new_status(post).await?; // Sleep a bit to avoid throttling tokio::time::sleep(Duration::from_secs(1)).await; } Ok(()) } async fn handle_post_with_no_commands(&mut self) { debug!("No commands in post"); if self.status.in_reply_to_id.is_none() { if self.can_write { // Someone tagged the group in OP, boost it. info!("Boosting OP mention"); // tokio::time::sleep(DELAY_BEFORE_ACTION).await; self.reblog_status().await; } else { warn!("User @{} can't post to group!", self.status_acct); } // Otherwise, don't react } else { debug!("Not OP, ignore mention"); } } async fn cmd_announce(&mut self, msg: String) { info!("Sending PSA"); self.add_announcement(msg); } async fn cmd_boost(&mut self) { if self.can_write { self.do_boost_prev_post = self.status.in_reply_to_id.is_some(); } else { 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> { let u = normalize_acct(user, &self.group_acct)?; if self.is_admin { if !self.config.is_banned(&u) { match self.config.ban_user(&u, true) { Ok(_) => { self.add_reply(format!("User {} banned from group!", u)); self.unfollow_by_acct(&u).await .log_error("Failed to unfollow banned user"); } Err(e) => { self.add_reply(format!("Failed to ban user {}: {}", u, e)); } } } else { self.add_reply(format!("No action, user {} is already banned", u)); } } else { warn!("Ignore cmd, user not admin"); } Ok(()) } async fn cmd_unban_user(&mut self, user: &str) -> Result<(), GroupError> { let u = normalize_acct(user, &self.group_acct)?; if self.is_admin { if self.config.is_banned(&u) { match self.config.ban_user(&u, false) { Ok(_) => { self.add_reply(format!("User {} un-banned!", u)); // no announcement here } Err(_) => { unreachable!() } } } else { self.add_reply(format!("No action, user {} is not banned", u)); } } else { warn!("Ignore cmd, user not admin"); } Ok(()) } async fn cmd_ban_server(&mut self, s: &str) { if self.is_admin { if !self.config.is_server_banned(s) { match self.config.ban_server(s, true) { Ok(_) => { self.add_reply(format!("Server {} banned from group!", s)); } Err(e) => { self.add_reply(format!("Failed to ban server {}: {}", s, e)); } } } else { self.add_reply(format!("No action, server {} already banned", s)); } } else { warn!("Ignore cmd, user not admin"); } } async fn cmd_unban_server(&mut self, s: &str) { if self.is_admin { if self.config.is_server_banned(s) { match self.config.ban_server(s, false) { Ok(_) => { self.add_reply(format!("Server {} un-banned!", s)); } Err(e) => { self.add_reply(format!("Unexpected error occured: {}", e)); } } } else { self.add_reply(format!("No action, server {} is not banned", s)); } } else { warn!("Ignore cmd, user not admin"); } } async fn cmd_add_member(&mut self, user: &str) -> Result<(), GroupError> { let u = normalize_acct(user, &self.group_acct)?; 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) { Ok(_) => { self.add_reply(format!("User {} added to the group!", u)); self.follow_by_acct(&u) .await.log_error("Failed to follow"); } Err(e) => { self.add_reply(format!("Failed to add user {} to group: {}", u, e)); } } } else { warn!("Ignore cmd, user not admin"); } Ok(()) } async fn cmd_remove_member(&mut self, user: &str) -> Result<(), GroupError> { let u = normalize_acct(user, &self.group_acct)?; if self.is_admin { match self.config.set_member(&u, false) { Ok(_) => { self.add_reply(format!("User {} removed from the group.", u)); self.unfollow_by_acct(&u).await .log_error("Failed to unfollow removed user"); } Err(e) => { self.add_reply(format!("Unexpected error occured: {}", e)); } } } else { warn!("Ignore cmd, user not admin"); } Ok(()) } async fn cmd_add_tag(&mut self, tag: String) { if self.is_admin { if !self.config.is_tag_followed(&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 { warn!("Ignore cmd, user not admin"); } } async fn cmd_remove_tag(&mut self, tag: String) { if self.is_admin { if self.config.is_tag_followed(&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 { warn!("Ignore cmd, user not admin"); } } async fn cmd_grant_admin(&mut self, user: &str) -> Result<(), GroupError> { let u = normalize_acct(user, &self.group_acct)?; if self.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); self.add_reply(format!("User {} is now a group admin!", u)); } Err(e) => { self.add_reply(format!( "Failed to make user {} a group admin: {}", u, e )); } } } else { self.add_reply(format!("No action, \"{}\" is admin already", u)); } } else { warn!("Ignore cmd, user not admin"); } Ok(()) } async fn cmd_revoke_member(&mut self, user: &str) -> Result<(), GroupError> { let u = normalize_acct(user, &self.group_acct)?; if self.is_admin { if self.config.is_admin(&u) { match self.config.set_admin(&u, false) { Ok(_) => { self.add_reply(format!("User {} is no longer a group admin!", u)); } Err(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 { warn!("Ignore cmd, user not admin"); } Ok(()) } async fn cmd_open_group(&mut self) { if self.is_admin { if self.config.is_member_only() { self.config.set_member_only(false); self.add_reply("Group changed to open-access"); } else { self.add_reply("No action, group is open-access already"); } } else { warn!("Ignore cmd, user not admin"); } } async fn cmd_close_group(&mut self) { if self.is_admin { if !self.config.is_member_only() { self.config.set_member_only(true); self.add_reply("Group changed to member-only"); } else { self.add_reply("No action, group is member-only already"); } } else { warn!("Ignore cmd, user not admin"); } } async fn cmd_help(&mut self) { self.want_markdown = true; let membership_line = if self.is_admin { "*You are an admin.*" } else if self.config.is_member(&self.status_acct) { "*You are a member.*" } else if self.config.is_member_only() { "*You are not a member, ask one of the admins to add you.*" } else { "*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("\ To share a post, @ the group user or use a group hashtag.\n\ \n\ **Supported commands:**\n\ `/boost`, `/b` - boost the replied-to post into the group\n\ `/ignore`, `/i` - make the group ignore the post\n\ `/tags` - show group hashtags\n\ `/join` - (re-)join the group\n\ `/leave` - leave the group\n\ `/optout` - forbid sharing of your posts"); if self.is_admin { self.add_reply("`/members`, `/who` - show group members / admins"); // undo is listed as an admin command } else { 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 { self.add_reply("\n\ **Admin commands:**\n\ `/ping` - check the group works\n\ `/add user` - add a member (user@domain)\n\ `/remove user` - remove a member\n\ `/add #hashtag` - add 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\ `/unban x` - lift a ban\n\ `/admin user` - grant admin rights\n\ `/deadmin user` - revoke admin rights\n\ `/closegroup` - make member-only\n\ `/opengroup` - make public-access\n\ `/announce x` - make a public announcement"); } } async fn cmd_list_members(&mut self) { self.want_markdown = true; if self.is_admin { self.add_reply("Group members:"); self.append_member_list_to_reply(); } else { self.add_reply("Group admins:"); self.append_admin_list_to_reply(); } } async fn cmd_list_tags(&mut self) { self.add_reply("Group tags:"); self.want_markdown = true; let mut tags = self.config.get_tags().collect::>(); tags.sort(); for t in tags { self.replies.push(format!("- {}", t).to_string()); } } async fn cmd_leave(&mut self) { if self.config.is_member_or_admin(&self.status_acct) { // admin can leave but that's a bad idea let _ = self.config.set_member(&self.status_acct, false); self.add_reply("You're no longer a group member. Unfollow the group user to stop receiving group messages."); } self.unfollow_user_by_id(&self.status_user_id).await .log_error("Failed to unfollow"); } async fn cmd_join(&mut self) { if self.config.is_member_or_admin(&self.status_acct) { debug!("Already member or admin, try to follow-back again"); // Already a member, so let's try to follow the user // again, maybe first time it failed self.follow_user_by_id(&self.status_user_id).await .log_error("Failed to follow"); } else { // Not a member yet if self.config.is_member_only() { // No you can't self.add_reply("\ Sorry, this group is closed to new sign-ups.\n\ Please ask one of the group admins to add you:"); self.append_admin_list_to_reply(); } else { // Open access, try to follow back self.follow_user_by_id(&self.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(&self.status_acct, true); self.add_reply("\ Welcome to the group! The group user will now follow you to complete the sign-up. \ Make sure you follow back to receive shared posts!\n\n\ Use /help for more info."); } } } async fn cmd_ping(&mut self) { self.add_reply(format!("pong, this is fedigroups service v{}", env!("CARGO_PKG_VERSION"))); } async fn unfollow_by_acct(&self, acct: &str) -> Result<(), GroupError> { // Try to unfollow if let Ok(Some(id)) = self.lookup_acct_id(acct, true).await { self.unfollow_user_by_id(&id).await?; } Ok(()) } async fn follow_by_acct(&self, acct: &str) -> Result<(), GroupError> { // Try to unfollow if let Ok(Some(id)) = self.lookup_acct_id(acct, false).await { self.follow_user_by_id(&id).await?; } Ok(()) } /// Convert ID to account, checking if the user is boostable async fn id_to_acct_check_boostable(&self, id: &str) -> Result { // Try to unfollow let account = self.client.get_account(id).await?; let bio = utils::strip_html(&account.note); if RE_NOBOT_TAG.is_match(&bio) { // #nobot Err(GroupError::UserOptedOut) } else { let normalized = normalize_acct(&account.acct, &self.group_acct)?; if self.config.is_banned(&normalized) { return Err(GroupError::UserIsBanned); } else if self.config.is_optout(&normalized) { return Err(GroupError::UserOptedOut); } else { Ok(normalized) } } } } fn apply_trailing_hashtag_pleroma_bug_workaround(msg: &mut String) { 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(" ."); } } fn smart_split(msg : &str, prefix: Option, limit: usize) -> Vec { let prefix = prefix.unwrap_or_default(); if msg.len() + prefix.len() < limit { return vec![format!("{}{}", prefix, msg)]; } let mut parts_to_send = vec![]; let mut this_piece = prefix.clone(); for l in msg.split("\n") { if this_piece.len() + l.len() == limit { this_piece.push_str(l); parts_to_send.push(std::mem::take(&mut this_piece).trim().to_owned()); this_piece.push_str(&prefix); } else if this_piece.len() + l.len() > limit { let trimmed = this_piece.trim(); if !trimmed.is_empty() { parts_to_send.push(trimmed.to_owned()); } // start new piece this_piece = format!("{}{}", prefix, l); while this_piece.len() > limit { let to_send = if let Some(last_space) = (&this_piece[..limit]).rfind(' ') { let mut p = this_piece.split_off(last_space); std::mem::swap(&mut p, &mut this_piece); p } else { let mut p = this_piece.split_off(limit); std::mem::swap(&mut p, &mut this_piece); p }; parts_to_send.push(to_send); this_piece = format!("{}{}", prefix, this_piece); } this_piece.push('\n'); } else { this_piece.push_str(l); this_piece.push('\n'); } } parts_to_send } #[cfg(test)] mod test { #[test] fn test_smart_split1() { let to_split = "a234567890\nb234567890\nc234567890\nd234\n67890\ne234567890\n"; let parts = super::smart_split(to_split, None, 10); assert_eq!(vec![ "a234567890".to_string(), "b234567890".to_string(), "c234567890".to_string(), "d234\n67890".to_string(), "e234567890".to_string(), ], parts); } #[test] fn test_smart_split2() { let to_split = "foo\nbar\nbaz"; let parts = super::smart_split(to_split, None, 1000); assert_eq!(vec![ "foo\nbar\nbaz".to_string(), ], parts); } #[test] fn test_smart_split3() { let to_split = "foo\nbar\nbaz"; let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 1000); assert_eq!(vec![ "PREFIXfoo\nbar\nbaz".to_string(), ], parts); } #[test] fn test_smart_split4() { let to_split = "1234\n56\n7"; let parts = super::smart_split(to_split, Some("PREFIX".to_string()), 10); assert_eq!(vec![ "PREFIX1234".to_string(), "PREFIX56\n7".to_string(), ], parts); } }