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 {