use std::collections::HashSet; use std::time::Duration; use elefren::{FediClient, SearchType, StatusBuilder}; use elefren::entities::prelude::Status; use elefren::status_builder::Visibility; use crate::command::StatusCommand; use crate::error::GroupError; use crate::group_handler::GroupHandle; use crate::store::data::GroupConfig; use crate::utils::{LogError, normalize_acct}; pub struct ProcessMention<'a> { status: Status, 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(a.to_string()); } } 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 { m.to_string() }); } } 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 { 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 ToString) { self.replies.push(line.to_string()) } fn add_announcement(&mut self, line: impl ToString) { self.announcements.push(line.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::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::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_member(&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 { self.client .reblog(self.status.in_reply_to_id.as_ref().unwrap()) .await .log_error("Failed to boost"); } if !self.replies.is_empty() { debug!("replies={:?}", self.replies); let r = self.replies.join("\n"); debug!("r={}", r); if let Ok(post) = StatusBuilder::new() .status(format!("@{user}\n{msg}", user = self.status_acct, msg = r)) .content_type(if self.want_markdown { "text/markdown" } else { "text/plain" }) .visibility(self.status.visibility) // Copy visibility .build() { let _ = self.client.new_status(post) .await.log_error("Failed to post"); } } if !self.announcements.is_empty() { let msg = self.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"); } 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 { self.add_reply("You are not allowed to post to this group"); } } 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 { self.add_reply("You are not allowed to share to this group"); } } 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("Only admins can manage user bans"); } 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("Only admins can manage user bans"); } 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_announcement(format!("Server \"{}\" has been banned.", s)); 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("Only admins can manage server bans"); } } 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_announcement(format!("Server \"{}\" has been un-banned.", s)); self.add_reply(format!("Server {} un-banned!", s)); } Err(_) => { unreachable!() } } } } else { self.add_reply("Only admins can manage server bans"); } } async fn cmd_add_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, 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 { self.add_reply("Only admins can manage members"); } 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(_) => { unreachable!() } } } else { self.add_reply("Only admins can manage members"); } Ok(()) } async fn cmd_add_tag(&mut self, tag: String) { if self.is_admin { self.config.add_tag(&tag); self.add_reply(format!("Tag #{} added to the group!", tag)); } else { self.add_reply("Only admins can manage group tags"); } } async fn cmd_remove_tag(&mut self, tag: String) { if self.is_admin { self.config.remove_tag(&tag); self.add_reply(format!("Tag #{} removed from the group!", tag)); } else { self.add_reply("Only admins can manage group tags"); } } async fn cmd_grant_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, 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)); self.add_announcement(format!("User @{} can now manage this group!", u)); } Err(e) => { self.add_reply(format!( "Failed to make user {} a group admin: {}", u, e )); } } } } else { self.add_reply("Only admins can manage admins"); } 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)); self.add_announcement(format!("User @{} no longer manages this group.", u)); } Err(e) => { self.add_reply(format!("Failed to revoke {}'s group admin: {}", u, e)); } } } } else { self.add_reply("Only admins can manage admins"); } 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"); self.add_announcement("This group is now open-access!"); } } else { self.add_reply("Only admins can set group options"); } } 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"); self.add_announcement("This group is now member-only!"); } } else { self.add_reply("Only admins can set group options"); } } async fn cmd_help(&mut self) { self.want_markdown = true; if self.config.is_member_only() { self.add_reply("This is a member-only group. "); } 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) { self.add_reply("*You are a member.*"); } else if self.config.is_member_only() { self.add_reply("*You are not a member, ask one of the admins to add you.*"); } else { self.add_reply("*You are not a member, follow or use /join to join the group.*"); } self.add_reply("\n\ To share a post, mention the group user or use one of the group hashtags. \ 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 ignore the post\n\ `/ping` - check the service is alive\n\ `/tags` - show group hashtags\n\ `/join` - join the group\n\ `/leave` - leave the group"); if self.config.is_member_only() { self.add_reply("`/members`, `/who` - show group members / admins"); } else { self.add_reply("`/members`, `/who` - show group admins"); } if self.is_admin { self.add_reply("\n\ **Admin commands:**\n\ `/add user` - add a member (use e-mail style address)\n\ `/remove user` - remove a member\n\ `/add #hashtag` - add a group hashtag\n\ `/remove #hashtag` - remove a group hashtag\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\ `/opengroup` - make member-only\n\ `/closegroup` - make public-access\n\ `/announce x` - make a public announcement from the rest of the status (without formatting)"); } } async fn cmd_list_members(&mut self) { 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:"); 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(()) } }