Skip to content
This repository has been archived by the owner on Mar 24, 2024. It is now read-only.

Commit

Permalink
add time-based notification clearing
Browse files Browse the repository at this point in the history
  • Loading branch information
ThatsNoMoon committed Feb 15, 2023
1 parent e673e91 commit 0418657
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 39 deletions.
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "highlights"
version = "2.1.4"
version = "2.1.5"
authors = ["ThatsNoMoon <git@thatsnomoon.dev>"]
repository = "https://github.com/ThatsNoMoon/highlights"
license = "OSL-3.0"
Expand Down
6 changes: 5 additions & 1 deletion example_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions src/bot/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
131 changes: 102 additions & 29 deletions src/bot/highlighting.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand All @@ -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},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) =
Expand Down Expand Up @@ -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::<SerenityError>() {
Some(SerenityError::Http(inner)) => match &**inner {
HttpError::UnsuccessfulRequest(ErrorResponse {
status_code: StatusCode::NOT_FOUND,
..
}) => Ok(()),

_ => Err(e),
},
_ => Err(e),
}
})
})
.collect::<FuturesUnordered<_>>()
.try_for_each(|_| async { Ok(()) })
.await?;

Notification::delete_old_notifications(lifetime).await?;

Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
9 changes: 6 additions & 3 deletions src/bot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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!");
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -273,7 +276,7 @@ async fn handle_deletion(
return;
}

highlighting::delete_sent_notifications(&ctx, &notifications).await;
highlighting::clear_sent_notifications(&ctx, &notifications).await;

if let Err(e) =
Notification::delete_notifications_of_message(message_id).await
Expand Down
40 changes: 39 additions & 1 deletion src/db/notification.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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,
)]
Expand Down Expand Up @@ -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<Vec<Notification>> {
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<u64> {
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<Model> for Notification {
Expand Down
9 changes: 9 additions & 0 deletions src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration>,

/// Deprecated method to specify patience.
#[serde(
deserialize_with = "deserialize_duration",
Expand Down

0 comments on commit 0418657

Please sign in to comment.