Skip to content

Commit

Permalink
Merge pull request #27 from perpetualcacophony/nortverse
Browse files Browse the repository at this point in the history
nortverse commands
  • Loading branch information
perpetualcacophony authored Aug 12, 2024
2 parents 91e142c + 28e9989 commit 6cf0d54
Show file tree
Hide file tree
Showing 17 changed files with 1,237 additions and 4 deletions.
5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ poise = "0.6.1"
rand = "0.8.5"
regex = "1.10.3"
reqwest = { version = "0.11.18", default-features = true, features = ["json"] }
scraper = { version = "0.17.1", default-features = false }
scraper = { version = "0.20", default-features = false }
serde = { version = "1.0.*", default-features = false }
serde_json = { version = "1.0.*", default-features = false }
thiserror = "1.0.57"
Expand Down Expand Up @@ -64,5 +64,6 @@ built = { version = "0.7.4", features = ["git2"] }
git2 = { version = "0.19", features = ["vendored-libgit2"] }

[features]
default = ["wordle"]
default = ["wordle", "nortverse"]
wordle = ["dep:kwordle"]
nortverse = []
6 changes: 6 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ macro_rules! list {
#[cfg(feature = "wordle")]
vec.push(wordle());

#[cfg(feature = "nortverse")]
vec.push(nortverse::nortverse());

vec
}
};
Expand Down Expand Up @@ -49,6 +52,9 @@ pub mod wordle;
#[cfg(feature = "wordle")]
use wordle::wordle;

#[cfg(feature = "nortverse")]
pub mod nortverse;

trait LogCommands {
async fn log_command(&self);
}
Expand Down
138 changes: 138 additions & 0 deletions src/commands/nortverse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use crate::{
utils::poise::{CommandResult, ContextExt},
Context,
};

mod data;

mod error;
pub use error::NortverseError as Error;

mod comic;

mod client;
pub use client::Nortverse;

mod response;

#[tracing::instrument(skip_all)]
#[poise::command(
slash_command,
prefix_command,
discard_spare_arguments,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL",
subcommands("latest", "subscribe", "unsubscribe", "random")
)]
pub async fn nortverse(ctx: Context<'_>) -> crate::Result<()> {
latest_inner(ctx).await
}

#[tracing::instrument(skip_all)]
#[poise::command(
slash_command,
prefix_command,
discard_spare_arguments,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
pub async fn latest(ctx: Context<'_>) -> crate::Result<()> {
latest_inner(ctx).await
}

async fn latest_inner(ctx: Context<'_>) -> crate::Result<()> {
let result: CommandResult = try {
let _broadcast = ctx.defer_or_broadcast().await?;

let response = ctx
.data()
.nortverse()
.latest_comic()
.await?
.builder()
.in_guild(ctx.guild_id().is_some())
.build_reply(ctx.http())
.await?
.reply(true);

ctx.send_ext(response).await?;
};

result?;
Ok(())
}

#[tracing::instrument(skip_all)]
#[poise::command(
slash_command,
prefix_command,
discard_spare_arguments,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
pub async fn subscribe(ctx: Context<'_>) -> crate::Result<()> {
let result: CommandResult = try {
let _broadcast = ctx.defer_or_broadcast().await?;

ctx.data()
.nortverse()
.add_subscriber(ctx.author().id)
.await?;

ctx.reply_ephemeral("you'll be notified whenever a new comic is posted!\n`..nortverse unsubscribe` to unsubscribe")
.await?;
};

result?;
Ok(())
}

#[tracing::instrument(skip_all)]
#[poise::command(
slash_command,
prefix_command,
discard_spare_arguments,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
pub async fn unsubscribe(ctx: Context<'_>) -> crate::Result<()> {
let result: CommandResult = try {
let _broadcast = ctx.defer_or_broadcast().await?;

ctx.data()
.nortverse()
.remove_subscriber(ctx.author().id)
.await?;

ctx.reply_ephemeral("you will no longer be notified for new comics.")
.await?;
};

result?;
Ok(())
}

#[tracing::instrument(skip_all)]
#[poise::command(
slash_command,
prefix_command,
discard_spare_arguments,
required_bot_permissions = "SEND_MESSAGES | VIEW_CHANNEL"
)]
pub async fn random(ctx: Context<'_>) -> crate::Result<()> {
let result: CommandResult = try {
let _broadcast = ctx.defer_or_broadcast().await?;

let nortverse = ctx.data().nortverse();

let response = nortverse
.random_comic()
.await?
.builder()
.in_guild(ctx.guild_id().is_some())
.build_reply(ctx.http())
.await?
.reply(true);

ctx.send_ext(response).await?;
};

result?;
Ok(())
}
201 changes: 201 additions & 0 deletions src/commands/nortverse/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
use std::sync::Arc;

use poise::serenity_prelude as serenity;
use tracing_unwrap::ResultExt;

use crate::utils::poise::CommandResult;

use super::{
comic::ComicPage,
data::{self, NortverseDataAsync},
Error,
};

type Result<T, E = Error> = std::result::Result<T, E>;

#[derive(Debug)]
pub struct Nortverse<Data = data::MongoDb> {
data: std::sync::Arc<tokio::sync::RwLock<Data>>,
client: reqwest::Client,
}

impl Nortverse {
pub fn from_database(db: &mongodb::Database) -> Self {
Self::new(data::MongoDb::from_database(db))
}

#[tracing::instrument(skip_all)]
pub async fn subscribe_action(
&self,
cache: Arc<serenity::Cache>,
http: Arc<serenity::Http>,
) -> CommandResult {
tracing::info!("checking for new comic");

try {
let (comic, updated, old_slug) = self.refresh_latest().await?;

if updated {
tracing::info!(comic.slug = comic.slug(), old.slug = ?old_slug, "new comic found");

let message = {
comic
.builder()
.in_guild(false)
.include_date(false)
.subscribed(true)
.build_message(&http)
.await?
};

for subscriber in self.subscribers().await? {
let message = message.clone();
let cache = cache.clone();
let http = http.clone();

tracing::trace!(user.id = %subscriber, "messaging subscriber");

use crate::utils::serenity::UserIdExt;

tokio::spawn(async move {
subscriber
.dm_ext((&cache, http.as_ref()), message.clone())
.await
.expect_or_log("failed to send message, skipping...");
});
}
} else {
tracing::trace!("no new comic found")
}
}
}

#[tracing::instrument(skip_all)]
pub fn subscribe_task(self, cache: Arc<serenity::Cache>, http: Arc<serenity::Http>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_mins(60));

loop {
interval.tick().await;

self.subscribe_action(cache.clone(), http.clone())
.await
.expect_or_log("failed to run subscribe task");
}
});
}
}

impl<T> Clone for Nortverse<T> {
fn clone(&self) -> Self {
Self {
data: self.data.clone(),
client: self.client.clone(),
}
}
}

impl<Data> Nortverse<Data> {
fn new(data: Data) -> Self {
Self {
data: std::sync::Arc::new(tokio::sync::RwLock::new(data)),
client: reqwest::Client::new(),
}
}

async fn data(&self) -> tokio::sync::RwLockReadGuard<Data> {
self.data.read().await
}

async fn data_mut(&self) -> tokio::sync::RwLockWriteGuard<Data> {
self.data.write().await
}

fn client(&self) -> &reqwest::Client {
&self.client
}

pub async fn random_comic(&self) -> Result<ComicPage> {
Ok(ComicPage::random(self.client()).await?)
}

pub async fn latest_comic(&self) -> Result<ComicPage> {
Ok(ComicPage::from_homepage(self.client()).await?)
}
}

impl<Data> Nortverse<Data>
where
Data: NortverseDataAsync,
{
#[tracing::instrument(skip_all)]
pub async fn refresh_latest(&self) -> Result<(ComicPage, bool, Option<String>)> {
let latest = self.latest_comic().await?;

let data_slug = {
let data = self.data().await;
let data_slug = data.latest_slug().await.map_err(Error::data)?;
data_slug.map(|as_ref| as_ref.as_ref().to_owned())
};

let updated = Some(latest.slug()) != data_slug.as_deref();

if updated {
self.data_mut()
.await
.set_latest(latest.slug().to_owned())
.await
.map_err(Error::data)?;

tracing::info!(slug = %latest.slug(), "updated latest comic")
}

Ok((latest, updated, data_slug))
}

#[tracing::instrument(skip_all, fields(id))]
pub async fn add_subscriber(&self, id: serenity::UserId) -> Result<()> {
let mut data = self.data_mut().await;

if data.contains_subscriber(&id).await.map_err(Error::data)? {
tracing::warn!(user.id = %id, "user already subscribed");

Err(Error::already_subscribed(id))
} else {
data.add_subscriber(id).await.map_err(Error::data)?;

tracing::info!(user.id = %id, "added subscriber");

Ok(())
}
}

pub async fn remove_subscriber(&self, id: serenity::UserId) -> Result<()> {
let mut data = self.data_mut().await;

if data.contains_subscriber(&id).await.map_err(Error::data)? {
data.remove_subscriber(id).await.map_err(Error::data)?;

tracing::info!(user.id = %id, "removed subscriber");

Ok(())
} else {
tracing::warn!(user.id = %id, "user not subscribed");

Err(Error::not_subscribed(id))
}
}

async fn subscribers(&self) -> Result<impl Iterator<Item = serenity::UserId>> {
let data = self.data().await;

let vec: Vec<serenity::UserId> = data
.subscribers()
.await
.map_err(Error::data)?
.into_iter()
.collect();

Ok(vec.into_iter())
}
}
Loading

0 comments on commit 6cf0d54

Please sign in to comment.