diff --git a/Cargo.toml b/Cargo.toml index 3cfece0..4fe75a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "topgg" -version = "2.0.0" +version = "1.5.0" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] description = "A simple API wrapper for Top.gg written in Rust." @@ -22,21 +22,21 @@ urlencoding = "2" serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } -twilight-http = { version = "0.15", optional = true } twilight-model = { version = "0.15", optional = true } twilight-cache-inmemory = { version = "0.15", optional = true } -chrono = { version = "0.4", default-features = false, optional = true, features = ["serde", "now"] } +chrono = { version = "0.4", default-features = false, optional = true, features = ["serde"] } serde_json = { version = "1", optional = true } rocket = { version = "0.5", default-features = false, features = ["json"], optional = true } axum = { version = "0.8", default-features = false, optional = true, features = ["http1", "tokio"] } async-trait = { version = "0.1", optional = true } -warp = { version = "0.3", default-features = false, optional = true } -actix-web = { version = "4", default-features = false, optional = true } +warp = { version = "0.4", default-features = false, features = ["server"], optional = true } +actix-web = { version = "4", default-features = false, features = ["macros"], optional = true } [dev-dependencies] -tokio = { version = "1", features = ["rt", "macros"] } +reqwest = "0.12" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } twilight-gateway = "0.15" [lints.clippy] @@ -63,18 +63,19 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["async-trait", "base64", "chrono", "reqwest", "serde_json"] -bot-autoposter = ["api", "tokio"] -autoposter = ["bot-autoposter"] +api = ["base64", "chrono", "reqwest", "serde_json"] +autoposter = ["api", "tokio"] -serenity = ["dep:serenity", "paste"] +serenity = ["dep:serenity", "async-trait", "paste"] serenity-cached = ["serenity", "serenity/cache"] -twilight = ["twilight-model", "twilight-http"] +twilight = ["twilight-model"] twilight-cached = ["twilight", "twilight-cache-inmemory"] -webhooks = [] -rocket = ["webhooks", "dep:rocket"] -axum = ["webhooks", "async-trait", "serde_json", "dep:axum"] -warp = ["webhooks", "async-trait", "dep:warp"] -actix-web = ["webhooks", "dep:actix-web"] \ No newline at end of file +webhook = [] +rocket = ["webhook", "async-trait", "dep:rocket"] +axum = ["webhook", "async-trait", "serde_json", "dep:axum"] +warp = ["webhook", "async-trait", "dep:warp"] +actix-web = ["webhook", "dep:actix-web"] + +_internal-doctest = [] \ No newline at end of file diff --git a/README.md b/README.md index a258444..2cd08d2 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ -# [topgg](https://crates.io/crates/topgg) [![crates.io][crates-io-image]][crates-io-url] [![crates.io downloads][crates-io-downloads-image]][crates-io-url] +# [topgg](https://crates.io/crates/topgg) [![crates.io][crates-io-image]][crates-io-url] [![crates.io downloads][crates-io-downloads-image]][crates-io-url] [![license][github-license-image]][github-license-url] [![BLAZINGLY FAST!!!][blazingly-fast-image]][blazingly-fast-url] [crates-io-image]: https://img.shields.io/crates/v/topgg?style=flat-square [crates-io-downloads-image]: https://img.shields.io/crates/d/topgg?style=flat-square [crates-io-url]: https://crates.io/crates/topgg - +[github-license-image]: https://img.shields.io/github/license/top-gg/rust-sdk?style=flat-square +[github-license-url]: https://github.com/top-gg/rust-sdk/blob/main/LICENSE +[blazingly-fast-image]: https://img.shields.io/badge/speed-BLAZINGLY%20FAST!!!%20%F0%9F%94%A5%F0%9F%9A%80%F0%9F%92%AA%F0%9F%98%8E-brightgreen.svg?style=flat-square +[blazingly-fast-url]: https://twitter.com/acdlite/status/974390255393505280 The official Rust SDK for the [Top.gg API](https://docs.top.gg). ## Getting Started @@ -11,30 +14,31 @@ The official Rust SDK for the [Top.gg API](https://docs.top.gg). Make sure to have a [Top.gg API](https://docs.top.gg) token handy. If not, then [view this tutorial on how to retrieve yours](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). After that, add the following line to the `dependencies` section of your `Cargo.toml`: ```toml -topgg = "1.4" +topgg = "1.5" ``` -For more information, please read [the documentation](https://docs.rs/topgg)! - ## Features This library provides several feature flags that can be enabled/disabled in `Cargo.toml`. Such as: - **`api`**: Interacting with the [Top.gg API](https://docs.top.gg) and accessing the `top.gg/api/*` endpoints. (enabled by default) - **`autoposter`**: Automating the process of periodically posting bot statistics to the [Top.gg API](https://docs.top.gg). -- **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Vote` struct. - - **`actix-web`**: Wrapper for working with the [actix-web](https://actix.rs/) web framework. - - **`axum`**: Wrapper for working with the [axum](https://crates.io/crates/axum) web framework. - - **`rocket`**: Wrapper for working with the [rocket](https://rocket.rs/) web framework. - - **`warp`**: Wrapper for working with the [warp](https://crates.io/crates/warp) web framework. -- **`serenity`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching disabled). - - **`serenity-cached`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching enabled). -- **`twilight`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching disabled). - - **`twilight-cached`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching enabled). +- **`webhook`**: Accessing the [`serde` deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Vote` struct. + - **`actix-web`**: Wrapper for working with the [`actix-web`](https://actix.rs/) web framework. + - **`axum`**: Wrapper for working with the [`axum`](https://crates.io/crates/axum) web framework. + - **`rocket`**: Wrapper for working with the [`rocket`](https://rocket.rs/) web framework. + - **`warp`**: Wrapper for working with the [`warp`](https://crates.io/crates/warp) web framework. +- **`serenity`**: Extra helpers for working with [`serenity`](https://crates.io/crates/serenity) library (with bot caching disabled). + - **`serenity-cached`**: Extra helpers for working with [`serenity`](https://crates.io/crates/serenity) library (with bot caching enabled). +- **`twilight`**: Extra helpers for working with [`twilight`](https://twilight.rs) library (with bot caching disabled). + - **`twilight-cached`**: Extra helpers for working with [`twilight`](https://twilight.rs) library (with bot caching enabled). ## Examples -### Fetching a user from its Discord ID +More things can be read in the [documentation](https://docs.rs/topgg). + +
+api: Fetching a single Discord user from it's Discord ID ```rust,no_run use topgg::Client; @@ -51,7 +55,54 @@ async fn main() { } ``` -### Posting your bot's statistics +
+
+api: Fetching a single Discord bot from it's Discord ID + +```rust,no_run +use topgg::Client; + +#[tokio::main] +async fn main() { + let client = Client::new(env!("TOPGG_TOKEN").to_string()); + let bot = client.get_bot(264811613708746752).await.unwrap(); + + assert_eq!(bot.username, "Luca"); + assert_eq!(bot.discriminator, "1375"); + assert_eq!(bot.id, 264811613708746752); + + println!("{:?}", bot); +} +``` + +
+
+api: Querying several Discord bots + +```rust,no_run +use topgg::Client; + +#[tokio::main] +async fn main() { + let client = Client::new(env!("TOPGG_TOKEN").to_string()); + + let bots = client + .get_bots() + .limit(250) + .skip(50) + .username("shiro") + .certified(true) + .await; + + for bot in bots { + println!("{:?}", bot); + } +} +``` + +
+
+api: Posting your Discord bot's statistics ```rust,no_run use topgg::{Client, Stats}; @@ -68,7 +119,9 @@ async fn main() { } ``` -### Checking if a user has voted your bot +
+
+api: Checking if a user has voted for your Discord bot ```rust,no_run use topgg::Client; @@ -83,17 +136,19 @@ async fn main() { } ``` -### Autoposting with [serenity](https://crates.io/crates/serenity) +
+
+autoposter, serenity: Automating the process of periodically posting your Discord bot's statistics with the serenity library In your `Cargo.toml`: ```toml [dependencies] # using serenity with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "serenity"] } +topgg = { version = "1.5", features = ["autoposter", "serenity"] } # using serenity with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "serenity-cached"] } +topgg = { version = "1.5", features = ["autoposter", "serenity-cached"] } ``` In your code: @@ -125,7 +180,7 @@ async fn main() { let topgg_client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); let autoposter = Autoposter::serenity(&topgg_client, Duration::from_secs(1800)); - let bot_token = env!("DISCORD_TOKEN").to_string(); + let bot_token = env!("BOT_TOKEN").to_string(); let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::GUILDS | GatewayIntents::MESSAGE_CONTENT; let mut client = Client::builder(&bot_token, intents) @@ -140,17 +195,19 @@ async fn main() { } ``` -### Autoposting with [twilight](https://twilight.rs) +
+
+autoposter, twilight: Automating the process of periodically posting your Discord bot's statistics with the twilight library In your `Cargo.toml`: ```toml [dependencies] # using twilight with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "twilight"] } +topgg = { version = "1.5", features = ["autoposter", "twilight"] } # using twilight with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "twilight-cached"] } +topgg = { version = "1.5", features = ["autoposter", "twilight-cached"] } ``` In your code: @@ -167,7 +224,7 @@ async fn main() { let mut shard = Shard::new( ShardId::ONE, - env!("DISCORD_TOKEN").to_string(), + env!("BOT_TOKEN").to_string(), Intents::GUILD_MEMBERS | Intents::GUILDS, ); @@ -196,13 +253,15 @@ async fn main() { } ``` -### Writing an [actix-web](https://actix.rs) webhook for listening to votes +
+
+actix-web: Writing an actix-web webhook for listening to your project's vote events In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["actix-web"] } +topgg = { version = "1.5", default-features = false, features = ["actix-web"] } ``` In your code: @@ -241,13 +300,15 @@ async fn main() -> io::Result<()> { } ``` -### Writing an [axum](https://crates.io/crates/axum) webhook for listening to votes +
+
+axum: Writing an axum webhook for listening to your project's vote events In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["axum"] } +topgg = { version = "1.5", default-features = false, features = ["axum"] } ``` In your code: @@ -288,13 +349,15 @@ async fn main() { } ``` -### Writing a [rocket](https://rocket.rs) webhook for listening to votes +
+
+rocket: Writing a rocket webhook for listening to your project's vote events In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["rocket"] } +topgg = { version = "1.5", default-features = false, features = ["rocket"] } ``` In your code: @@ -333,13 +396,15 @@ fn main() { } ``` -### Writing a [warp](https://crates.io/crates/warp) webhook for listening to votes +
+
+warp: Writing a warp webhook for listening to your project's vote events In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["warp"] } +topgg = { version = "1.5", default-features = false, features = ["warp"] } ``` In your code: @@ -376,3 +441,5 @@ async fn main() { warp::serve(routes).run(addr).await } ``` + +
diff --git a/src/bot_autoposter/client.rs b/src/autoposter/client.rs similarity index 100% rename from src/bot_autoposter/client.rs rename to src/autoposter/client.rs diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs new file mode 100644 index 0000000..8593948 --- /dev/null +++ b/src/autoposter/mod.rs @@ -0,0 +1,467 @@ +use crate::{Result, Stats}; +use std::{ + ops::{Deref, DerefMut}, + sync::Arc, + time::Duration, +}; +use tokio::{ + sync::{mpsc, RwLock, RwLockWriteGuard}, + task::{spawn, JoinHandle}, + time::sleep, +}; + +mod client; + +pub use client::AsClient; +pub(crate) use client::AsClientSealed; + +cfg_if::cfg_if! { + if #[cfg(any(feature = "serenity", feature = "serenity-cached"))] { + mod serenity_impl; + + #[cfg_attr(docsrs, doc(cfg(any(feature = "serenity", feature = "serenity-cached"))))] + pub use serenity_impl::Serenity; + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "twilight", feature = "twilight-cached"))] { + mod twilight_impl; + + #[cfg_attr(docsrs, doc(cfg(any(feature = "twilight", feature = "twilight-cached"))))] + pub use twilight_impl::Twilight; + } +} + +/// A thread-safe form of the [`Stats`] struct to be used in autoposter [`Handler`]s. +pub struct SharedStats { + stats: RwLock, +} + +/// A guard wrapping over tokio's [`RwLockWriteGuard`] that lets you freely feed new [`Stats`] data before being sent to the [`Autoposter`]. +pub struct SharedStatsGuard<'a> { + guard: RwLockWriteGuard<'a, Stats>, +} + +impl SharedStatsGuard<'_> { + /// Directly replaces the current [`Stats`] inside with another. + #[inline(always)] + pub fn replace(&mut self, other: Stats) { + *self.guard = other; + } + + /// Sets the current [`Stats`] server count. + #[inline(always)] + #[cfg_attr(any(test, feature = "_internal-doctest"), allow(unused_variables))] + pub fn set_server_count(&mut self, server_count: usize) { + cfg_if::cfg_if! { + if #[cfg(any(test, feature = "_internal-doctest"))] { + self.guard.server_count = Some(2); + } else { + self.guard.server_count = Some(server_count); + } + } + } + + /// Sets the current [`Stats`] shard count. + #[inline(always)] + pub fn set_shard_count(&mut self, shard_count: usize) { + self.guard.shard_count = Some(shard_count); + } +} + +impl Deref for SharedStatsGuard<'_> { + type Target = Stats; + + #[inline(always)] + fn deref(&self) -> &Self::Target { + &self.guard + } +} + +impl DerefMut for SharedStatsGuard<'_> { + #[inline(always)] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.guard + } +} + +impl SharedStats { + /// Creates a new [`SharedStats`] struct. Before any modifications, the [`Stats`] struct inside defaults to zero server count. + #[inline(always)] + pub fn new() -> Self { + Self { + stats: RwLock::new(Stats::from(0)), + } + } + + /// Locks this [`SharedStats`] with exclusive write access, causing the current task to yield until the lock has been acquired. This is akin to [`RwLock::write`]. + #[inline(always)] + pub async fn write(&self) -> SharedStatsGuard<'_> { + SharedStatsGuard { + guard: self.stats.write().await, + } + } +} + +impl Default for SharedStats { + #[inline(always)] + fn default() -> Self { + Self::new() + } +} + +/// A trait for handling events from third-party Discord Bot libraries. +/// +/// The struct implementing this trait should own an [`SharedStats`] struct and update it accordingly whenever Discord updates them with new data regarding server/shard count. +pub trait Handler: Send + Sync + 'static { + /// The method that borrows [`SharedStats`] to the [`Autoposter`]. + fn stats(&self) -> &SharedStats; +} + +/// Automatically update the stats in your Discord bot's Top.gg page every few minutes. +/// +/// **NOTE**: This struct owns the autoposter thread which means that it will stop once it gets dropped. +#[must_use] +pub struct Autoposter { + handler: Arc, + thread: JoinHandle<()>, + receiver: Option>>, +} + +impl Autoposter +where + H: Handler, +{ + /// Creates and starts an autoposter thread. + #[cfg_attr(any(test, feature = "_internal-doctest"), allow(unused_mut))] + pub fn new(client: &C, handler: H, mut interval: Duration) -> Self + where + C: AsClient, + { + #[cfg(not(any(test, feature = "_internal-doctest")))] + if interval.as_secs() < 900 { + interval = Duration::from_secs(900); + } + + let client = client.as_client(); + let handler = Arc::new(handler); + let (sender, receiver) = mpsc::unbounded_channel(); + + Self { + handler: Arc::clone(&handler), + thread: spawn(async move { + loop { + { + let stats = handler.stats().stats.read().await; + + if sender.send(client.post_stats(&stats).await).is_err() { + break; + } + }; + + sleep(interval).await; + } + }), + receiver: Some(receiver), + } + } + + /// This autoposter's handler. + #[inline(always)] + pub fn handler(&self) -> Arc { + Arc::clone(&self.handler) + } + + /// Returns a future that resolves whenever an attempt to update the stats in your bot's Top.gg page has been made. + /// + /// **NOTE**: If you want to use the receiver directly, call [`receiver`][Autoposter::receiver]. + /// + /// # Panics + /// + /// Panics if this method gets called again after [`receiver`][Autoposter::receiver] is called. + #[inline(always)] + pub async fn recv(&mut self) -> Option> { + self.receiver.as_mut().expect("The receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await + } + + /// Takes the receiver responsible for [`recv`][Autoposter::recv]. + /// + /// # Panics + /// + /// Panics if this method gets called for the second time. + #[inline(always)] + pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { + self + .receiver + .take() + .expect("receiver() can only be called once.") + } +} + +impl Deref for Autoposter { + type Target = H; + + #[inline(always)] + fn deref(&self) -> &Self::Target { + &self.handler + } +} + +#[cfg(any(feature = "serenity", feature = "serenity-cached"))] +#[cfg_attr( + docsrs, + doc(cfg(any(feature = "serenity", feature = "serenity-cached"))) +)] +impl Autoposter { + /// Creates and starts a serenity-based autoposter thread. + /// + /// # Example + /// + /// ```rust + /// use std::time::Duration; + /// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; + /// use topgg::Autoposter; + /// # + /// # use std::sync::Arc; + /// # use tokio::sync::{mpsc, Mutex, Notify}; + /// # + /// # struct SerenityTestContext { + /// # immature_thread_closure: Notify, + /// # autoposter_receiver: Mutex>>, + /// # } + /// # + /// # impl SerenityTestContext { + /// # async fn autoposter_recv(&self) -> Option> { + /// # let mut guard = self.autoposter_receiver.lock().await; + /// # + /// # guard.recv().await + /// # } + /// # } + /// + /// struct AutoposterHandler; + /// + /// #[async_trait::async_trait] + /// impl EventHandler for AutoposterHandler { + /// async fn ready(&self, _: Context, ready: Ready) { + /// println!("{} is now ready!", ready.user.name); + /// } + /// } + /// + /// #[tokio::main] + /// async fn main() { + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// + /// # /* + /// let mut autoposter = Autoposter::serenity(&client, Duration::from_secs(1800)); + /// # */ + /// # let mut autoposter = Autoposter::serenity(&client, Duration::from_secs(2)); + /// # + /// # let local_test_context = Arc::new(SerenityTestContext { + /// # immature_thread_closure: Notify::const_new(), + /// # autoposter_receiver: Mutex::const_new(autoposter.receiver()), + /// # }); + /// # let thread_test_context = Arc::clone(&local_test_context); + /// + /// let bot_token = env!("BOT_TOKEN").to_string(); + /// let intents = GatewayIntents::GUILDS; + /// + /// let mut bot = Client::builder(&bot_token, intents) + /// .event_handler(AutoposterHandler) + /// .event_handler_arc(autoposter.handler()) + /// .await + /// .unwrap(); + /// + /// # let shard_manager = Arc::clone(&bot.shard_manager); + /// # /* + /// let mut receiver = autoposter.receiver(); + /// + /// tokio::spawn(async move { + /// # */ + /// # let test_thread = tokio::spawn(async move { + /// # let mut autopost_counter = 0; + /// # + /// # /* + /// while let Some(result) = receiver.recv().await { + /// # */ + /// # loop { + /// # tokio::select! { + /// # _ = thread_test_context.immature_thread_closure.notified() => { + /// # return Ok(()); + /// # } + /// # + /// # Some(result) = thread_test_context.autoposter_recv() => { + /// println!("Just posted: {result:?}"); + /// # autopost_counter += 1; + /// # + /// # if result.is_err() || autopost_counter == 3 { + /// # shard_manager.shutdown_all().await; + /// # + /// # return result; + /// # } + /// # } + /// # } + /// } + /// }); + /// + /// if let Err(why) = bot.start().await { + /// # local_test_context.immature_thread_closure.notify_one(); + /// # + /// # /* + /// println!("Client error: {why:?}"); + /// # */ + /// # panic!("Client error: {why:?}"); + /// } + /// # + /// # test_thread.await.unwrap().unwrap(); + /// } + /// + /// ``` + #[inline(always)] + pub fn serenity(client: &C, interval: Duration) -> Self + where + C: AsClient, + { + Self::new(client, Serenity::new(), interval) + } +} + +#[cfg(any(feature = "twilight", feature = "twilight-cached"))] +#[cfg_attr( + docsrs, + doc(cfg(any(feature = "twilight", feature = "twilight-cached"))) +)] +impl Autoposter { + /// Creates and starts a twilight-based autoposter thread. + /// + /// # Example + /// + /// ```rust + /// use std::time::Duration; + /// use topgg::{Autoposter, Client}; + /// use twilight_gateway::{Event, Intents, Shard, ShardId}; + /// # + /// # use std::sync::{atomic::{self, AtomicBool}, Arc}; + /// # use tokio::sync::{mpsc, Mutex, Notify, OnceCell}; + /// # + /// # static TEST_EVENT_HANDLER_READY_ONCE: OnceCell<()> = OnceCell::const_new(); + /// # + /// # enum TwilightTestError { + /// # FatalImmatureClosure, + /// # PostStats(topgg::Error), + /// # } + /// # + /// # struct TwilightTestContext { + /// # running: AtomicBool, + /// # immature_thread_closure: Notify, + /// # test_result_sender: mpsc::Sender>, + /// # autoposter_receiver: Mutex>>, + /// # } + /// # + /// # impl TwilightTestContext { + /// # async fn autoposter_recv(&self) -> Option> { + /// # let mut guard = self.autoposter_receiver.lock().await; + /// # + /// # guard.recv().await + /// # } + /// # } + /// + /// #[tokio::main] + /// async fn main() { + /// let client = Client::new(env!("TOPGG_TOKEN").to_string()); + /// # /* + /// let autoposter = Autoposter::twilight(&client, Duration::from_secs(1800)); + /// # */ + /// # let mut autoposter = Autoposter::twilight(&client, Duration::from_secs(2)); + /// # + /// # let (test_result_sender, mut test_result_receiver) = mpsc::channel(1); + /// # + /// # let local_test_context = Arc::new(TwilightTestContext { + /// # running: AtomicBool::new(true), + /// # immature_thread_closure: Notify::const_new(), + /// # test_result_sender, + /// # autoposter_receiver: Mutex::const_new(autoposter.receiver()), + /// # }); + /// + /// let mut shard = Shard::new( + /// ShardId::ONE, + /// env!("BOT_TOKEN").to_string(), + /// Intents::GUILD_MESSAGES | Intents::GUILDS, + /// ); + /// + /// loop { + /// # if !local_test_context.running.load(atomic::Ordering::Relaxed) { + /// # break; + /// # } + /// # + /// let event = match shard.next_event().await { + /// Ok(event) => event, + /// Err(source) => { + /// if source.is_fatal() { + /// # local_test_context.immature_thread_closure.notify_one(); + /// # + /// break; + /// } + /// + /// continue; + /// } + /// }; + /// + /// autoposter.handle(&event).await; + /// + /// match event { + /// Event::Ready(_) => { + /// println!("Bot is now ready!"); + /// # + /// # let thread_test_context = Arc::clone(&local_test_context); + /// # + /// # TEST_EVENT_HANDLER_READY_ONCE.get_or_init(|| async move { + /// # tokio::spawn(async move { + /// # let mut autopost_counter = 0; + /// # + /// # loop { + /// # tokio::select! { + /// # _ = thread_test_context.immature_thread_closure.notified() => { + /// # thread_test_context.test_result_sender.send(Err(TwilightTestError::FatalImmatureClosure)).await.unwrap(); + /// # + /// # return; + /// # } + /// # + /// # Some(posted) = thread_test_context.autoposter_recv() => { + /// # autopost_counter += 1; + /// # + /// # if posted.is_err() || autopost_counter == 3 { + /// # thread_test_context.test_result_sender.send(posted.map_err(TwilightTestError::PostStats)).await.unwrap(); + /// # thread_test_context.running.store(false, atomic::Ordering::Relaxed); + /// # + /// # return; + /// # } + /// # } + /// # } + /// # } + /// # }); + /// # }).await; + /// }, + /// + /// _ => {} + /// } + /// } + /// # + /// # test_result_receiver.recv().await.unwrap().unwrap(); + /// } + /// ``` + #[inline(always)] + pub fn twilight(client: &C, interval: Duration) -> Self + where + C: AsClient, + { + Self::new(client, Twilight::new(), interval) + } +} + +impl Drop for Autoposter { + #[inline(always)] + fn drop(&mut self) { + self.thread.abort(); + } +} diff --git a/src/bot_autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs similarity index 79% rename from src/bot_autoposter/serenity_impl.rs rename to src/autoposter/serenity_impl.rs index 327344f..26e3117 100644 --- a/src/bot_autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -1,4 +1,4 @@ -use crate::bot_autoposter::BotAutoposterHandler; +use crate::autoposter::{Handler, SharedStats}; use paste::paste; use serenity::{ client::{Context, EventHandler, FullEvent}, @@ -8,7 +8,6 @@ use serenity::{ id::GuildId, }, }; -use tokio::sync::RwLock; cfg_if::cfg_if! { if #[cfg(not(feature = "serenity-cached"))] { @@ -21,12 +20,12 @@ cfg_if::cfg_if! { } } -/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the serenity library. +/// A built-in [`Handler`] for the serenity library. #[must_use] pub struct Serenity { #[cfg(not(feature = "serenity-cached"))] cache: Mutex, - server_count: RwLock, + stats: SharedStats, } macro_rules! serenity_handler { @@ -49,7 +48,7 @@ macro_rules! serenity_handler { cache: Mutex::const_new(Cache { guilds: HashSet::new(), }), - server_count: RwLock::new(0), + stats: SharedStats::new(), } } @@ -80,7 +79,7 @@ macro_rules! serenity_handler { )* } - #[serenity::async_trait] + #[async_trait::async_trait] #[allow(unused_variables)] impl EventHandler for Serenity { $( @@ -101,9 +100,9 @@ serenity_handler! { } handle(guilds: &[UnavailableGuild]) { - let mut server_count = self.server_count.write().await; + let mut stats = self.stats.write().await; - *server_count = guilds.len(); + stats.set_server_count(guilds.len()); cfg_if::cfg_if! { if #[cfg(not(feature = "serenity-cached"))] { @@ -122,9 +121,9 @@ serenity_handler! { } handle(guild_count: usize) { - let mut server_count = self.server_count.write().await; + let mut stats = self.stats.write().await; - *server_count = guild_count; + stats.set_server_count(guild_count); } } @@ -144,17 +143,17 @@ serenity_handler! { cfg_if::cfg_if! { if #[cfg(feature = "serenity-cached")] { if is_new { - let mut server_count = self.server_count.write().await; + let mut stats = self.stats.write().await; - *server_count = guild_count; + stats.set_server_count(guild_count); } } else { let mut cache = self.cache.lock().await; if cache.guilds.insert(guild_id) { - let mut server_count = self.server_count.write().await; + let mut stats = self.stats.write().await; - *server_count = cache.guilds.len(); + stats.set_server_count(cache.guilds.len()); } } } @@ -174,16 +173,16 @@ serenity_handler! { #[cfg(not(feature = "serenity-cached"))] guild_id: GuildId) { cfg_if::cfg_if! { if #[cfg(feature = "serenity-cached")] { - let mut server_count = self.server_count.write().await; + let mut stats = self.stats.write().await; - *server_count = guild_count; + stats.set_server_count(guild_count); } else { let mut cache = self.cache.lock().await; if cache.guilds.remove(&guild_id) { - let mut server_count = self.server_count.write().await; + let mut stats = self.stats.write().await; - *server_count = cache.guilds.len(); + stats.set_server_count(cache.guilds.len()); } } } @@ -192,11 +191,9 @@ serenity_handler! { } } -#[async_trait::async_trait] -impl BotAutoposterHandler for Serenity { - async fn server_count(&self) -> usize { - let guard = self.server_count.read().await; - - *guard +impl Handler for Serenity { + #[inline(always)] + fn stats(&self) -> &SharedStats { + &self.stats } } diff --git a/src/autoposter/twilight_impl.rs b/src/autoposter/twilight_impl.rs new file mode 100644 index 0000000..6e3cfd3 --- /dev/null +++ b/src/autoposter/twilight_impl.rs @@ -0,0 +1,63 @@ +use crate::autoposter::{Handler, SharedStats}; +use std::collections::HashSet; +use tokio::sync::Mutex; +use twilight_model::gateway::event::Event; + +/// A built-in [`Handler`] for the twilight library. +pub struct Twilight { + cache: Mutex>, + stats: SharedStats, +} + +impl Twilight { + #[inline(always)] + pub(super) fn new() -> Self { + Self { + cache: Mutex::const_new(HashSet::new()), + stats: SharedStats::new(), + } + } + + /// Handles an entire twilight [`Event`] enum. + pub async fn handle(&self, event: &Event) { + match event { + Event::Ready(ready) => { + let mut cache = self.cache.lock().await; + let mut stats = self.stats.write().await; + let cache_ref = &mut *cache; + + *cache_ref = ready.guilds.iter().map(|guild| guild.id.get()).collect(); + stats.set_server_count(cache.len()); + } + + Event::GuildCreate(guild_create) => { + let mut cache = self.cache.lock().await; + + if cache.insert(guild_create.0.id.get()) { + let mut stats = self.stats.write().await; + + stats.set_server_count(cache.len()); + } + } + + Event::GuildDelete(guild_delete) => { + let mut cache = self.cache.lock().await; + + if cache.remove(&guild_delete.id.get()) { + let mut stats = self.stats.write().await; + + stats.set_server_count(cache.len()); + } + } + + _ => {} + } + } +} + +impl Handler for Twilight { + #[inline(always)] + fn stats(&self) -> &SharedStats { + &self.stats + } +} diff --git a/src/bot.rs b/src/bot.rs index b11436b..9eaf146 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -3,14 +3,12 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{ cmp::min, - collections::HashMap, fmt::Write, future::{Future, IntoFuture}, pin::Pin, }; /// A Discord bot's reviews on Top.gg. -#[must_use] #[derive(Clone, Debug, Deserialize)] pub struct BotReviews { /// This bot's average review score out of 5. @@ -21,93 +19,158 @@ pub struct BotReviews { pub count: usize, } -/// A Discord bot listed on Top.gg. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct Bot { - /// This bot's Discord ID. - #[serde(rename = "clientid", deserialize_with = "snowflake::deserialize")] - pub id: u64, - - /// This bot's Top.gg ID. - #[serde(rename = "id", deserialize_with = "snowflake::deserialize")] - pub topgg_id: u64, - - /// This bot's username. - #[serde(rename = "username")] - pub name: String, - - /// This bot's prefix. - pub prefix: String, - - /// This bot's short description. - #[serde(rename = "shortdesc")] - pub short_description: String, - - /// This bot's HTML/Markdown long description. - #[serde( - default, - deserialize_with = "util::deserialize_optional_string", - rename = "longdesc" - )] - pub long_description: Option, - - /// This bot's tags. - #[serde(deserialize_with = "util::deserialize_default")] - pub tags: Vec, - - /// This bot's website URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub website: Option, - - /// This bot's GitHub repository URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub github: Option, - - /// This bot's owner IDs. - #[serde(deserialize_with = "snowflake::deserialize_vec")] - pub owners: Vec, - - /// This bot's submission date. - #[serde(rename = "date")] - pub submitted_at: DateTime, - - /// The amount of votes this bot has. - #[serde(rename = "points")] - pub votes: usize, - - /// The amount of votes this bot has this month. - #[serde(rename = "monthlyPoints")] - pub monthly_votes: usize, - - /// This bot's support URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub support: Option, - - /// This bot's avatar URL. - pub avatar: String, - - /// This bot's invite URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub invite: Option, - - /// This bot's Top.gg vanity code. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub vanity: Option, - - /// This bot's posted server count. - #[serde(default)] - pub server_count: Option, - - /// This bot's reviews. - #[serde(rename = "reviews")] - pub review: BotReviews, -} +util::debug_struct! { + /// A Discord Bot listed on Top.gg. + #[must_use] + #[derive(Clone, Deserialize)] + Bot { + public { + /// This bot's Discord ID. + #[serde(rename = "clientid", deserialize_with = "snowflake::deserialize")] + id: u64, + + /// This bot's Top.gg ID. + #[serde(rename = "id", deserialize_with = "snowflake::deserialize")] + topgg_id: u64, + + /// This bot's username. + username: String, + + /// This bot's discriminator. + #[serde(skip)] + discriminator: String, + + /// This bot's prefix. + prefix: String, + + /// This bot's short description. + #[serde(rename = "shortdesc")] + short_description: String, + + /// This bot's HTML/Markdown long description. + #[serde( + default, + deserialize_with = "util::deserialize_optional_string", + rename = "longdesc" + )] + long_description: Option, + + /// This bot's tags. + #[serde(default, deserialize_with = "util::deserialize_default")] + tags: Vec, + + /// This bot's website URL. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + website: Option, + + /// This bot's GitHub repository URL. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + github: Option, + + /// This bot's owner IDs. + #[serde(deserialize_with = "snowflake::deserialize_vec")] + owners: Vec, + + /// This bot's server IDs. + #[serde(skip)] + guilds: Vec, + + /// This bot's banner image URL. + #[serde(skip)] + banner_url: Option, + + /// This bot's approval date. + #[serde(skip)] + #[deprecated(since = "1.5.0", note = "Actually refers to submission date. Use `submitted_at` instead.")] + approved_at: DateTime, + + /// This bot's submission date. + #[serde(rename = "date")] + submitted_at: DateTime, + + /// Whether this bot is certified. + #[serde(skip)] + is_certified: bool, + + /// This bot's shards. + #[serde(skip)] + shards: Vec, + + /// The amount of votes this bot has. + #[serde(rename = "points")] + votes: usize, + + /// The amount of votes this bot has this month. + #[serde(rename = "monthlyPoints")] + monthly_votes: usize, + + /// This bot's support URL. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + support: Option, + + /// This bot's avatar URL. + avatar: String, + + /// This bot's Top.gg vanity code. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + vanity: Option, + + /// This bot's posted server count. + #[serde(default)] + server_count: Option, + + /// This bot's reviews. + #[serde(rename = "reviews")] + review: BotReviews, + } + + private { + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + invite: Option, + } -#[derive(Serialize, Deserialize)] -pub(crate) struct BotStats { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) server_count: Option, + getters(self) { + /// This bot's creation date. + #[must_use] + #[inline(always)] + created_at: DateTime => { + util::get_creation_date(self.id) + } + + /// This bot's avatar URL. + #[deprecated(since = "1.5.0", note = "Just directly use the public `avatar` property.")] + avatar: String => { + self.avatar.clone() + } + + /// This bot's invite URL. + #[must_use] + invite: String => { + match &self.invite { + Some(inv) => inv.to_owned(), + _ => format!( + "https://discord.com/oauth2/authorize?scope=bot&client_id={}", + self.id + ), + } + } + + /// This bot's shard count. + shard_count: usize => { + 0 + } + + /// This bot's Top.gg page URL. + #[must_use] + #[inline(always)] + url: String => { + format!( + "https://top.gg/bot/{}", + self.vanity.as_deref().unwrap_or(&self.id.to_string()) + ) + } + } + } } #[derive(Deserialize)] @@ -115,27 +178,188 @@ pub(crate) struct Bots { pub(crate) results: Vec, } +util::debug_struct! { + /// A Discord bot's statistics. + /// + /// # Examples + /// + /// Solely from a server count: + /// + /// ```rust + /// use topgg::Stats; + /// + /// let _stats = Stats::from(12345); + /// ``` + /// + /// Server count with a shard count: + /// + /// ```rust + /// use topgg::Stats; + /// + /// let server_count = 12345; + /// let shard_count = 10; + /// let _stats = Stats::from_count(server_count, Some(shard_count)); + /// ``` + /// + /// Solely from shards information: + /// + /// ```rust + /// use topgg::Stats; + /// + /// // the shard posting this data has 456 servers. + /// let _stats = Stats::from_shards([123, 456, 789], Some(1)); + /// ``` + #[must_use] + #[derive(Clone, Serialize, Deserialize)] + Stats { + protected { + #[serde(skip_serializing_if = "Option::is_none")] + shard_count: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + server_count: Option, + } + + private { + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "util::deserialize_default")] + shards: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "util::deserialize_default")] + shard_id: Option, + } + + getters(self) { + /// This bot's list of server count for each shard. + #[must_use] + #[inline(always)] + shards: &[usize] => { + match self.shards { + Some(ref shards) => shards, + None => &[], + } + } + + /// This bot's shard count. + #[must_use] + #[inline(always)] + shard_count: usize => { + self.shard_count.unwrap_or(match self.shards { + Some(ref shards) => shards.len(), + None => 0, + }) + } + + /// The amount of servers this bot is in. `None` if such information is publicly unavailable. + #[must_use] + server_count: Option => { + self.server_count.or_else(|| { + self.shards.as_ref().and_then(|shards| { + if shards.is_empty() { + None + } else { + Some(shards.iter().copied().sum()) + } + }) + }) + } + } + } +} + +impl Stats { + /// Creates a [`Stats`] struct from the cache of a serenity [`Context`][serenity::client::Context]. + #[cfg(feature = "serenity-cached")] + #[cfg_attr(docsrs, doc(cfg(feature = "serenity-cached")))] + pub fn from_context(context: &serenity::client::Context) -> Self { + Self::from_count( + context.cache.guilds().len(), + Some(context.cache.shard_count() as _), + ) + } + + /// Creates a [`Stats`] struct based on total server and optionally, shard count data. + pub const fn from_count(server_count: usize, shard_count: Option) -> Self { + Self { + server_count: Some(server_count), + shard_count, + shards: None, + shard_id: None, + } + } + + /// Creates a [`Stats`] struct based on an array of server count per shard and optionally the index (to the array) of shard posting this data. + /// + /// # Panics + /// + /// Panics if the `shard_index` argument is [`Some`] yet it's out of range of the `shards` array. + /// + /// # Example + /// + /// Basic usage: + /// + /// ```rust + /// use topgg::Stats; + /// + /// // the shard posting this data has 456 servers. + /// let _stats = Stats::from_shards([123, 456, 789], Some(1)); + /// ``` + pub fn from_shards(shards: A, shard_index: Option) -> Self + where + A: IntoIterator, + { + let mut total_server_count = 0; + let shards = shards.into_iter(); + let mut shards_list = Vec::with_capacity(shards.size_hint().0); + + for server_count in shards { + total_server_count += server_count; + shards_list.push(server_count); + } + + if let Some(index) = shard_index { + assert!(index < shards_list.len(), "Shard index out of range."); + } + + Self { + server_count: Some(total_server_count), + shard_count: Some(shards_list.len()), + shards: Some(shards_list), + shard_id: shard_index, + } + } +} + +/// Creates a [`Stats`] struct solely from a server count. +impl From for Stats { + #[inline(always)] + fn from(server_count: usize) -> Self { + Self::from_count(server_count, None) + } +} + #[derive(Deserialize)] pub(crate) struct IsWeekend { pub(crate) is_weekend: bool, } -/// Query for [`Client::get_bots`]. +/// Query configuration for [`get_bots`][crate::Client::get_bots]. #[must_use] -pub struct BotQuery<'a> { +pub struct GetBots<'a> { client: &'a Client, - query: HashMap<&'static str, String>, + query: String, + search: String, sort: Option<&'static str>, } macro_rules! get_bots_method { ($( - $(#[doc = $doc:literal])* - $lib_name:ident: $lib_type:ty = $property:ident($api_name:ident, $lib_value:expr); + $(#[$details:meta])* + $input_name:ident: $input_type:ty $(= $property:ident($($format:tt)*))?; )*) => {$( - $(#[doc = $doc])* - pub fn $lib_name(mut self, $lib_name: $lib_type) -> Self { - self.$property.insert(stringify!($api_name), $lib_value); + $(#[$details])* + #[allow(unused, unused_mut)] + pub fn $input_name(mut self, $input_name: $input_type) -> Self { + $(write!(&mut self.$property, $($format)*).unwrap();)? self } )*}; @@ -143,10 +367,10 @@ macro_rules! get_bots_method { macro_rules! get_bots_sort { ($( - $(#[doc = $doc:literal])* + $(#[$details:meta])* $func_name:ident: $api_name:ident, )*) => {$( - $(#[doc = $doc])* + $(#[$details])* pub fn $func_name(mut self) -> Self { self.sort.replace(stringify!($api_name)); self @@ -154,12 +378,13 @@ macro_rules! get_bots_sort { )*}; } -impl<'a> BotQuery<'a> { +impl<'a> GetBots<'a> { #[inline(always)] pub(crate) fn new(client: &'a Client) -> Self { Self { client, - query: HashMap::new(), + query: String::from('?'), + search: String::new(), sort: None, } } @@ -168,39 +393,60 @@ impl<'a> BotQuery<'a> { /// Sorts results based on each bot's ID. sort_by_id: id, - /// Sorts results based on each bot's submission date. - sort_by_submission_date: date, + /// Sorts results based on each bot's approval date. + sort_by_approval_date: date, /// Sorts results based on each bot's monthly vote count. sort_by_monthly_votes: monthlyPoints, } get_bots_method! { - /// Sets the maximum amount of bots to be returned. - limit: u16 = query(limit, min(limit, 500).to_string()); + /// Sets the maximum amount of bots to be queried. This cannot be more than 500. + limit: u16 = query("limit={}&", min(limit, 500)); + + /// Sets the amount of bots to be skipped during the query. This cannot be more than 499. + skip: u16 = query("offset={}&", min(skip, 499)); + + /// Queries only bots that has this username. + username: &str = search("username%3A%20{}%20", urlencoding::encode(username)); + + /// Queries only bots that has this discriminator. + discriminator: &str = search("discriminator%3A%20{discriminator}%20"); + + /// Queries only bots that has this prefix. + prefix: &str = search("prefix%3A%20{}%20", urlencoding::encode(prefix)); + + /// Queries only bots that has this vote count. + votes: usize = search("points%3A%20{votes}%20"); - /// Sets the amount of bots to be skipped. - skip: u16 = query(offset, min(skip, 499).to_string()); + /// Queries only bots that has this monthly vote count. + monthly_votes: usize = search("monthlyPoints%3A%20{monthly_votes}%20"); + + /// Queries only Top.gg certified bots. + certified: bool = search("certifiedBot%3A%20{certified}%20"); + + /// Queries only bots that has this Top.gg vanity URL. + vanity: &str = search("vanity%3A%20{}%20", urlencoding::encode(vanity)); } } -impl<'a> IntoFuture for BotQuery<'a> { +impl<'a> IntoFuture for GetBots<'a> { type Output = crate::Result>; type IntoFuture = Pin + Send + 'a>>; fn into_future(self) -> Self::IntoFuture { - let mut path = String::from("/bots?"); + let mut query = self.query; - if let Some(sort) = self.sort { - write!(&mut path, "sort={sort}&").unwrap(); + if self.search.is_empty() { + query.pop(); + } else { + write!(&mut query, "search={}", self.search).unwrap(); } - for (key, value) in self.query { - write!(&mut path, "{key}={value}&").unwrap(); + if let Some(sort) = self.sort { + write!(&mut query, "sort={sort}&").unwrap(); } - path.pop(); - - Box::pin(self.client.get_bots_inner(path)) + Box::pin(self.client.get_bots_inner(query)) } } diff --git a/src/bot_autoposter/mod.rs b/src/bot_autoposter/mod.rs deleted file mode 100644 index a761313..0000000 --- a/src/bot_autoposter/mod.rs +++ /dev/null @@ -1,354 +0,0 @@ -use crate::Result; -use std::{ops::Deref, sync::Arc, time::Duration}; -use tokio::{ - sync::mpsc, - task::{spawn, JoinHandle}, - time::sleep, -}; - -mod client; - -pub use client::AsClient; -pub(crate) use client::AsClientSealed; - -cfg_if::cfg_if! { - if #[cfg(feature = "serenity")] { - mod serenity_impl; - - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] - pub use serenity_impl::Serenity; - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "twilight")] { - mod twilight_impl; - - #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] - pub use twilight_impl::Twilight; - } -} - -/// Handle events from third-party Discord bot libraries. -/// -/// Structs that implement this ideally should own a `RwLock` instance and update it accordingly whenever Discord sends them new data regarding their server count. -#[async_trait::async_trait] -pub trait BotAutoposterHandler: Send + Sync + 'static { - /// The bot's latest server count. - async fn server_count(&self) -> usize; -} - -/// Automatically update the server count in your Discord bot's Top.gg page every few minutes. -/// -/// **NOTE**: This struct owns the Discord bot autoposter thread which means that it will stop once it gets dropped. -/// -/// # Examples -/// -/// Serenity: -/// -/// ```rust,no_run -/// use std::time::Duration; -/// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; -/// use topgg::BotAutoposter; -/// -/// struct BotAutoposterHandler; -/// -/// #[serenity::async_trait] -/// impl EventHandler for BotAutoposterHandler { -/// async fn ready(&self, _: Context, ready: Ready) { -/// println!("{} is now ready!", ready.user.name); -/// } -/// } -/// -/// #[tokio::main] -/// async fn main() { -/// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); -/// -/// // Posts once every 30 minutes -/// let mut bot_autoposter = BotAutoposter::serenity(&client, Duration::from_secs(1800)); -/// -/// let bot_token = env!("BOT_TOKEN").to_string(); -/// let intents = GatewayIntents::GUILDS; -/// -/// let mut bot = Client::builder(&bot_token, intents) -/// .event_handler(BotAutoposterHandler) -/// .event_handler_arc(bot_autoposter.handler()) -/// .await -/// .unwrap(); -/// -/// let mut receiver = bot_autoposter.receiver(); -/// -/// tokio::spawn(async move { -/// while let Some(result) = receiver.recv().await { -/// println!("Just posted: {result:?}"); -/// } -/// }); -/// -/// if let Err(why) = bot.start().await { -/// println!("Client error: {why:?}"); -/// } -/// } -/// ``` -/// -/// Twilight: -/// -/// ```rust,no_run -/// use std::time::Duration; -/// use topgg::{BotAutoposter, Client}; -/// use twilight_gateway::{Event, Intents, Shard, ShardId}; -/// -/// #[tokio::main] -/// async fn main() { -/// let client = Client::new(env!("TOPGG_TOKEN").to_string()); -/// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); -/// -/// let mut shard = Shard::new( -/// ShardId::ONE, -/// env!("BOT_TOKEN").to_string(), -/// Intents::GUILD_MESSAGES | Intents::GUILDS, -/// ); -/// -/// loop { -/// let event = match shard.next_event().await { -/// Ok(event) => event, -/// Err(source) => { -/// if source.is_fatal() { -/// break; -/// } -/// -/// continue; -/// } -/// }; -/// -/// bot_autoposter.handle(&event).await; -/// -/// match event { -/// Event::Ready(_) => { -/// println!("Bot is now ready!"); -/// }, -/// -/// _ => {} -/// } -/// } -/// } -/// ``` -#[must_use] -pub struct BotAutoposter { - handler: Arc, - thread: JoinHandle<()>, - receiver: Option>>, -} - -impl BotAutoposter -where - H: BotAutoposterHandler, -{ - /// Creates and starts a Discord bot autoposter thread. - #[allow(unused_mut)] - pub fn new(client: &C, handler: H, mut interval: Duration) -> Self - where - C: AsClient, - { - #[cfg(not(test))] - if interval.as_secs() < 900 { - interval = Duration::from_secs(900); - } - - let client = client.as_client(); - let handler = Arc::new(handler); - let local_handler = Arc::clone(&handler); - let (sender, receiver) = mpsc::unbounded_channel(); - - Self { - handler: local_handler, - thread: spawn(async move { - loop { - cfg_if::cfg_if! { - if #[cfg(test)] { - let server_count = 3; - } else { - let server_count = handler.server_count().await; - } - } - - if sender - .send( - client - .post_bot_server_count(server_count) - .await - .map(|()| server_count), - ) - .is_err() - { - break; - } - - sleep(interval).await; - } - }), - receiver: Some(receiver), - } - } - - /// This Discord bot autoposter's handler. - #[inline(always)] - pub fn handler(&self) -> Arc { - Arc::clone(&self.handler) - } - - /// Returns a future that resolves whenever an attempt to update the server count in your bot's Top.gg page has been made. The `usize` in this case is the server count that was just posted. - /// - /// **NOTE**: If you want to use the receiver directly, call [`receiver`][BotAutoposter::receiver]. - /// - /// # Panics - /// - /// Panics if this method gets called again after [`receiver`][BotAutoposter::receiver] is called. - #[inline(always)] - pub async fn recv(&mut self) -> Option> { - self.receiver.as_mut().expect("The receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await - } - - /// Takes the receiver responsible for [`recv`][BotAutoposter::recv]. - /// - /// # Panics - /// - /// Panics if this method gets called for the second time. - #[inline(always)] - pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { - self - .receiver - .take() - .expect("receiver() can only be called once.") - } -} - -impl Deref for BotAutoposter { - type Target = H; - - #[inline(always)] - fn deref(&self) -> &Self::Target { - &self.handler - } -} - -#[cfg(feature = "serenity")] -#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] -impl BotAutoposter { - /// Creates and starts a serenity-based Discord bot autoposter thread. - /// - /// # Example - /// - /// ```rust,no_run - /// use std::time::Duration; - /// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; - /// use topgg::BotAutoposter; - /// - /// struct BotAutoposterHandler; - /// - /// #[serenity::async_trait] - /// impl EventHandler for BotAutoposterHandler { - /// async fn ready(&self, _: Context, ready: Ready) { - /// println!("{} is now ready!", ready.user.name); - /// } - /// } - /// - /// #[tokio::main] - /// async fn main() { - /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - /// - /// // Posts once every 30 minutes - /// let mut bot_autoposter = BotAutoposter::serenity(&client, Duration::from_secs(1800)); - /// - /// let bot_token = env!("BOT_TOKEN").to_string(); - /// let intents = GatewayIntents::GUILDS; - /// - /// let mut bot = Client::builder(&bot_token, intents) - /// .event_handler(BotAutoposterHandler) - /// .event_handler_arc(bot_autoposter.handler()) - /// .await - /// .unwrap(); - /// - /// let mut receiver = bot_autoposter.receiver(); - /// - /// tokio::spawn(async move { - /// while let Some(result) = receiver.recv().await { - /// println!("Just posted: {result:?}"); - /// } - /// }); - /// - /// if let Err(why) = bot.start().await { - /// println!("Client error: {why:?}"); - /// } - /// } - /// ``` - #[inline(always)] - pub fn serenity(client: &C, interval: Duration) -> Self - where - C: AsClient, - { - Self::new(client, Serenity::new(), interval) - } -} - -#[cfg(feature = "twilight")] -#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] -impl BotAutoposter { - /// Creates and starts a twilight-based Discord bot autoposter thread. - /// - /// # Example - /// - /// ```rust,no_run - /// use std::time::Duration; - /// use topgg::{BotAutoposter, Client}; - /// use twilight_gateway::{Event, Intents, Shard, ShardId}; - /// - /// #[tokio::main] - /// async fn main() { - /// let client = Client::new(env!("TOPGG_TOKEN").to_string()); - /// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); - /// - /// let mut shard = Shard::new( - /// ShardId::ONE, - /// env!("BOT_TOKEN").to_string(), - /// Intents::GUILD_MESSAGES | Intents::GUILDS, - /// ); - /// - /// loop { - /// let event = match shard.next_event().await { - /// Ok(event) => event, - /// Err(source) => { - /// if source.is_fatal() { - /// break; - /// } - /// - /// continue; - /// } - /// }; - /// - /// bot_autoposter.handle(&event).await; - /// - /// match event { - /// Event::Ready(_) => { - /// println!("Bot is now ready!"); - /// }, - /// - /// _ => {} - /// } - /// } - /// } - /// ``` - #[inline(always)] - pub fn twilight(client: &C, interval: Duration) -> Self - where - C: AsClient, - { - Self::new(client, Twilight::new(), interval) - } -} - -impl Drop for BotAutoposter { - #[inline(always)] - fn drop(&mut self) { - self.thread.abort(); - } -} diff --git a/src/bot_autoposter/twilight_impl.rs b/src/bot_autoposter/twilight_impl.rs deleted file mode 100644 index 69886ad..0000000 --- a/src/bot_autoposter/twilight_impl.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::bot_autoposter::BotAutoposterHandler; -use std::collections::HashSet; -use tokio::sync::{Mutex, RwLock}; -use twilight_model::gateway::event::Event; - -/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the twilight. -pub struct Twilight { - cache: Mutex>, - server_count: RwLock, -} - -impl Twilight { - #[inline(always)] - pub(super) fn new() -> Self { - Self { - cache: Mutex::const_new(HashSet::new()), - server_count: RwLock::new(0), - } - } - - /// Handles an entire twilight [`Event`] enum. - pub async fn handle(&self, event: &Event) { - match event { - Event::Ready(ready) => { - let mut cache: tokio::sync::MutexGuard<'_, HashSet> = self.cache.lock().await; - let mut server_count = self.server_count.write().await; - let cache_ref = &mut *cache; - - *cache_ref = ready.guilds.iter().map(|guild| guild.id.get()).collect(); - *server_count = cache.len(); - } - - Event::GuildCreate(guild_create) => { - let mut cache = self.cache.lock().await; - - if cache.insert(guild_create.id.get()) { - let mut server_count = self.server_count.write().await; - - *server_count = cache.len(); - } - } - - Event::GuildDelete(guild_delete) => { - let mut cache = self.cache.lock().await; - - if cache.remove(&guild_delete.id.get()) { - let mut server_count = self.server_count.write().await; - - *server_count = cache.len(); - } - } - - _ => {} - } - } -} - -#[async_trait::async_trait] -impl BotAutoposterHandler for Twilight { - async fn server_count(&self) -> usize { - let guard = self.server_count.read().await; - - *guard - } -} diff --git a/src/client.rs b/src/client.rs index ea85b09..6872b79 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,15 +1,14 @@ use crate::{ - bot::{Bot, BotQuery, BotStats, Bots, IsWeekend}, - util, - vote::{Voted, Voter}, - Error, Result, Snowflake, + bot::{Bot, Bots, GetBots, IsWeekend}, + user::{User, Voted, Voter}, + util, Error, Result, Snowflake, Stats, }; use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; use serde::{de::DeserializeOwned, Deserialize}; cfg_if::cfg_if! { - if #[cfg(feature = "bot-autoposter")] { - use crate::bot_autoposter; + if #[cfg(feature = "autoposter")] { + use crate::autoposter; use std::sync::Arc; type SyncedClient = Arc; @@ -24,36 +23,29 @@ struct Ratelimit { retry_after: u16, } -#[macro_export] macro_rules! api { ($e:literal) => { concat!("https://top.gg/api", $e) }; ($e:literal, $($rest:tt)*) => { - format!($crate::client::api!($e), $($rest)*) + format!(api!($e), $($rest)*) }; } -pub(crate) use api; - pub struct InnerClient { http: reqwest::Client, token: String, id: u64, } -#[derive(Deserialize)] -pub(crate) struct ErrorJson { - #[serde(default, alias = "message", alias = "detail")] - message: Option, -} - -// This is implemented here because the Discord bot autoposter needs to access this struct from a different thread. +// This is implemented here because autoposter needs to access this struct from a different thread. impl InnerClient { - pub(crate) fn new(token: String) -> Self { + pub(crate) fn new(mut token: String) -> Self { let id = util::parse_api_token(&token); + token.insert_str(0, "Bearer "); + Self { http: reqwest::Client::new(), token, @@ -90,13 +82,9 @@ impl InnerClient { Ok(response) } else { Err(match status { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => panic!("Invalid API token."), - StatusCode::NOT_FOUND => Error::NotFound( - util::parse_json::(response) - .await - .ok() - .and_then(|err| err.message), - ), + StatusCode::BAD_REQUEST => Error::InvalidRequest, + StatusCode::UNAUTHORIZED => panic!("Invalid Top.gg API token."), + StatusCode::NOT_FOUND => Error::NotFound, StatusCode::TOO_MANY_REQUESTS => match util::parse_json::(response).await { Ok(ratelimit) => Error::Ratelimit { retry_after: ratelimit.retry_after, @@ -128,19 +116,16 @@ impl InnerClient { } } - pub(crate) async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { - if server_count == 0 { - return Err(Error::InvalidRequest); + pub(crate) async fn post_stats(&self, new_stats: &Stats) -> Result<()> { + if new_stats.server_count.unwrap_or_default() == 0 { + return Ok(()); } self .send_inner( Method::POST, api!("/bots/stats"), - serde_json::to_vec(&BotStats { - server_count: Some(server_count), - }) - .unwrap(), + serde_json::to_vec(new_stats).unwrap(), ) .await .map(|_| ()) @@ -156,27 +141,60 @@ pub struct Client { impl Client { /// Creates a new instance. /// - /// To retrieve your API token, [see this tutorial](https://github.com/top-gg-community/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). - /// /// # Panics /// /// Panics if the client uses an invalid API token. /// /// # Example /// - /// ```rust,no_run + /// ```rust /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); /// ``` #[inline(always)] pub fn new(token: String) -> Self { let inner = InnerClient::new(token); - #[cfg(feature = "bot-autoposter")] + #[cfg(feature = "autoposter")] let inner = Arc::new(inner); Self { inner } } + /// Fetches a user from a Discord ID. + /// + /// # Panics + /// + /// Panics if any of the following conditions are met: + /// - The ID argument is a string but not numeric + /// - The client uses an invalid Top.gg API token (unauthorized) + /// + /// # Errors + /// + /// Errors if any of the following conditions are met: + /// - An internal error from the client itself preventing it from sending a HTTP request to Top.gg ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the Top.gg servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The requested user does not exist ([`NotFound`][crate::Error::NotFound]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,should_panic + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # + /// let user = client.get_user(661200758510977084).await.unwrap(); + /// # } + /// ``` + #[allow(clippy::unused_async)] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + pub async fn get_user(&self, _id: I) -> Result + where + I: Snowflake, + { + Err(Error::NotFound) + } + /// Fetches a Discord bot from its ID. /// /// # Panics @@ -195,8 +213,13 @@ impl Client { /// /// # Example /// - /// ```rust,no_run + /// ```rust + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # /// let bot = client.get_bot(264811613708746752).await.unwrap(); + /// # } /// ``` pub async fn get_bot(&self, id: I) -> Result where @@ -208,7 +231,7 @@ impl Client { .await } - /// Fetches your Discord bot's posted server count. + /// Fetches your Discord bot's statistics. /// /// # Panics /// @@ -223,18 +246,22 @@ impl Client { /// /// # Example /// - /// ```rust,no_run - /// let server_count = client.get_bot_server_count().await.unwrap(); + /// ```rust + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # + /// let stats = client.get_stats().await.unwrap(); + /// # } /// ``` - pub async fn get_bot_server_count(&self) -> Result> { + pub async fn get_stats(&self) -> Result { self .inner .send(Method::GET, api!("/bots/stats"), None) .await - .map(|stats: BotStats| stats.server_count) } - /// Updates the server count in your Discord bot's Top.gg page. + /// Updates your Discord bot's statistics. /// /// # Panics /// @@ -243,19 +270,27 @@ impl Client { /// # Errors /// /// Returns [`Err`] if: - /// - The bot is currently in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) + /// - The bot is in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) /// /// # Example /// - /// ```rust,no_run - /// client.post_bot_server_count(bot.server_count()).await.unwrap(); + /// ```rust + /// use topgg::Stats; + /// # + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// + /// // Server count + /// client.post_stats(Stats::from(2)).await.unwrap(); + /// # } /// ``` #[inline(always)] - pub async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { - self.inner.post_bot_server_count(server_count).await + pub async fn post_stats(&self, new_stats: Stats) -> Result<()> { + self.inner.post_stats(&new_stats).await } /// Fetches your project's recent unique voters. @@ -275,18 +310,30 @@ impl Client { /// /// # Example /// - /// ```rust,no_run + /// ```rust + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # /// // Page number - /// let voters = client.get_voters(1).await.unwrap(); + /// let voters = client.get_voters(None).await.unwrap(); /// /// for voter in voters { /// println!("{}", voter.username); /// } + /// # } /// ``` - pub async fn get_voters(&self, mut page: usize) -> Result> { - if page < 1 { - page = 1; - } + pub async fn get_voters(&self, page: Option) -> Result> { + let page = match page { + Some(page) => { + if page >= 1 { + page + } else { + 1 + } + } + None => 1, + }; self .inner @@ -298,30 +345,36 @@ impl Client { .await } - pub(crate) async fn get_bots_inner(&self, path: String) -> Result> { + pub(crate) async fn get_bots_inner(&self, query: String) -> Result> { self .inner - .send::(Method::GET, api!("{}", path), None) + .send::(Method::GET, api!("/bots{}", query), None) .await .map(|res| res.results) } - /// Fetches Discord bots that matches the specified query. + /// Queries/searches through the Top.gg database to look for matching listed Discord bots. /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// Panics if any of the client uses an invalid Top.gg API token (unauthorized). /// /// # Errors /// - /// Returns [`Err`] if: - /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// Errors if any of the following conditions are met: + /// - An internal error from the client itself preventing it from sending a HTTP request to Top.gg ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the Top.gg servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) /// /// # Example /// - /// ```rust,no_run + /// Basic usage: + /// + /// ```rust + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # /// let bots = client /// .get_bots() /// .limit(250) @@ -331,15 +384,16 @@ impl Client { /// .unwrap(); /// /// for bot in bots { - /// println!("{}", bot.name); + /// println!("{:?}", bot); /// } + /// # } /// ``` #[inline(always)] - pub fn get_bots(&self) -> BotQuery<'_> { - BotQuery::new(self) + pub fn get_bots(&self) -> GetBots<'_> { + GetBots::new(self) } - /// Checks if a Top.gg user has voted for your Discord bot in the past 12 hours. + /// Checks if a Top.gg user has voted for your project in the past 12 hours. /// /// # Panics /// @@ -357,8 +411,13 @@ impl Client { /// /// # Example /// - /// ```rust,no_run + /// ```rust + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # /// let has_voted = client.has_voted(8226924471638491136).await.unwrap(); + /// # } /// ``` pub async fn has_voted(&self, user_id: I) -> Result where @@ -390,8 +449,13 @@ impl Client { /// /// # Example /// - /// ```rust,no_run + /// ```rust + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # /// let is_weekend = client.is_weekend().await.unwrap(); + /// # } /// ``` pub async fn is_weekend(&self) -> Result { self @@ -403,14 +467,14 @@ impl Client { } cfg_if::cfg_if! { - if #[cfg(feature = "bot-autoposter")] { - impl bot_autoposter::AsClientSealed for Client { + if #[cfg(feature = "autoposter")] { + impl autoposter::AsClientSealed for Client { #[inline(always)] fn as_client(&self) -> Arc { Arc::clone(&self.inner) } } - impl bot_autoposter::AsClient for Client {} + impl autoposter::AsClient for Client {} } } diff --git a/src/error.rs b/src/error.rs index 3139798..6e5fa4c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,8 +12,8 @@ pub enum Error { /// Attempted to send an invalid request to the API. InvalidRequest, - /// Such query does not exist. Inside is the message from the API if available. - NotFound(Option), + /// Such query does not exist. + NotFound, /// Ratelimited from sending more requests. Ratelimit { @@ -28,11 +28,7 @@ impl fmt::Display for Error { Self::InternalClientError(err) => write!(f, "Internal Client Error: {err}"), Self::InternalServerError => write!(f, "Internal Server Error"), Self::InvalidRequest => write!(f, "Invalid Request"), - Self::NotFound(message) => write!( - f, - "Not Found: {}", - message.as_deref().unwrap_or("") - ), + Self::NotFound => write!(f, "Not Found"), Self::Ratelimit { retry_after } => write!( f, "Blocked by the API for an hour. Please try again in {retry_after} seconds", @@ -52,4 +48,4 @@ impl error::Error for Error { } /// The result type primarily used in this SDK. -pub type Result = result::Result; \ No newline at end of file +pub type Result = result::Result; diff --git a/src/lib.rs b/src/lib.rs index 1f1ac99..365aa35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,58 +1,46 @@ -#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, doc = include_str!("../README.md"))] #![cfg_attr(docsrs, feature(doc_cfg))] -#![cfg_attr(feature = "webhooks", allow(unreachable_patterns))] -#![allow(clippy::needless_pass_by_value)] mod snowflake; -#[cfg(test)] -mod test; cfg_if::cfg_if! { if #[cfg(feature = "api")] { - pub(crate) mod client; - mod bot; + mod client; mod error; mod util; - mod vote; - #[cfg(feature = "bot-autoposter")] + #[cfg(feature = "autoposter")] pub(crate) use client::InnerClient; + /// Bot-related traits and structs. + pub mod bot; + + /// User-related structs. + pub mod user; + #[doc(inline)] - pub use bot::{Bot, BotQuery}; + pub use bot::Stats; pub use client::Client; pub use error::{Error, Result}; pub use snowflake::Snowflake; // for doc purposes - pub use vote::Voter; - - #[doc(hidden)] - #[cfg(any(feature = "twilight", feature = "twilight-cached"))] - pub use project::TwilightGetCommandsError; } } cfg_if::cfg_if! { - if #[cfg(feature = "bot-autoposter")] { - mod bot_autoposter; + if #[cfg(feature = "autoposter")] { + /// Autoposter-related traits and structs. + #[cfg_attr(docsrs, doc(cfg(feature = "autoposter")))] + pub mod autoposter; #[doc(inline)] - #[cfg_attr(docsrs, doc(cfg(feature = "bot-autoposter")))] - pub use bot_autoposter::{BotAutoposter, BotAutoposterHandler}; - - #[cfg(any(feature = "serenity", feature = "serenity-cached"))] - #[cfg_attr(docsrs, doc(cfg(all(feature = "bot-autoposter", any(feature = "serenity", feature = "serenity-cached")))))] - pub use bot_autoposter::Serenity as SerenityBotAutoposter; - - #[cfg(any(feature = "twilight", feature = "twilight-cached"))] - #[cfg_attr(docsrs, doc(cfg(all(feature = "bot-autoposter", any(feature = "twilight", feature = "twilight-cached")))))] - pub use bot_autoposter::Twilight as TwilightBotAutoposter; + pub use autoposter::{Autoposter, SharedStats}; } } cfg_if::cfg_if! { - if #[cfg(feature = "webhooks")] { - mod webhooks; + if #[cfg(feature = "webhook")] { + mod webhook; - pub use webhooks::*; + pub use webhook::*; } } diff --git a/src/snowflake.rs b/src/snowflake.rs index 02eaf0b..e29981c 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -56,8 +56,8 @@ cfg_if::cfg_if! { ); impl_topgg_idstruct!( - crate::Bot, - crate::Voter + crate::bot::Bot, + crate::user::Voter ); } } diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index d7ed055..0000000 --- a/src/test.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::Client; -use tokio::time::{sleep, Duration}; - -macro_rules! delayed { - ($($b:tt)*) => { - $($b)* - sleep(Duration::from_secs(1)).await - }; -} - -#[tokio::test] -async fn api() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); - - delayed! { - let bot = client.get_bot(264811613708746752).await.unwrap(); - - assert_eq!(bot.name, "Luca"); - assert_eq!(bot.id, 264811613708746752); - } - - delayed! { - let _bots = client - .get_bots() - .limit(250) - .skip(50) - .sort_by_monthly_votes() - .await - .unwrap(); - } - - delayed! { - client - .post_bot_server_count(2) - .await - .unwrap(); - } - - delayed! { - assert_eq!(client.get_bot_server_count().await.unwrap().unwrap(), 2); - } - - delayed! { - let _voters = client.get_voters(1).await.unwrap(); - } - - delayed! { - let _has_voted = client.has_voted(661200758510977084).await.unwrap(); - } - - delayed! { - let _is_weekend = client.is_weekend().await.unwrap(); - } -} \ No newline at end of file diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..58ba96d --- /dev/null +++ b/src/user.rs @@ -0,0 +1,136 @@ +use crate::{snowflake, util}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// A user's social links. +#[allow(clippy::doc_markdown)] +#[derive(Clone, Debug, Deserialize)] +pub struct Socials { + /// This user's GitHub account URL. + #[serde(skip)] + pub github: Option, + + /// This user's Instagram account URL. + #[serde(skip)] + pub instagram: Option, + + /// This user's Reddit account URL. + #[serde(skip)] + pub reddit: Option, + + /// This user's Twitter account URL. + #[serde(skip)] + pub twitter: Option, + + /// This user's YouTube channel URL. + #[serde(skip)] + pub youtube: Option, +} + +util::debug_struct! { + /// A user logged into Top.gg. + #[derive(Clone, Deserialize)] + User { + public { + /// This user's ID. + #[serde(skip)] + id: u64, + + /// This user's username. + #[serde(skip)] + username: String, + + /// The user's bio. + #[serde(skip)] + bio: Option, + + /// This user's profile banner image. + #[serde(skip)] + banner: Option, + + /// This user's social links. + #[serde(skip)] + socials: Option, + + /// Whether this user is a Top.gg supporter. + #[serde(skip)] + is_supporter: bool, + + /// Whether this user is a Top.gg certified developer. + #[serde(skip)] + is_certified_dev: bool, + + /// Whether this user is a Top.gg moderator. + #[serde(skip)] + is_moderator: bool, + + /// Whether this user is a Top.gg website moderator. + #[serde(skip)] + is_web_moderator: bool, + + /// Whether this user is a Top.gg website administrator. + #[serde(skip)] + is_admin: bool, + } + + private { + #[serde(skip)] + avatar: Option, + } + + getters(self) { + /// This user's creation date. + #[must_use] + #[inline(always)] + created_at: DateTime => { + util::get_creation_date(self.id) + } + + /// This user's avatar URL. + #[must_use] + #[inline(always)] + avatar: String => { + util::get_avatar(self.avatar.as_ref(), self.id) + } + } + } +} + +#[derive(Deserialize)] +pub(crate) struct Voted { + pub(crate) voted: u8, +} + +util::debug_struct! { + /// A user who has voted on a Discord bot listed on Top.gg. (See [`Client::get_voters`][crate::Client::get_voters]) + #[must_use] + #[derive(Clone, Deserialize)] + Voter { + public { + /// This voter's ID. + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, + + /// This voter's username. + username: String, + + /// This voter's avatar URL. + avatar: String, + } + + getters(self) { + /// This voter's creation date. + #[must_use] + #[inline(always)] + created_at: DateTime => { + util::get_creation_date(self.id) + } + + /// This voter's avatar URL. + #[deprecated(since = "1.5.0", note = "Just directly use the public `avatar` property.")] + avatar: String => { + self.avatar.clone() + } + } + } +} diff --git a/src/util.rs b/src/util.rs index 33ca6ad..a9d798f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,8 +1,87 @@ use crate::{snowflake, Error}; use base64::Engine; +use chrono::{DateTime, TimeZone, Utc}; use reqwest::Response; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; +const DISCORD_EPOCH: u64 = 1_420_070_400_000; + +macro_rules! debug_struct { + ( + $(#[$struct_attr:meta])* + $struct_name:ident { + $(public { + $( + $(#[$pub_prop_attr:meta])* + $pub_prop_name:ident: $pub_prop_type:ty, + )* + })? + $(protected { + $( + $(#[$protected_prop_attr:meta])* + $protected_prop_name:ident: $protected_prop_type:ty, + )* + })? + $(private { + $( + $(#[$priv_prop_attr:meta])* + $priv_prop_name:ident: $priv_prop_type:ty, + )* + })? + $(getters($self:ident) { + $( + $(#[$getter_attr:meta])* + $getter_name:ident: $getter_type:ty => $getter_code:tt + )* + })? + } + ) => { + #[allow(deprecated)] + $(#[$struct_attr])* + pub struct $struct_name { + $($( + $(#[$pub_prop_attr])* + pub $pub_prop_name: $pub_prop_type, + )*)? + $($( + $(#[$protected_prop_attr])* + pub(crate) $protected_prop_name: $protected_prop_type, + )*)? + $($( + $(#[$priv_prop_attr])* + $priv_prop_name: $priv_prop_type, + )*)? + } + + $( + #[allow(deprecated)] + impl $struct_name { + $( + $(#[$getter_attr])* + pub fn $getter_name(&$self) -> $getter_type $getter_code + )* + } + )? + + #[allow(deprecated)] + impl std::fmt::Debug for $struct_name { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt + .debug_struct(stringify!($struct_name)) + $($( + .field(stringify!($pub_prop_name), &self.$pub_prop_name) + )*)? + $($( + .field(stringify!($getter_name), &self.$getter_name()) + )*)? + .finish() + } + } + }; +} + +pub(crate) use debug_struct; + #[inline(always)] pub(crate) fn deserialize_optional_string<'de, D>( deserializer: D, @@ -26,6 +105,14 @@ where Option::deserialize(deserializer).map(Option::unwrap_or_default) } +#[inline(always)] +pub(crate) fn get_creation_date(id: u64) -> DateTime { + Utc + .timestamp_millis_opt(((id >> 22) + DISCORD_EPOCH) as _) + .single() + .unwrap() +} + #[inline(always)] pub(crate) async fn parse_json(response: Response) -> crate::Result where @@ -40,6 +127,20 @@ where Err(Error::InternalServerError) } +pub(crate) fn get_avatar(hash: Option<&String>, id: u64) -> String { + match hash { + Some(hash) => { + let ext = if hash.starts_with("a_") { "gif" } else { "png" }; + + format!("https://cdn.discordapp.com/avatars/{id}/{hash}.{ext}?size=1024") + } + _ => format!( + "https://cdn.discordapp.com/embed/avatars/{}.png", + (id >> 22) % 5 + ), + } +} + #[derive(Deserialize)] #[allow(clippy::used_underscore_binding)] struct TokenStructure { diff --git a/src/vote.rs b/src/vote.rs deleted file mode 100644 index 627bf5c..0000000 --- a/src/vote.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::snowflake; -use serde::Deserialize; - -#[derive(Deserialize)] -pub(crate) struct Voted { - pub(crate) voted: u8, -} - -/// A Top.gg voter. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct Voter { - /// This voter's ID. - #[serde(deserialize_with = "snowflake::deserialize")] - pub id: u64, - - /// This voter's username. - #[serde(rename = "username")] - pub name: String, - - /// This voter's avatar URL. - pub avatar: String, -} diff --git a/src/webhooks/actix_web.rs b/src/webhook/actix_web.rs similarity index 61% rename from src/webhooks/actix_web.rs rename to src/webhook/actix_web.rs index 9393140..10109e7 100644 --- a/src/webhooks/actix_web.rs +++ b/src/webhook/actix_web.rs @@ -1,11 +1,10 @@ -use crate::Incoming; +use crate::{IncomingVote, Vote}; use actix_web::{ dev::Payload, - error::{Error, ErrorBadRequest, ErrorUnauthorized}, + error::{Error, ErrorBadRequest}, web::Json, FromRequest, HttpRequest, }; -use serde::de::DeserializeOwned; use std::{ future::Future, pin::Pin, @@ -13,16 +12,13 @@ use std::{ }; #[doc(hidden)] -pub struct IncomingFut { +pub struct IncomingVoteFut { req: HttpRequest, - json_fut: as FromRequest>::Future, + json_fut: as FromRequest>::Future, } -impl Future for IncomingFut -where - T: DeserializeOwned, -{ - type Output = Result, Error>; +impl Future for IncomingVoteFut { + type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { if let Ok(json) = ready!(Pin::new(&mut self.json_fut).poll(cx)) { @@ -30,14 +26,12 @@ where if let Some(authorization) = headers.get("Authorization") { if let Ok(authorization) = authorization.to_str() { - return Poll::Ready(Ok(Incoming { + return Poll::Ready(Ok(IncomingVote { authorization: authorization.to_owned(), - data: json.into_inner(), + vote: json.into_inner(), })); } } - - return Poll::Ready(Err(ErrorUnauthorized("401"))); } Poll::Ready(Err(ErrorBadRequest("400"))) @@ -45,16 +39,13 @@ where } #[cfg_attr(docsrs, doc(cfg(feature = "actix-web")))] -impl FromRequest for Incoming -where - T: DeserializeOwned, -{ +impl FromRequest for IncomingVote { type Error = Error; - type Future = IncomingFut; + type Future = IncomingVoteFut; #[inline(always)] fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { - IncomingFut { + IncomingVoteFut { req: req.clone(), json_fut: Json::from_request(req, payload), } diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs new file mode 100644 index 0000000..be652c6 --- /dev/null +++ b/src/webhook/axum.rs @@ -0,0 +1,151 @@ +use crate::VoteHandler; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use std::sync::Arc; + +struct WebhookState { + state: Arc, + password: Arc, +} + +impl Clone for WebhookState { + #[inline(always)] + fn clone(&self) -> Self { + Self { + state: Arc::clone(&self.state), + password: Arc::clone(&self.password), + } + } +} + +async fn handler( + headers: HeaderMap, + State(webhook): State>, + body: String, +) -> Response +where + T: VoteHandler, +{ + if let Some(authorization) = headers.get("Authorization") { + if let Ok(authorization) = authorization.to_str() { + if authorization == *(webhook.password) { + if let Ok(vote) = serde_json::from_str(&body) { + webhook.state.voted(vote).await; + + return (StatusCode::NO_CONTENT, ()).into_response(); + } + } + } + } + + (StatusCode::UNAUTHORIZED, ()).into_response() +} + +/// Creates a new axum [`Router`] for receiving vote events. +/// +/// # Example +/// +/// Basic usage: +/// +/// ```rust +/// use axum::{routing::get, Router}; +/// use std::sync::Arc; +/// use tokio::net::TcpListener; +/// use topgg::{Vote, VoteHandler}; +/// # +/// # use std::time::Duration; +/// # use tokio::{sync::{oneshot, Notify}, time::sleep}; +/// # +/// # async fn test_request() -> reqwest::Result { +/// # let client = reqwest::Client::new(); +/// # +/// # client +/// # .post("http://127.0.0.1:8080/webhook") +/// # .header("Content-Type", "application/json") +/// # .header("Authorization", env!("TOPGG_WEBHOOK_PASSWORD")) +/// # .body("{\"bot\":\"1026525568344264724\",\"user\":\"661200758510977084\",\"type\":\"test\",\"isWeekend\":false}") +/// # .send() +/// # .await +/// # } +/// +/// struct MyVoteHandler {} +/// +/// #[async_trait::async_trait] +/// impl VoteHandler for MyVoteHandler { +/// async fn voted(&self, vote: Vote) { +/// println!("{:?}", vote); +/// } +/// } +/// +/// async fn index() -> &'static str { +/// "Hello, World!" +/// } +/// +/// #[tokio::main] +/// async fn main() { +/// let state = Arc::new(MyVoteHandler {}); +/// +/// let router = Router::new().route("/", get(index)).nest( +/// "/webhook", +/// topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), +/// ); +/// +/// let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); +/// +/// # let local_immature_thread_closure = Arc::new(Notify::const_new()); +/// # let thread_immature_thread_closure = Arc::clone(&local_immature_thread_closure); +/// # +/// # let (shutdown_tx, shutdown_rx) = oneshot::channel(); +/// # +/// # let test_thread = tokio::spawn(async move { +/// # sleep(Duration::from_secs(5)); +/// # +/// # tokio::select! { +/// # _ = thread_immature_thread_closure.notified() => { +/// # return Ok(None); +/// # } +/// # +/// # result = test_request() => { +/// # shutdown_tx.send(()).unwrap(); +/// # +/// # return result.map(Some); +/// # } +/// # } +/// # }); +/// # +/// # if let Err(why) = +/// axum::serve(listener, router) +/// # .with_graceful_shutdown(async { shutdown_rx.await.ok(); }) +/// .await +/// # { +/// # local_immature_thread_closure.notify_one(); +/// # +/// # panic!("Server error: {why:?}"); +/// # } +/// # /* +/// .unwrap(); +/// # */ +/// # +/// # let test_response = test_thread.await.unwrap().unwrap().unwrap(); +/// # +/// # assert_eq!(test_response.status(), reqwest::StatusCode::NO_CONTENT); +/// } +/// ``` +#[inline(always)] +#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] +pub fn webhook(password: String, state: Arc) -> Router +where + T: VoteHandler, +{ + Router::new() + .route("/", post(handler::)) + .with_state(WebhookState { + state, + password: Arc::new(password), + }) +} diff --git a/src/webhook/mod.rs b/src/webhook/mod.rs new file mode 100644 index 0000000..674de01 --- /dev/null +++ b/src/webhook/mod.rs @@ -0,0 +1,25 @@ +mod vote; +#[cfg_attr(docsrs, doc(cfg(feature = "webhook")))] +pub use vote::*; + +#[cfg(feature = "actix-web")] +mod actix_web; + +#[cfg(feature = "rocket")] +mod rocket; + +cfg_if::cfg_if! { + if #[cfg(feature = "axum")] { + /// Extra helpers for working with axum. + #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] + pub mod axum; + } +} + +cfg_if::cfg_if! { + if #[cfg(feature = "warp")] { + /// Extra helpers for working with warp. + #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] + pub mod warp; + } +} diff --git a/src/webhooks/rocket.rs b/src/webhook/rocket.rs similarity index 55% rename from src/webhooks/rocket.rs rename to src/webhook/rocket.rs index 0bfb988..b389ab4 100644 --- a/src/webhooks/rocket.rs +++ b/src/webhook/rocket.rs @@ -1,31 +1,26 @@ -use crate::Incoming; +use crate::{IncomingVote, Vote}; use rocket::{ data::{Data, FromData, Outcome}, http::Status, request::Request, serde::json::Json, }; -use serde::de::DeserializeOwned; #[cfg_attr(docsrs, doc(cfg(feature = "rocket")))] -#[rocket::async_trait] -impl<'r, T> FromData<'r> for Incoming -where - T: DeserializeOwned, -{ +#[async_trait::async_trait] +impl<'r> FromData<'r> for IncomingVote { type Error = (); async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> { let headers = request.headers(); if let Some(authorization) = headers.get_one("Authorization") { - return match as FromData>::from_data(request, data).await { - Outcome::Success(data) => Outcome::Success(Self { + if let Outcome::Success(vote) = as FromData>::from_data(request, data).await { + return Outcome::Success(Self { authorization: authorization.to_owned(), - data: data.into_inner(), - }), - _ => Outcome::Error((Status::BadRequest, ())), - }; + vote: vote.into_inner(), + }); + } } Outcome::Error((Status::Unauthorized, ())) diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs new file mode 100644 index 0000000..303dce9 --- /dev/null +++ b/src/webhook/vote.rs @@ -0,0 +1,287 @@ +use crate::snowflake; +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; + +#[inline(always)] +fn deserialize_is_test<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + String::deserialize(deserializer).map(|s| s == "test") +} + +fn deserialize_query_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok( + String::deserialize(deserializer) + .map(|s| { + let mut output = HashMap::new(); + + for mut it in s.split('&').map(|pair| pair.split('=')) { + if let (Some(k), Some(v)) = (it.next(), it.next()) { + if let Ok(v) = urlencoding::decode(v) { + output.insert(k.to_owned(), v.into_owned()); + } + } + } + + output + }) + .unwrap_or_default(), + ) +} + +/// A dispatched Top.gg project vote event. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct Vote { + /// The ID of the project that received a vote. + #[serde( + deserialize_with = "snowflake::deserialize", + alias = "bot", + alias = "guild" + )] + pub receiver_id: u64, + + /// The ID of the user who voted. + #[serde(deserialize_with = "snowflake::deserialize", rename = "user")] + pub voter_id: u64, + + /// Whether this vote's receiver is a server or not (bot otherwise). + #[serde(skip)] + #[deprecated(since = "1.5.0", note = "No longer supported.")] + pub is_server: bool, + + /// Whether this vote is just a test coming from the project owner. Most of the time this would be `false`. + #[serde(deserialize_with = "deserialize_is_test", rename = "type")] + pub is_test: bool, + + /// Whether the weekend multiplier is active or not, meaning a single vote counts as two. + /// If the dispatched event came from a server being voted, this will always be `false`. + #[serde(default, rename = "isWeekend")] + pub is_weekend: bool, + + /// Query strings found on the vote page. + #[serde(default, deserialize_with = "deserialize_query_string")] + pub query: HashMap, +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "actix-web", feature = "rocket"))] { + /// An **unauthenticated** request containing a [`Vote`] data. + /// + /// To authenticate this structure with a valid password and consume the [`Vote`] data inside of it, see the [`authenticate`][IncomingVote::authenticate] method. + #[must_use] + #[cfg_attr(docsrs, doc(cfg(any(feature = "actix-web", feature = "rocket"))))] + #[derive(Clone)] + pub struct IncomingVote { + pub(crate) authorization: String, + pub(crate) vote: Vote, + } + + impl IncomingVote { + /// Authenticates a valid password with this request. Returns a [`Some(Vote)`][`Vote`] if succeeds, otherwise `None`. + /// + /// # Examples + /// + /// Basic usage with actix-web: + /// + /// ```rust + /// use actix_web::{error::{Error, ErrorUnauthorized}, post}; + /// use topgg::IncomingVote; + /// # + /// # use actix_web::{get, App, HttpServer}; + /// # use std::{sync::Arc, time::Duration}; + /// # use tokio::{sync::{oneshot, Notify}, time::sleep}; + /// # + /// # async fn test_request() -> reqwest::Result { + /// # let client = reqwest::Client::new(); + /// # + /// # client + /// # .post("http://127.0.0.1:8080/webhook") + /// # .header("Content-Type", "application/json") + /// # .header("Authorization", env!("TOPGG_WEBHOOK_PASSWORD")) + /// # .body("{\"bot\":\"1026525568344264724\",\"user\":\"661200758510977084\",\"type\":\"test\",\"isWeekend\":false}") + /// # .send() + /// # .await + /// # } + /// + /// #[post("/webhook")] + /// async fn voted(incoming_vote: IncomingVote) -> Result<&'static str, Error> { + /// match incoming_vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { + /// Some(vote) => { + /// println!("{:?}", vote); + /// + /// Ok("ok") + /// }, + /// _ => { + /// println!("found an unauthorized attacker."); + /// + /// Err(ErrorUnauthorized("401")) + /// } + /// } + /// } + /// # + /// # #[get("/")] + /// # async fn index() -> &'static str { + /// # "Hello, World!" + /// # } + /// # + /// # #[actix_web::main] + /// # async fn main() { + /// # let server = HttpServer::new(|| App::new().service(index).service(voted)) + /// # .bind("127.0.0.1:8080") + /// # .unwrap() + /// # .run(); + /// # + /// # let server_handle = server.handle(); + /// # + /// # let local_immature_thread_closure = Arc::new(Notify::const_new()); + /// # let thread_immature_thread_closure = Arc::clone(&local_immature_thread_closure); + /// # + /// # let test_thread = tokio::spawn(async move { + /// # sleep(Duration::from_secs(5)); + /// # + /// # tokio::select! { + /// # _ = thread_immature_thread_closure.notified() => { + /// # return Ok(None); + /// # } + /// # + /// # result = test_request() => { + /// # server_handle.stop(true); + /// # + /// # return result.map(Some); + /// # } + /// # } + /// # }); + /// # + /// # if let Err(why) = server.await { + /// # local_immature_thread_closure.notify_one(); + /// # + /// # panic!("Server error: {why:?}"); + /// # } + /// # + /// # let test_response = test_thread.await.unwrap().unwrap().unwrap(); + /// # + /// # assert_eq!(test_response.status(), reqwest::StatusCode::OK); + /// # } + /// ``` + /// + /// Basic usage with rocket: + /// + /// ```rust + /// use rocket::{http::Status, post}; + /// use topgg::IncomingVote; + /// # + /// # use rocket::{get, launch, routes, Config}; + /// # use std::{sync::Arc, time::Duration}; + /// # use tokio::{sync::{oneshot, Notify}, time::sleep}; + /// # + /// # async fn test_request() -> reqwest::Result { + /// # let client = reqwest::Client::new(); + /// # + /// # client + /// # .post("http://127.0.0.1:8080/webhook") + /// # .header("Content-Type", "application/json") + /// # .header("Authorization", env!("TOPGG_WEBHOOK_PASSWORD")) + /// # .body("{\"bot\":\"1026525568344264724\",\"user\":\"661200758510977084\",\"type\":\"test\",\"isWeekend\":false}") + /// # .send() + /// # .await + /// # } + /// + /// #[post("/webhook", data = "")] + /// fn voted(incoming_vote: IncomingVote) -> Status { + /// match incoming_vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { + /// Some(vote) => { + /// println!("{:?}", vote); + /// + /// Status::NoContent + /// }, + /// _ => { + /// println!("found an unauthorized attacker."); + /// + /// Status::Unauthorized + /// } + /// } + /// } + /// # + /// # #[get("/")] + /// # fn index() -> &'static str { + /// # "Hello, World!" + /// # } + /// # + /// # #[rocket::main] + /// # async fn main() { + /// # let config = Config { + /// # address: "127.0.0.1".parse().unwrap(), + /// # port: 8080, + /// # ..Config::default() + /// # }; + /// # + /// # let rocket = rocket::custom(config).mount("/", routes![index, voted]).ignite().await.unwrap(); + /// # let shutdown = rocket.shutdown(); + /// # + /// # let local_immature_thread_closure = Arc::new(Notify::const_new()); + /// # let thread_immature_thread_closure = Arc::clone(&local_immature_thread_closure); + /// # + /// # let test_thread = tokio::spawn(async move { + /// # tokio::select! { + /// # _ = thread_immature_thread_closure.notified() => { + /// # return Ok(None); + /// # } + /// # + /// # result = test_request() => { + /// # shutdown.notify(); + /// # + /// # return result.map(Some); + /// # } + /// # } + /// # }); + /// # + /// # if let Err(why) = rocket.launch().await { + /// # local_immature_thread_closure.notify_one(); + /// # + /// # panic!("Server error: {why:?}"); + /// # } + /// # + /// # let test_response = test_thread.await.unwrap().unwrap().unwrap(); + /// # + /// # assert_eq!(test_response.status(), reqwest::StatusCode::NO_CONTENT); + /// # } + /// ``` + #[must_use] + #[inline(always)] + pub fn authenticate(self, password: &str) -> Option { + if self.authorization == password { + Some(self.vote) + } else { + None + } + } + } + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "axum", feature = "warp"))] { + /// An async trait for adding an on-vote event handler to your application logic. + /// + /// It's described as follows (without [`async_trait`]'s macro expansion): + /// ```rust + /// # use topgg::Vote; + /// # + /// #[async_trait::async_trait] + /// pub trait VoteHandler: Send + Sync + 'static { + /// async fn voted(&self, vote: Vote); + /// } + /// ``` + #[cfg_attr(docsrs, doc(cfg(any(feature = "axum", feature = "warp"))))] + #[async_trait::async_trait] + pub trait VoteHandler: Send + Sync + 'static { + /// Your vote handler's on-vote async callback. + async fn voted(&self, vote: Vote); + } + } +} diff --git a/src/webhook/warp.rs b/src/webhook/warp.rs new file mode 100644 index 0000000..e4be02a --- /dev/null +++ b/src/webhook/warp.rs @@ -0,0 +1,104 @@ +use crate::{Vote, VoteHandler}; +use std::sync::Arc; +use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; + +/// Creates a new warp [`Filter`] for adding an on-vote event handler to your application logic. +/// +/// # Example +/// +/// Basic usage: +/// +/// ```rust +/// use std::sync::Arc; +/// use tokio::net::TcpListener; +/// use topgg::{Vote, VoteHandler}; +/// use warp::Filter; +/// # +/// # use std::time::Duration; +/// # use tokio::{sync::oneshot, time::sleep}; +/// +/// struct MyVoteHandler {} +/// +/// #[async_trait::async_trait] +/// impl VoteHandler for MyVoteHandler { +/// async fn voted(&self, vote: Vote) { +/// println!("{:?}", vote); +/// } +/// } +/// +/// #[tokio::main] +/// async fn main() { +/// let state = Arc::new(MyVoteHandler {}); +/// +/// // POST /webhook +/// let webhook = topgg::warp::webhook( +/// "webhook", +/// env!("TOPGG_WEBHOOK_PASSWORD").to_string(), +/// Arc::clone(&state), +/// ); +/// +/// let routes = warp::get().map(|| "Hello, World!").or(webhook); +/// +/// let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); +/// # +/// # let (shutdown_tx, shutdown_rx) = oneshot::channel(); +/// # +/// # let test_thread = tokio::spawn(async move { +/// # sleep(Duration::from_secs(5)); +/// # +/// # let client = reqwest::Client::new(); +/// # +/// # let response = client +/// # .post("http://127.0.0.1:8080/webhook") +/// # .header("Content-Type", "application/json") +/// # .header("Authorization", env!("TOPGG_WEBHOOK_PASSWORD")) +/// # .body("{\"bot\":\"1026525568344264724\",\"user\":\"661200758510977084\",\"type\":\"test\",\"isWeekend\":false}") +/// # .send() +/// # .await; +/// # +/// # shutdown_tx.send(()).unwrap(); +/// # +/// # response +/// # }); +/// +/// warp::serve(routes) +/// .incoming(listener) +/// # .graceful(async { shutdown_rx.await.ok(); }) +/// .run() +/// .await; +/// # +/// # let test_response = test_thread.await.unwrap().unwrap(); +/// # +/// # assert_eq!(test_response.status(), reqwest::StatusCode::NO_CONTENT); +/// } +/// ``` +#[cfg_attr(docsrs, doc(cfg(feature = "warp")))] +pub fn webhook( + endpoint: &'static str, + password: String, + state: Arc, +) -> impl Filter + Clone +where + T: VoteHandler, +{ + let password = Arc::new(password); + + warp::post() + .and(path(endpoint)) + .and(header("Authorization")) + .and(body::json()) + .then(move |auth: String, vote: Vote| { + let current_state = Arc::clone(&state); + let current_password = Arc::clone(&password); + + async move { + if auth == *current_password { + current_state.voted(vote).await; + + StatusCode::NO_CONTENT + } else { + StatusCode::UNAUTHORIZED + } + } + }) +} diff --git a/src/webhooks/axum.rs b/src/webhooks/axum.rs deleted file mode 100644 index 4175b1b..0000000 --- a/src/webhooks/axum.rs +++ /dev/null @@ -1,96 +0,0 @@ -use super::Webhook; -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - response::IntoResponse, - routing::post, - Router, -}; -use serde::de::DeserializeOwned; -use std::sync::Arc; - -struct WebhookState { - state: Arc, - password: Arc, -} - -impl Clone for WebhookState { - #[inline(always)] - fn clone(&self) -> Self { - Self { - state: Arc::clone(&self.state), - password: Arc::clone(&self.password), - } - } -} - -/// Creates a new axum [`Router`] for receiving vote events. -/// -/// # Example -/// -/// ```rust,no_run -/// use axum::{routing::get, Router}; -/// use topgg::{VoteEvent, Webhook}; -/// use tokio::net::TcpListener; -/// use std::sync::Arc; -/// -/// struct MyVoteListener {} -/// -/// #[async_trait::async_trait] -/// impl Webhook for MyVoteListener { -/// async fn callback(&self, vote: VoteEvent) { -/// println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); -/// } -/// } -/// -/// async fn index() -> &'static str { -/// "Hello, World!" -/// } -/// -/// #[tokio::main] -/// async fn main() { -/// let state = Arc::new(MyVoteListener {}); -/// -/// let router = Router::new().route("/", get(index)).nest( -/// "/votes", -/// topgg::axum::webhook(env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), Arc::clone(&state)), -/// ); -/// -/// let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); -/// -/// axum::serve(listener, router).await.unwrap(); -/// } -/// ``` -#[inline(always)] -#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] -pub fn webhook(password: String, state: Arc) -> Router -where - D: DeserializeOwned + Send, - T: Webhook, -{ - Router::new() - .route( - "/", - post( - async |headers: HeaderMap, State(webhook): State>, body: String| { - if let Some(authorization) = headers.get("Authorization") { - if let Ok(authorization) = authorization.to_str() { - if authorization == *(webhook.password) { - if let Ok(data) = serde_json::from_str(&body) { - webhook.state.callback(data).await; - - return (StatusCode::NO_CONTENT, ()).into_response(); - } - } - } - } - - (StatusCode::UNAUTHORIZED, ()).into_response() - }, - ), - ) - .with_state(WebhookState { - state, - password: Arc::new(password), - }) -} diff --git a/src/webhooks/mod.rs b/src/webhooks/mod.rs deleted file mode 100644 index a5ec591..0000000 --- a/src/webhooks/mod.rs +++ /dev/null @@ -1,74 +0,0 @@ -mod vote; -#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] -pub use vote::*; - -#[cfg(feature = "actix-web")] -mod actix_web; - -#[cfg(feature = "rocket")] -mod rocket; - -cfg_if::cfg_if! { - if #[cfg(feature = "axum")] { - /// Extra helpers for working with axum. - #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] - pub mod axum; - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "warp")] { - /// Extra helpers for working with warp. - #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] - pub mod warp; - } -} - -cfg_if::cfg_if! { - if #[cfg(any(feature = "actix-web", feature = "rocket"))] { - /// An unauthenticated incoming Top.gg webhook request. - #[must_use] - #[cfg_attr(docsrs, doc(cfg(any(feature = "actix-web", feature = "rocket"))))] - pub struct Incoming { - pub(crate) authorization: String, - pub(crate) data: T, - } - - impl Incoming { - /// Authenticates a valid password with this request. - #[must_use] - #[inline(always)] - pub fn authenticate(self, password: &str) -> Option { - if self.authorization == password { - Some(self.data) - } else { - None - } - } - } - - impl Clone for Incoming - where - T: Clone, - { - #[inline(always)] - fn clone(&self) -> Self { - Self { - authorization: self.authorization.clone(), - data: self.data.clone(), - } - } - } - } -} - -cfg_if::cfg_if! { - if #[cfg(any(feature = "axum", feature = "warp"))] { - /// Webhook event handler. - #[cfg_attr(docsrs, doc(cfg(any(feature = "axum", feature = "warp"))))] - #[async_trait::async_trait] - pub trait Webhook: Send + Sync + 'static { - async fn callback(&self, data: T); - } - } -} diff --git a/src/webhooks/vote.rs b/src/webhooks/vote.rs deleted file mode 100644 index 0bbbb54..0000000 --- a/src/webhooks/vote.rs +++ /dev/null @@ -1,67 +0,0 @@ -use crate::snowflake; -use serde::{Deserialize, Deserializer}; -use std::collections::HashMap; - -#[inline(always)] -fn deserialize_is_test<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - String::deserialize(deserializer).map(|s| s == "test") -} - -fn deserialize_query_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok( - String::deserialize(deserializer) - .map(|s| { - let mut output = HashMap::new(); - - for mut it in s - .trim_start_matches('?') - .split('&') - .map(|pair| pair.split('=')) - { - if let (Some(k), Some(v)) = (it.next(), it.next()) { - if let Ok(v) = urlencoding::decode(v) { - output.insert(k.to_owned(), v.into_owned()); - } - } - } - - output - }) - .unwrap_or_default(), - ) -} - -/// A dispatched Top.gg vote event. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct VoteEvent { - /// The ID of the project that received a vote. - #[serde( - deserialize_with = "snowflake::deserialize", - alias = "bot", - alias = "guild" - )] - pub receiver_id: u64, - - /// The ID of the Top.gg user who voted. - #[serde(deserialize_with = "snowflake::deserialize", rename = "user")] - pub voter_id: u64, - - /// Whether this vote is just a test done from the page settings. - #[serde(deserialize_with = "deserialize_is_test", rename = "type")] - pub is_test: bool, - - /// Whether the weekend multiplier is active, where a single vote counts as two. - #[serde(default, rename = "isWeekend")] - pub is_weekend: bool, - - /// Query strings found on the vote page. - #[serde(default, deserialize_with = "deserialize_query_string")] - pub query: HashMap, -} diff --git a/src/webhooks/warp.rs b/src/webhooks/warp.rs deleted file mode 100644 index 51c108b..0000000 --- a/src/webhooks/warp.rs +++ /dev/null @@ -1,72 +0,0 @@ -use super::Webhook; -use serde::de::DeserializeOwned; -use std::sync::Arc; -use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; - -/// Creates a new warp [`Filter`] for receiving webhook events. -/// -/// # Example -/// -/// ```rust,no_run -/// use std::{net::SocketAddr, sync::Arc}; -/// use topgg::{VoteEvent, Webhook}; -/// use warp::Filter; -/// -/// struct MyVoteListener {} -/// -/// #[async_trait::async_trait] -/// impl Webhook for MyVoteListener { -/// async fn callback(&self, vote: VoteEvent) { -/// println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); -/// } -/// } -/// -/// #[tokio::main] -/// async fn main() { -/// let state = Arc::new(MyVoteListener {}); -/// -/// // POST /votes -/// let webhook = topgg::warp::webhook( -/// "votes", -/// env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), -/// Arc::clone(&state), -/// ); -/// -/// let routes = warp::get().map(|| "Hello, World!").or(webhook); -/// -/// let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); -/// -/// warp::serve(routes).run(addr).await -/// } -/// ``` -#[cfg_attr(docsrs, doc(cfg(feature = "warp")))] -pub fn webhook( - endpoint: &'static str, - password: String, - state: Arc, -) -> impl Filter + Clone -where - D: DeserializeOwned + Send, - T: Webhook, -{ - let password = Arc::new(password); - - warp::post() - .and(path(endpoint)) - .and(header("Authorization")) - .and(body::json()) - .then(move |auth: String, data: D| { - let current_state = Arc::clone(&state); - let current_password = Arc::clone(&password); - - async move { - if auth == *current_password { - current_state.callback(data).await; - - StatusCode::NO_CONTENT - } else { - StatusCode::UNAUTHORIZED - } - } - }) -}