From 04186575e2f6c7a4cd31d83dedb471dcd594e538 Mon Sep 17 00:00:00 2001 From: ThatsNoMoon Date: Wed, 15 Feb 2023 12:30:21 -0700 Subject: [PATCH] add time-based notification clearing --- Cargo.lock | 2 +- Cargo.toml | 2 +- example_config.toml | 6 +- src/bot/commands/mod.rs | 6 +- src/bot/highlighting.rs | 131 +++++++++++++++++++++++++++++++--------- src/bot/mod.rs | 9 ++- src/db/notification.rs | 40 +++++++++++- src/settings.rs | 9 +++ 8 files changed, 166 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6372455..857051a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,7 +702,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "highlights" -version = "2.1.4" +version = "2.1.5" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index ae2d425..cad17ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "highlights" -version = "2.1.4" +version = "2.1.5" authors = ["ThatsNoMoon "] repository = "https://github.com/ThatsNoMoon/highlights" license = "OSL-3.0" diff --git a/example_config.toml b/example_config.toml index 6a526df..1ca0eaf 100644 --- a/example_config.toml +++ b/example_config.toml @@ -15,7 +15,11 @@ max_keywords = 100 # Amount of time to wait for activity before sending a notification # Other examples: "1m 30sec", "5minutes" # See https://docs.rs/humantime/latest/humantime/fn.parse_duration.html for complete list -patience = "2m" +patience = "2min" +# Amount of time to leave notifications visible +# This uses the same format as patience +# Other examples: "1y", "90d", "1M" (one month) +#patience_lifetime = "1month" [logging] # Discord webhook to send errors and panics to diff --git a/src/bot/commands/mod.rs b/src/bot/commands/mod.rs index ab6d479..688f2b9 100644 --- a/src/bot/commands/mod.rs +++ b/src/bot/commands/mod.rs @@ -49,7 +49,7 @@ use crate::{ }; // Create all slash commands globally, and in a test guild if configured. -pub(crate) async fn create_commands(ctx: Context) -> Result<()> { +pub(crate) async fn create_commands(ctx: &Context) -> Result<()> { debug!("Registering slash commands"); let commands = COMMAND_INFO .iter() @@ -59,13 +59,13 @@ pub(crate) async fn create_commands(ctx: Context) -> Result<()> { debug!("Registering commands in test guild"); guild - .set_application_commands(&ctx, |create| { + .set_application_commands(ctx, |create| { create.set_application_commands(commands.clone()) }) .await .context("Failed to create guild application commands")?; } - ApplicationCommand::set_global_application_commands(&ctx, |create| { + ApplicationCommand::set_global_application_commands(ctx, |create| { create.set_application_commands(commands) }) .await diff --git a/src/bot/highlighting.rs b/src/bot/highlighting.rs index 0a26954..c10bdb8 100644 --- a/src/bot/highlighting.rs +++ b/src/bot/highlighting.rs @@ -1,18 +1,22 @@ -// Copyright 2022 ThatsNoMoon +// Copyright 2023 ThatsNoMoon // Licensed under the Open Software License version 3.0 //! Functions for sending, editing, and deleting notifications. -use std::{collections::HashMap, fmt::Write as _, ops::Range, time::Duration}; +use std::{ + cmp::min, collections::HashMap, fmt::Write as _, ops::Range, time::Duration, +}; -use anyhow::{anyhow, bail, Context as _, Result}; -use futures_util::{stream, StreamExt, TryStreamExt}; +use anyhow::{anyhow, bail, Context as _, Error, Result}; +use futures_util::{ + stream, stream::FuturesUnordered, StreamExt, TryFutureExt, TryStreamExt, +}; use indoc::indoc; use lazy_regex::regex; use serenity::{ builder::{CreateEmbed, CreateMessage, EditMessage}, client::Context, - http::{error::ErrorResponse, HttpError}, + http::{error::ErrorResponse, HttpError, StatusCode}, model::{ application::interaction::application_command::ApplicationCommandInteraction as Command, channel::{Channel, Message}, @@ -22,8 +26,11 @@ use serenity::{ Error as SerenityError, }; use tinyvec::TinyVec; -use tokio::{select, time::sleep}; -use tracing::{debug, error}; +use tokio::{ + select, + time::{interval, sleep}, +}; +use tracing::{debug, error, info_span}; use crate::{ bot::util::{followup_eph, user_can_read_channel}, @@ -191,7 +198,7 @@ pub(crate) async fn notify_keywords( message.content = content; let keywords = stream::iter(keywords) - .map(Ok::<_, anyhow::Error>) // convert to a TryStream + .map(Ok::<_, Error>) // convert to a TryStream .try_filter_map(|keyword| async { Ok(should_notify_keyword( &ctx, @@ -473,34 +480,44 @@ async fn send_notification_message( /// Deletes the given notification messages sent to the corresponding users. #[tracing::instrument(skip(ctx))] -pub(crate) async fn delete_sent_notifications( +pub(crate) async fn clear_sent_notifications( ctx: &Context, notification_messages: &[(UserId, MessageId)], ) { - for (user_id, message_id) in notification_messages { - let result: Result<()> = async { - let dm_channel = user_id.create_dm_channel(ctx).await?; - - dm_channel - .edit_message(ctx, message_id, |m| { - m.embed(|e| { - e.description("*Original message deleted*") - .color(ERROR_COLOR) - }) - }) - .await - .context("Failed to edit notification message")?; - - Ok(()) - } - .await; - - if let Err(e) = result { + for &(user_id, message_id) in notification_messages { + if let Err(e) = clear_sent_notification( + ctx, + user_id, + message_id, + "*Original message deleted*", + ) + .await + { error!("{:?}", e); } } } +/// Replaces the given direct message with the given placeholder. +#[tracing::instrument(skip(ctx, placeholder))] +async fn clear_sent_notification( + ctx: &Context, + user_id: UserId, + message_id: MessageId, + placeholder: impl ToString, +) -> Result<()> { + let dm_channel = user_id.create_dm_channel(ctx).await?; + + dm_channel + .edit_message(ctx, message_id, |m| { + m.embed(|e| e.description(placeholder).color(ERROR_COLOR)) + }) + .await + .context("Failed to edit notification message")?; + + Ok(()) +} + /// Updates sent notifications after a message edit. /// /// Edits the content of each notification to reflect the new content of the @@ -576,7 +593,7 @@ pub(crate) async fn update_sent_notifications( } } - delete_sent_notifications(ctx, &to_delete).await; + clear_sent_notifications(ctx, &to_delete).await; for (_, notification_message) in to_delete { if let Err(e) = @@ -703,6 +720,62 @@ pub(crate) async fn warn_for_failed_dm( .await } +pub(super) fn start_notification_clearing(ctx: Context) { + if let Some(lifetime) = settings().behavior.notification_lifetime { + debug!("Starting notification clearing"); + tokio::spawn(async move { + let span = info_span!(parent: None, "notification_clearing"); + let _entered = span.enter(); + let step = min(lifetime / 2, Duration::from_secs(60 * 60)); + let mut timer = interval(step); + loop { + if let Err(e) = clear_old_notifications(&ctx, lifetime).await { + error!("Failed to clear old notifications: {e}\n{e:?}"); + } + timer.tick().await; + } + }); + } +} + +async fn clear_old_notifications( + ctx: &Context, + lifetime: Duration, +) -> Result<()> { + debug!("Clearing old notifications"); + Notification::old_notifications(lifetime) + .await? + .into_iter() + .map(|notification| { + clear_sent_notification( + ctx, + notification.user_id, + notification.notification_message, + "*Notification expired*", + ) + .or_else(|e| async move { + match e.downcast_ref::() { + Some(SerenityError::Http(inner)) => match &**inner { + HttpError::UnsuccessfulRequest(ErrorResponse { + status_code: StatusCode::NOT_FOUND, + .. + }) => Ok(()), + + _ => Err(e), + }, + _ => Err(e), + } + }) + }) + .collect::>() + .try_for_each(|_| async { Ok(()) }) + .await?; + + Notification::delete_old_notifications(lifetime).await?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/bot/mod.rs b/src/bot/mod.rs index ad0263b..1446600 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -45,6 +45,7 @@ use tracing::{ use self::highlighting::CachedMessages; use crate::{ + bot::highlighting::start_notification_clearing, db::{Ignore, Keyword, Notification}, global::ERROR_COLOR, settings::settings, @@ -140,12 +141,14 @@ async fn ready(ctx: Context) { ctx.set_activity(Activity::listening("/help")).await; - if let Err(e) = commands::create_commands(ctx).await { + if let Err(e) = commands::create_commands(&ctx).await { error!("{e}\n{e:?}"); } let _ = STARTED.set(Instant::now()); + start_notification_clearing(ctx); + info!("Ready to highlight!"); } @@ -230,7 +233,7 @@ async fn handle_update( } /// Finds notifications for a deleted message and uses -/// [`delete_sent_notifications`](highlighting::delete_sent_notifications) to +/// [`delete_sent_notifications`](highlighting::clear_sent_notifications) to /// delete them. async fn handle_deletion( ctx: Context, @@ -273,7 +276,7 @@ async fn handle_deletion( return; } - highlighting::delete_sent_notifications(&ctx, ¬ifications).await; + highlighting::clear_sent_notifications(&ctx, ¬ifications).await; if let Err(e) = Notification::delete_notifications_of_message(message_id).await diff --git a/src/db/notification.rs b/src/db/notification.rs index 62de8ad..b612c5a 100644 --- a/src/db/notification.rs +++ b/src/db/notification.rs @@ -1,8 +1,10 @@ -// Copyright 2022 ThatsNoMoon +// Copyright 2023 ThatsNoMoon // Licensed under the Open Software License version 3.0 //! Handling for sent notification messages. +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + use anyhow::Result; use futures_util::TryStreamExt; use sea_orm::{ @@ -16,6 +18,8 @@ use serenity::model::id::{MessageId, UserId}; use super::{connection, DbInt, IdDbExt}; +const DISCORD_EPOCH: u64 = 1420070400000; + #[derive( Clone, Debug, PartialEq, Eq, DeriveEntityModel, DeriveActiveModelBehavior, )] @@ -104,6 +108,40 @@ impl Notification { Ok(()) } + + /// Gets notifications older than a certain duration from the DB. + #[tracing::instrument] + pub(crate) async fn old_notifications( + age: Duration, + ) -> Result> { + Entity::find() + .filter(Column::OriginalMessage.lte(age_to_oldest_snowflake(age)?)) + .stream(connection()) + .await? + .map_err(Into::into) + .map_ok(Notification::from) + .try_collect() + .await + } + + /// Deletes notifications older than a certain duration from the DB. + #[tracing::instrument] + pub(crate) async fn delete_old_notifications(age: Duration) -> Result<()> { + Entity::delete_many() + .filter(Column::OriginalMessage.lte(age_to_oldest_snowflake(age)?)) + .exec(connection()) + .await?; + + Ok(()) + } +} + +fn age_to_oldest_snowflake(age: Duration) -> Result { + let millis = age.as_millis() as u64; + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64; + let oldest_unix = now - millis; + let oldest_discord = oldest_unix - DISCORD_EPOCH; + Ok(oldest_discord << 22) } impl From for Notification { diff --git a/src/settings.rs b/src/settings.rs index cf6f042..3a99dcd 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -258,6 +258,15 @@ pub(crate) struct BehaviorSettings { #[cfg(feature = "bot")] pub(crate) patience: Duration, + /// Duration to wait before deleting notifications. + #[serde( + alias = "notificationlifetime", + with = "humantime_serde::option", + default + )] + #[cfg(feature = "bot")] + pub(crate) notification_lifetime: Option, + /// Deprecated method to specify patience. #[serde( deserialize_with = "deserialize_duration",