From 0419faefe6b1444616b17dbfe6ef7089b2c08ece Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 17 Sep 2025 21:07:36 +0700 Subject: [PATCH 1/8] revert: revert back to f8a59ca275118a1de5b209cf0a05e13ef0288df6 --- Cargo.toml | 50 +- src/{bot_autoposter => autoposter}/client.rs | 4 +- src/autoposter/mod.rs | 254 ++++++++++ .../serenity_impl.rs | 77 +-- src/autoposter/twilight_impl.rs | 63 +++ src/bot.rs | 475 +++++++++++------- src/bot_autoposter/mod.rs | 354 ------------- src/bot_autoposter/twilight_impl.rs | 65 --- src/client.rs | 284 ++++------- src/error.rs | 38 +- src/lib.rs | 46 +- src/snowflake.rs | 259 +++++----- src/test.rs | 54 -- src/user.rs | 141 ++++++ src/util.rs | 127 ++++- src/vote.rs | 23 - src/{webhooks => webhook}/actix_web.rs | 35 +- src/webhook/axum.rs | 102 ++++ src/webhook/mod.rs | 25 + src/{webhooks => webhook}/rocket.rs | 19 +- src/webhook/vote.rs | 151 ++++++ src/{webhooks => webhook}/warp.rs | 38 +- src/webhooks/axum.rs | 96 ---- src/webhooks/mod.rs | 74 --- src/webhooks/vote.rs | 67 --- 25 files changed, 1486 insertions(+), 1435 deletions(-) rename src/{bot_autoposter => autoposter}/client.rs (54%) create mode 100644 src/autoposter/mod.rs rename src/{bot_autoposter => autoposter}/serenity_impl.rs (70%) create mode 100644 src/autoposter/twilight_impl.rs delete mode 100644 src/bot_autoposter/mod.rs delete mode 100644 src/bot_autoposter/twilight_impl.rs delete mode 100644 src/test.rs create mode 100644 src/user.rs delete mode 100644 src/vote.rs rename src/{webhooks => webhook}/actix_web.rs (58%) create mode 100644 src/webhook/axum.rs create mode 100644 src/webhook/mod.rs rename src/{webhooks => webhook}/rocket.rs (57%) create mode 100644 src/webhook/vote.rs rename src/{webhooks => webhook}/warp.rs (61%) delete mode 100644 src/webhooks/axum.rs delete mode 100644 src/webhooks/mod.rs delete mode 100644 src/webhooks/vote.rs diff --git a/Cargo.toml b/Cargo.toml index 3cfece0..5eccf05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "topgg" -version = "2.0.0" +version = "1.4.2" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] -description = "A simple API wrapper for Top.gg written in Rust." +description = "The official Rust wrapper for the Top.gg API" readme = "README.md" repository = "https://github.com/Top-gg-Community/rust-sdk" license = "MIT" @@ -12,50 +12,27 @@ categories = ["api-bindings", "web-programming::http-client"] exclude = [".gitattributes", ".github/", ".gitignore", "rustfmt.toml"] [dependencies] -base64 = { version = "0.22", optional = true } cfg-if = "1" paste = { version = "1", optional = true } reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } -urlencoding = "2" +urlencoding = { version = "2", optional = true } 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"] } +axum = { version = "0.7", 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 } -[dev-dependencies] -tokio = { version = "1", features = ["rt", "macros"] } -twilight-gateway = "0.15" - -[lints.clippy] -all = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -cast-lossless = "allow" -cast-possible-truncation = "allow" -cast-possible-wrap = "allow" -cast-sign-loss = "allow" -inline-always = "allow" -module-name-repetitions = "allow" -must-use-candidate = "allow" -return-self-not-must-use = "allow" -similar-names = "allow" -single-match-else = "allow" -too-many-lines = "allow" -unnecessary-wraps = "allow" -unreadable-literal = "allow" - [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] @@ -63,18 +40,17 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["async-trait", "base64", "chrono", "reqwest", "serde_json"] -bot-autoposter = ["api", "tokio"] -autoposter = ["bot-autoposter"] +api = ["chrono", "reqwest", "serde_json"] +autoposter = ["api", "tokio"] serenity = ["dep:serenity", "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 = ["urlencoding"] +rocket = ["webhook", "dep:rocket"] +axum = ["webhook", "async-trait", "serde_json", "dep:axum"] +warp = ["webhook", "async-trait", "dep:warp"] +actix-web = ["webhook", "dep:actix-web"] diff --git a/src/bot_autoposter/client.rs b/src/autoposter/client.rs similarity index 54% rename from src/bot_autoposter/client.rs rename to src/autoposter/client.rs index 35118a6..be95464 100644 --- a/src/bot_autoposter/client.rs +++ b/src/autoposter/client.rs @@ -5,7 +5,9 @@ pub trait AsClientSealed { fn as_client(&self) -> Arc; } -/// Any datatype that can be interpreted as a [`Client`][crate::Client]. +/// A private trait that represents any datatype that can be interpreted as a [Top.gg API](https://docs.top.gg) Client. +/// +/// This can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. pub trait AsClient: AsClientSealed {} impl AsClientSealed for str { diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs new file mode 100644 index 0000000..4308260 --- /dev/null +++ b/src/autoposter/mod.rs @@ -0,0 +1,254 @@ +use crate::{Result, Stats}; +use core::{ + ops::{Deref, DerefMut}, + time::Duration, +}; +use std::sync::Arc; +use tokio::{ + sync::{mpsc, RwLock, RwLockWriteGuard, Semaphore}, + 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; + } +} + +/// A struct representing a thread-safe form of the [`Stats`] struct to be used in autoposter [`Handler`]s. +pub struct SharedStats { + sem: Semaphore, + 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> { + sem: &'a Semaphore, + guard: RwLockWriteGuard<'a, Stats>, +} + +impl SharedStatsGuard<'_> { + /// Directly replaces the current [`Stats`] inside with the other. + #[inline(always)] + pub fn replace(&mut self, other: Stats) { + let ref_mut = self.guard.deref_mut(); + *ref_mut = other; + } + + /// Sets the current [`Stats`] server count. + #[inline(always)] + pub fn set_server_count(&mut self, server_count: usize) { + 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.deref() + } +} + +impl DerefMut for SharedStatsGuard<'_> { + #[inline(always)] + fn deref_mut(&mut self) -> &mut Self::Target { + self.guard.deref_mut() + } +} + +impl Drop for SharedStatsGuard<'_> { + #[inline(always)] + fn drop(&mut self) { + if self.sem.available_permits() < 1 { + self.sem.add_permits(1); + } + } +} + +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 { + sem: Semaphore::const_new(0), + 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<'a>(&'a self) -> SharedStatsGuard<'a> { + SharedStatsGuard { + sem: &self.sem, + guard: self.stats.write().await, + } + } + + #[inline(always)] + async fn wait(&self) { + self.sem.acquire().await.unwrap().forget(); + } +} + +/// 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 guild/shard count. +pub trait Handler: Send + Sync + 'static { + /// The method that borrows [`SharedStats`] to the [`Autoposter`]. + fn stats(&self) -> &SharedStats; +} + +/// A struct that lets you automate the process of posting bot statistics to [Top.gg](https://top.gg) in intervals. +/// +/// **NOTE:** This struct owns the thread handle that executes the automatic posting. The autoposter thread will stop once this struct is dropped. +#[must_use] +pub struct Autoposter { + handler: Arc, + thread: JoinHandle<()>, + receiver: Option>>, +} + +impl Autoposter +where + H: Handler, +{ + /// Creates an [`Autoposter`] struct as well as immediately starting the thread. The thread will never stop until this struct gets dropped. + /// + /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. + /// - `handler` is a struct that handles the *retrieving stats* part before being sent to the [`Autoposter`]. This datatype is essentially the bridge between an external third-party Discord Bot library between this library. + /// + /// # Panics + /// + /// Panics if the interval argument is shorter than 15 minutes (900 seconds). + pub fn new(client: &C, handler: H, interval: Duration) -> Self + where + C: AsClient, + { + assert!( + interval.as_secs() >= 900, + "The interval mustn't be shorter than 15 minutes." + ); + + 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 { + handler.stats().wait().await; + + { + let stats = handler.stats().stats.read().await; + + if sender.send(client.post_stats(&stats).await).is_err() { + break; + } + }; + + sleep(interval).await; + } + }), + receiver: Some(receiver), + } + } + + /// Retrieves the [`Handler`] inside in the form of a [cloned][Arc::clone] [`Arc`][Arc]. + #[inline(always)] + pub fn handler(&self) -> Arc { + Arc::clone(&self.handler) + } + + /// Returns a future that resolves every time the [`Autoposter`] has attempted to post the bot's stats. If you want to use the receiver directly, call [`receiver`]. + #[inline(always)] + pub async fn recv(&mut self) -> Option> { + self.receiver.as_mut().expect("receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await + } + + /// Takes the receiver responsible for [`recv`]. Subsequent calls to this function and [`recv`] after this call will panic. + #[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.deref() + } +} + +#[cfg(feature = "serenity")] +#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] +impl Autoposter { + /// Creates an [`Autoposter`] struct from an existing built-in [serenity] [`Handler`] as well as immediately starting the thread. The thread will never stop until this struct gets dropped. + /// + /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. + /// + /// # Panics + /// + /// Panics if the interval argument is shorter than 15 minutes (900 seconds). + #[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 Autoposter { + /// Creates an [`Autoposter`] struct from an existing built-in [twilight](https://twilight.rs) [`Handler`] as well as immediately starting the thread. The thread will never stop until this struct gets dropped. + /// + /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. + /// + /// # Panics + /// + /// Panics if the interval argument is shorter than 15 minutes (900 seconds). + #[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 70% rename from src/bot_autoposter/serenity_impl.rs rename to src/autoposter/serenity_impl.rs index 327344f..22c19d5 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"))] { @@ -18,15 +17,17 @@ cfg_if::cfg_if! { struct Cache { guilds: HashSet, } + } else { + use std::ops::Add; } } -/// [`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,15 +50,11 @@ macro_rules! serenity_handler { cache: Mutex::const_new(Cache { guilds: HashSet::new(), }), - server_count: RwLock::new(0), + stats: SharedStats::new(), } } - /// Handles an entire serenity [`FullEvent`] enum. This can be used in serenity frameworks. - /// - /// # Panics - /// - /// The `serenity-cached` feature is enabled but the bot doesn't cache guilds. + /// Handles an entire [serenity] [`FullEvent`] enum. This can be used in [serenity] frameworks. pub async fn handle(&$self, $context: &Context, event: &FullEvent) { match event { $( @@ -97,19 +94,19 @@ serenity_handler! { (self, context) => { ready { map(data_about_bot: Ready) { - self.handle_ready(&data_about_bot.guilds).await; + self.handle_ready(&data_about_bot.guilds).await } 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"))] { let mut cache = self.cache.lock().await; - cache.guilds = guilds.iter().map(|x| x.id).collect(); + cache.guilds = guilds.into_iter().map(|x| x.id).collect(); } } } @@ -118,13 +115,27 @@ serenity_handler! { #[cfg(feature = "serenity-cached")] cache_ready { map(guilds: Vec) { - self.handle_cache_ready(guilds.len()).await; + self.handle_cache_ready(guilds.len()).await } 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); + } + } + + #[cfg(feature = "serenity-cached")] + shards_ready { + map(total_shards: u32) { + // turns either &u32 or u32 to a u32 :) + self.handle_shards_ready(total_shards.add(0)).await + } + + handle(shard_count: u32) { + let mut stats = self.stats.write().await; + + stats.set_shard_count(shard_count as _); } } @@ -133,8 +144,8 @@ serenity_handler! { self.handle_guild_create( #[cfg(not(feature = "serenity-cached"))] guild.id, #[cfg(feature = "serenity-cached")] context.cache.guilds().len(), - #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the bot doesn't cache guilds."), - ).await; + #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the discord bot doesn't cache guilds"), + ).await } handle( @@ -144,17 +155,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()); } } } @@ -166,7 +177,7 @@ serenity_handler! { self.handle_guild_delete( #[cfg(feature = "serenity-cached")] context.cache.guilds().len(), #[cfg(not(feature = "serenity-cached"))] incomplete.id - ).await; + ).await } handle( @@ -174,16 +185,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 +203,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..df225ff --- /dev/null +++ b/src/autoposter/twilight_impl.rs @@ -0,0 +1,63 @@ +use crate::autoposter::{Handler, SharedStats}; +use std::{collections::HashSet, ops::DerefMut}; +use tokio::sync::Mutex; +use twilight_model::gateway::event::Event; + +/// A built-in [`Handler`] for the [twilight](https://twilight.rs) 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](https://twilight.rs) [`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 = cache.deref_mut(); + + *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..81670d2 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,206 +1,323 @@ -use crate::{snowflake, util, Client}; +use crate::{snowflake, util}; 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. - #[serde(rename = "averageScore")] - pub score: f64, - - /// This bot's review count. - pub count: usize, +use serde::{Deserialize, Deserializer, Serialize}; + +#[inline(always)] +pub(crate) fn deserialize_support_server<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + util::deserialize_optional_string(deserializer) + .map(|inner| inner.map(|support| format!("https://discord.com/invite/{support}"))) } -/// 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 struct representing a Discord Bot listed on [Top.gg](https://top.gg). + #[must_use] + #[derive(Clone, Deserialize)] + Bot { + public { + /// The ID of this Discord bot. + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, + + /// The username of this Discord bot. + username: String, + + /// The discriminator of this Discord bot. + discriminator: String, + + /// The prefix of this Discord bot. + prefix: String, + + /// The short description of this Discord bot. + #[serde(rename = "shortdesc")] + short_description: String, + + /// The long description of this Discord bot. It can contain HTML and/or Markdown. + #[serde( + default, + deserialize_with = "util::deserialize_optional_string", + rename = "longdesc" + )] + long_description: Option, + + /// The tags of this Discord bot. + #[serde(default, deserialize_with = "util::deserialize_default")] + tags: Vec, + + /// The website URL of this Discord bot. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + website: Option, + + /// The link to this Discord bot's GitHub repository. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + github: Option, + + /// A list of IDs of this Discord bot's owners. The main owner is the first ID in the array. + #[serde(deserialize_with = "snowflake::deserialize_vec")] + owners: Vec, + + /// A list of IDs of the guilds featured on this Discord bot's page. + #[serde(default, deserialize_with = "snowflake::deserialize_vec")] + guilds: Vec, + + /// The URL for this Discord bot's banner image. + #[serde( + default, + deserialize_with = "util::deserialize_optional_string", + rename = "bannerUrl" + )] + banner_url: Option, + + /// The date when this Discord bot was approved on [Top.gg](https://top.gg). + #[serde(rename = "date")] + approved_at: DateTime, + + /// Whether this Discord bot is [Top.gg](https://top.gg) certified or not. + #[serde(rename = "certifiedBot")] + is_certified: bool, + + /// A list of this Discord bot's shards. + #[serde(default, deserialize_with = "util::deserialize_default")] + shards: Vec, + + /// The amount of upvotes this Discord bot has. + #[serde(rename = "points")] + votes: usize, + + /// The amount of upvotes this Discord bot has this month. + #[serde(rename = "monthlyPoints")] + monthly_votes: usize, + + /// The support server invite URL of this Discord bot. + #[serde(default, deserialize_with = "deserialize_support_server")] + support: Option, + } -#[derive(Serialize, Deserialize)] -pub(crate) struct BotStats { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) server_count: Option, -} + private { + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + avatar: Option, -#[derive(Deserialize)] -pub(crate) struct Bots { - pub(crate) results: Vec, -} + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + invite: Option, -#[derive(Deserialize)] -pub(crate) struct IsWeekend { - pub(crate) is_weekend: bool, -} + shard_count: Option, -/// Query for [`Client::get_bots`]. -#[must_use] -pub struct BotQuery<'a> { - client: &'a Client, - query: HashMap<&'static str, 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); - )*) => {$( - $(#[doc = $doc])* - pub fn $lib_name(mut self, $lib_name: $lib_type) -> Self { - self.$property.insert(stringify!($api_name), $lib_value); - self + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + vanity: Option, } - )*}; -} -macro_rules! get_bots_sort { - ($( - $(#[doc = $doc:literal])* - $func_name:ident: $api_name:ident, - )*) => {$( - $(#[doc = $doc])* - pub fn $func_name(mut self) -> Self { - self.sort.replace(stringify!($api_name)); - self + getters(self) { + /// Retrieves the creation date of this bot. + #[must_use] + #[inline(always)] + created_at: DateTime => { + util::get_creation_date(self.id) + } + + /// Retrieves the avatar URL of this bot. + /// + /// Its format will either be PNG or GIF if animated. + #[must_use] + #[inline(always)] + avatar: String => { + util::get_avatar(&self.avatar, self.id) + } + + /// The invite URL of this Discord bot. + #[must_use] + invite: String => { + match &self.invite { + Some(inv) => inv.to_owned(), + _ => format!( + "https://discord.com/oauth2/authorize?scope=bot&client_id={}", + self.id + ), + } + } + + /// The amount of shards this Discord bot has according to posted stats. + #[must_use] + #[inline(always)] + shard_count: usize => { + self.shard_count.unwrap_or(self.shards.len()) + } + + /// Retrieves the URL of this Discord bot's [Top.gg](https://top.gg) page. + #[must_use] + #[inline(always)] + url: String => { + format!( + "https://top.gg/bot/{}", + self.vanity.as_deref().unwrap_or(&self.id.to_string()) + ) + } } - )*}; + } } -impl<'a> BotQuery<'a> { - #[inline(always)] - pub(crate) fn new(client: &'a Client) -> Self { - Self { - client, - query: HashMap::new(), - sort: None, +util::debug_struct! { + /// A struct representing a Discord bot's statistics. + /// + /// # Examples + /// + /// Solely from a server count: + /// + /// ```rust,no_run + /// use topgg::Stats; + /// + /// let _stats = Stats::from(12345); + /// ``` + /// + /// Server count with a shard count: + /// + /// ```rust,no_run + /// 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,no_run + /// 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, } - } - - get_bots_sort! { - /// 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 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()); + 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, + } - /// Sets the amount of bots to be skipped. - skip: u16 = query(offset, min(skip, 499).to_string()); + getters(self) { + /// An array of this Discord bot's server count for each shard. + #[must_use] + #[inline(always)] + shards: &[usize] => { + match self.shards { + Some(ref shards) => shards, + None => &[], + } + } + + /// The amount of shards this Discord bot has. + #[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 publy 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<'a> IntoFuture for BotQuery<'a> { - type Output = crate::Result>; - type IntoFuture = Pin + Send + 'a>>; +impl Stats { + /// Creates a [`Stats`] struct from the cache of a serenity [`Context`][serenity::client::Context]. + #[inline(always)] + #[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 _), + ) + } - fn into_future(self) -> Self::IntoFuture { - let mut path = String::from("/bots?"); + /// 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, + } + } - if let Some(sort) = self.sort { - write!(&mut path, "sort={sort}&").unwrap(); + /// 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. + /// + /// # Examples + /// + /// Basic usage: + /// + /// ```rust,no_run + /// 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); } - for (key, value) in self.query { - write!(&mut path, "{key}={value}&").unwrap(); + if let Some(index) = shard_index { + assert!(index < shards_list.len(), "Shard index out of range."); } - path.pop(); + Self { + server_count: Some(total_server_count), + shard_count: Some(shards_list.len()), + shards: Some(shards_list), + shard_id: shard_index, + } + } +} - Box::pin(self.client.get_bots_inner(path)) +/// 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, +} 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..5c7fc44 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, 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,40 +23,30 @@ 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; - +#[derive(Debug)] 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 { - let id = util::parse_api_token(&token); + pub(crate) fn new(mut token: String) -> Self { + token.insert_str(0, "Bearer "); Self { http: reqwest::Client::new(), token, - id, } } @@ -90,13 +79,8 @@ 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::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,76 +112,79 @@ 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<()> { 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(|_| ()) } } -/// Interact with the API's endpoints. +/// A struct representing a [Top.gg API](https://docs.top.gg) client instance. #[must_use] +#[derive(Debug)] pub struct Client { inner: SyncedClient, } impl Client { - /// Creates a new instance. + /// Creates a brand new client instance from a [Top.gg](https://top.gg) token. /// - /// 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 - /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - /// ``` + /// To get your [Top.gg](https://top.gg) token, [view this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). #[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 Discord bot from its ID. + /// Fetches a user from a Discord ID. /// /// # Panics /// - /// Panics if: - /// - The specified ID is invalid. - /// - The client uses an invalid API token. + /// 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](https://docs.top.gg) token (unauthorized) /// /// # Errors /// - /// Returns [`Err`] if: - /// - The specified bot does not exist. ([`NotFound`][crate::Error::NotFound]) - /// - 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the [Top.gg](https://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]) + pub async fn get_user(&self, id: I) -> Result + where + I: Snowflake, + { + self + .inner + .send(Method::GET, api!("/users/{}", id.as_snowflake()), None) + .await + } + + /// Fetches a listed Discord bot 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](https://docs.top.gg) token (unauthorized) /// - /// # Example + /// # Errors /// - /// ```rust,no_run - /// let bot = client.get_bot(264811613708746752).await.unwrap(); - /// ``` + /// 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The requested Discord bot is not listed on [Top.gg](https://top.gg) ([`NotFound`][crate::Error::NotFound]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_bot(&self, id: I) -> Result where I: Snowflake, @@ -208,158 +195,75 @@ impl Client { .await } - /// Fetches your Discord bot's posted server count. + /// Fetches your Discord bot's statistics. /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) 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]) - /// - /// # Example - /// - /// ```rust,no_run - /// let server_count = client.get_bot_server_count().await.unwrap(); - /// ``` - pub async fn get_bot_server_count(&self) -> Result> { + /// 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + 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. + /// Posts your Discord bot's statistics. /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) /// /// # Errors /// - /// Returns [`Err`] if: - /// - The bot is currently 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(); - /// ``` + /// 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) #[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. - /// - /// The amount of voters returned can't exceed 100, so you would need to use the `page` argument for this. + /// Fetches your Discord bot's last 1000 voters. /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) 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]) - /// - /// # Example - /// - /// ```rust,no_run - /// // Page number - /// let voters = client.get_voters(1).await.unwrap(); - /// - /// for voter in voters { - /// println!("{}", voter.username); - /// } - /// ``` - pub async fn get_voters(&self, mut page: usize) -> Result> { - if page < 1 { - page = 1; - } - - self - .inner - .send( - Method::GET, - api!("/bots/{}/votes?page={}", self.inner.id, page), - None, - ) - .await - } - - pub(crate) async fn get_bots_inner(&self, path: String) -> Result> { + /// 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + pub async fn get_voters(&self) -> Result> { self .inner - .send::(Method::GET, api!("{}", path), None) + .send(Method::GET, api!("/bots/votes"), None) .await - .map(|res| res.results) - } - - /// Fetches Discord bots that matches the specified query. - /// - /// # Panics - /// - /// Panics if the client uses an invalid API token. - /// - /// # 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]) - /// - /// # Example - /// - /// ```rust,no_run - /// let bots = client - /// .get_bots() - /// .limit(250) - /// .skip(50) - /// .sort_by_monthly_votes() - /// .await - /// .unwrap(); - /// - /// for bot in bots { - /// println!("{}", bot.name); - /// } - /// ``` - #[inline(always)] - pub fn get_bots(&self) -> BotQuery<'_> { - BotQuery::new(self) } - /// Checks if a Top.gg user has voted for your Discord bot in the past 12 hours. + /// Checks if the specified user has voted your Discord bot. /// /// # Panics /// - /// Panics if: - /// - The specified ID is invalid. - /// - The client uses an invalid API token. + /// Panics if any of the following conditions are met: + /// - The user ID argument is a string and it's not a valid ID (expected things like `"123456789"`) + /// - The client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) /// /// # Errors /// - /// Returns [`Err`] if: - /// - The specified user has not logged in to Top.gg. ([`NotFound`][crate::Error::NotFound]) - /// - 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 - /// let has_voted = client.has_voted(8226924471638491136).await.unwrap(); - /// ``` + /// 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn has_voted(&self, user_id: I) -> Result where I: Snowflake, @@ -375,24 +279,18 @@ impl Client { .map(|res| res.voted != 0) } - /// Checks if the weekend multiplier is active, where a single vote counts as two. + /// Checks if the weekend multiplier is active. /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) 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]) - /// - /// # Example - /// - /// ```rust,no_run - /// let is_weekend = client.is_weekend().await.unwrap(); - /// ``` + /// 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn is_weekend(&self) -> Result { self .inner @@ -403,14 +301,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..17fe2e4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,23 +1,21 @@ -use std::{error, fmt, result}; +use core::{fmt, result}; +use std::error; -/// An error coming from this SDK. +/// A struct representing an error coming from this SDK - unexpected or not. #[derive(Debug)] pub enum Error { - /// HTTP request failure from the client-side. + /// An unexpected internal error coming from the client itself, preventing it from sending a request to [Top.gg](https://top.gg). InternalClientError(reqwest::Error), - /// HTTP request failure from the server-side. + /// An unexpected error coming from [Top.gg](https://top.gg)'s servers themselves. InternalServerError, - /// Attempted to send an invalid request to the API. - InvalidRequest, + /// The requested resource does not exist. (404) + NotFound, - /// Such query does not exist. Inside is the message from the API if available. - NotFound(Option), - - /// Ratelimited from sending more requests. + /// The client is being ratelimited from sending more HTTP requests. Ratelimit { - /// How long the client should wait in seconds before it could send requests again without receiving a 429. + /// The amount of seconds before the ratelimit is lifted. retry_after: u16, }, } @@ -25,17 +23,13 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - 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::InternalClientError(err) => write!(f, "internal client error: {err}"), + Self::InternalServerError => write!(f, "internal server error"), + 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", + "this client is ratelimited, try again in {} seconds", + retry_after / 60 ), } } @@ -51,5 +45,5 @@ impl error::Error for Error { } } -/// The result type primarily used in this SDK. -pub type Result = result::Result; \ No newline at end of file +/// The [`Result`][std::result::Result] type primarily used in this SDK. +pub type Result = result::Result; diff --git a/src/lib.rs b/src/lib.rs index 1f1ac99..0749924 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,58 +1,46 @@ #![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..ab5dae6 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -8,154 +8,153 @@ where String::deserialize(deserializer).and_then(|s| s.parse().map_err(D::Error::custom)) } +#[inline(always)] +#[cfg(feature = "api")] +pub(crate) fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Deserialize::deserialize(deserializer) + .map(|s: Vec| s.into_iter().filter_map(|next| next.parse().ok()).collect()) +} + +/// A trait that represents any datatype that can be interpreted as a Discord snowflake/ID. +pub trait Snowflake { + /// The method that converts this value to a [`u64`]. + fn as_snowflake(&self) -> u64; +} + +macro_rules! impl_snowflake( + ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { + $(#[$attr])? + impl Snowflake for $t { + #[inline(always)] + fn as_snowflake(&$self) -> u64 { + $body + } + } + } +); + +impl_snowflake!(self, u64, *self); + +macro_rules! impl_string( + ($($t:ty),+) => {$( + impl_snowflake!(self, $t, (*self).parse().expect("invalid snowflake as it's not numeric")); + )+} +); + +impl_string!(&str, String); + cfg_if::cfg_if! { if #[cfg(feature = "api")] { - #[inline(always)] - pub(crate) fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - Deserialize::deserialize(deserializer) - .map(|s: Vec| s.into_iter().filter_map(|next| next.parse().ok()).collect()) - } + macro_rules! impl_topgg_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(self, &$t, (*self).id); + )+} + ); - /// Any data type that can be interpreted as a Discord ID. - pub trait Snowflake { - /// Converts this value to a [`u64`]. - fn as_snowflake(&self) -> u64; - } + impl_topgg_idstruct!( + crate::bot::Bot, + crate::user::User, + crate::user::Voter + ); + } +} - macro_rules! impl_snowflake( - ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { - $(#[$attr])? - impl Snowflake for $t { - #[inline(always)] - fn as_snowflake(&$self) -> u64 { - $body - } - } - } +cfg_if::cfg_if! { + if #[cfg(feature = "serenity")] { + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, + &serenity::model::guild::Member, + (*self).user.id.get() ); - impl_snowflake!(self, u64, *self); + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, + &serenity::model::guild::PartialMember, + (*self).user.as_ref().expect("user property in PartialMember is None").id.get() + ); - macro_rules! impl_string( + macro_rules! impl_serenity_id( ($($t:ty),+) => {$( - impl_snowflake!(self, $t, self.parse().expect("Invalid snowflake as it's not numeric.")); + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, (*self).get()); )+} ); - impl_string!(&str, String); + impl_serenity_id!( + serenity::model::id::GenericId, + serenity::model::id::UserId + ); - cfg_if::cfg_if! { - if #[cfg(feature = "api")] { - macro_rules! impl_topgg_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(self, &$t, self.id); - )+} - ); + macro_rules! impl_serenity_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, (*self).id.get()); + )+} + ); - impl_topgg_idstruct!( - crate::Bot, - crate::Voter - ); - } - } + impl_serenity_idstruct!( + serenity::model::gateway::PresenceUser, + serenity::model::user::CurrentUser, + serenity::model::user::User + ); + } +} - cfg_if::cfg_if! { - if #[cfg(feature = "serenity")] { - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, - &serenity::model::guild::Member, - self.user.id.get() - ); - - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, - &serenity::model::guild::PartialMember, - self.user.as_ref().expect("User property in PartialMember is None.").id.get() - ); - - macro_rules! impl_serenity_id( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, self.get()); - )+} - ); - - impl_serenity_id!( - serenity::model::id::GenericId, - serenity::model::id::UserId - ); - - macro_rules! impl_serenity_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, self.id.get()); - )+} - ); - - impl_serenity_idstruct!( - serenity::model::gateway::PresenceUser, - serenity::model::user::CurrentUser, - serenity::model::user::User - ); - } - } +cfg_if::cfg_if! { + if #[cfg(feature = "serenity-cached")] { + use std::ops::Deref; - cfg_if::cfg_if! { - if #[cfg(feature = "serenity-cached")] { - use std::ops::Deref; - - macro_rules! impl_serenity_cacheref( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity-cached")))] self, $t, Snowflake::as_snowflake(&self.deref())); - )+} - ); - - impl_serenity_cacheref!( - serenity::cache::UserRef<'_>, - serenity::cache::MemberRef<'_>, - serenity::cache::CurrentUserRef<'_> - ); - } - } + macro_rules! impl_serenity_cacheref( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity-cached")))] self, $t, Snowflake::as_snowflake(&self.deref())); + )+} + ); - cfg_if::cfg_if! { - if #[cfg(feature = "twilight")] { - #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] - impl Snowflake for twilight_model::id::Id { - #[inline(always)] - fn as_snowflake(&self) -> u64 { - self.get() - } - } - - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, twilight_model::gateway::presence::UserOrId, match self { - twilight_model::gateway::presence::UserOrId::User(user) => user.id.get(), - twilight_model::gateway::presence::UserOrId::UserId { id } => id.get(), - }); - - macro_rules! impl_twilight_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, self.id.get()); - )+} - ); - - impl_twilight_idstruct!( - twilight_model::user::CurrentUser, - twilight_model::user::User, - twilight_model::gateway::payload::incoming::invite_create::PartialUser - ); - } - } + impl_serenity_cacheref!( + serenity::cache::UserRef<'_>, + serenity::cache::MemberRef<'_>, + serenity::cache::CurrentUserRef<'_> + ); + } +} - cfg_if::cfg_if! { - if #[cfg(feature = "twilight-cached")] { - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "twilight-cached")))] self, - &twilight_cache_inmemory::model::CachedMember, - self.user_id().get() - ); +cfg_if::cfg_if! { + if #[cfg(feature = "twilight")] { + #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] + impl Snowflake for twilight_model::id::Id { + #[inline(always)] + fn as_snowflake(&self) -> u64 { + self.get() } } + + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, twilight_model::gateway::presence::UserOrId, match self { + twilight_model::gateway::presence::UserOrId::User(user) => user.id.get(), + twilight_model::gateway::presence::UserOrId::UserId { id } => id.get(), + }); + + macro_rules! impl_twilight_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, (*self).id.get()); + )+} + ); + + impl_twilight_idstruct!( + twilight_model::user::CurrentUser, + twilight_model::user::User, + twilight_model::user::UserProfile, + twilight_model::gateway::payload::incoming::invite_create::PartialUser + ); + } +} + +cfg_if::cfg_if! { + if #[cfg(feature = "twilight-cached")] { + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "twilight-cached")))] self, + &twilight_cache_inmemory::model::CachedMember, + (*self).user_id().get() + ); } } 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..42d3dfc --- /dev/null +++ b/src/user.rs @@ -0,0 +1,141 @@ +use crate::{snowflake, util}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// A struct representing a user's social links. +#[derive(Clone, Debug, Deserialize)] +pub struct Socials { + /// A URL of this user's GitHub account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + pub github: Option, + + /// A URL of this user's Instagram account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + pub instagram: Option, + + /// A URL of this user's Reddit account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + pub reddit: Option, + + /// A URL of this user's Twitter account. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + pub twitter: Option, + + /// A URL of this user's YouTube channel. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + pub youtube: Option, +} + +util::debug_struct! { + /// A struct representing a user logged into [Top.gg](https://top.gg). + #[must_use] + #[derive(Clone, Deserialize)] + User { + public { + /// The Discord ID of this user. + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, + + /// The username of this user. + username: String, + + /// The user's bio. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + bio: Option, + + /// A URL of this user's profile banner image. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + banner: Option, + + /// A struct of this user's social links. + #[serde(rename = "social")] + socials: Option, + + /// Whether this user is a [Top.gg](https://top.gg) supporter or not. + #[serde(rename = "supporter")] + is_supporter: bool, + + /// Whether this user is a [Top.gg](https://top.gg) certified developer or not. + #[serde(rename = "certifiedDev")] + is_certified_dev: bool, + + /// Whether this user is a [Top.gg](https://top.gg) moderator or not. + #[serde(rename = "mod")] + is_moderator: bool, + + /// Whether this user is a [Top.gg](https://top.gg) website moderator or not. + #[serde(rename = "webMod")] + is_web_moderator: bool, + + /// Whether this user is a [Top.gg](https://top.gg) website administrator or not. + #[serde(rename = "admin")] + is_admin: bool, + } + + private { + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + avatar: Option, + } + + getters(self) { + /// Retrieves the creation date of this user. + #[must_use] + #[inline(always)] + created_at: DateTime => { + util::get_creation_date(self.id) + } + + /// Retrieves the Discord avatar URL of this user. + /// + /// Its format will either be PNG or GIF if animated. + #[must_use] + #[inline(always)] + avatar: String => { + util::get_avatar(&self.avatar, self.id) + } + } + } +} + +#[derive(Deserialize)] +pub(crate) struct Voted { + pub(crate) voted: u8, +} + +util::debug_struct! { + /// A struct representing a user who has voted on a Discord bot listed on [Top.gg](https://top.gg). (See [`Client::get_voters`][crate::Client::get_voters]) + #[must_use] + #[derive(Clone, Deserialize)] + Voter { + public { + /// The Discord ID of this user. + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, + + /// The username of this user. + username: String, + } + + private { + avatar: Option, + } + + getters(self) { + /// Retrieves the creation date of this user. + #[must_use] + #[inline(always)] + created_at: DateTime => { + util::get_creation_date(self.id) + } + + /// Retrieves the Discord avatar URL of this user. + /// + /// Its format will either be PNG or GIF if animated. + #[must_use] + #[inline(always)] + avatar: String => { + util::get_avatar(&self.avatar, self.id) + } + } + } +} diff --git a/src/util.rs b/src/util.rs index 33ca6ad..edaa15c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,8 +1,81 @@ -use crate::{snowflake, Error}; -use base64::Engine; +use crate::Error; +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 + )* + })? + } + ) => { + $(#[$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, + )*)? + } + + $(impl $struct_name { + $( + $(#[$getter_attr])* + pub fn $getter_name(&$self) -> $getter_type $getter_code + )* + })? + + 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, @@ -10,11 +83,16 @@ pub(crate) fn deserialize_optional_string<'de, D>( where D: Deserializer<'de>, { - Ok( - String::deserialize(deserializer) - .ok() - .filter(|s| !s.is_empty()), - ) + Ok(match ::deserialize(deserializer) { + Ok(s) => { + if s.is_empty() { + None + } else { + Some(s) + } + } + _ => None, + }) } #[inline(always)] @@ -23,7 +101,15 @@ where T: Default + Deserialize<'de>, D: Deserializer<'de>, { - Option::deserialize(deserializer).map(Option::unwrap_or_default) + Option::deserialize(deserializer).map(|res| res.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)] @@ -40,23 +126,16 @@ where Err(Error::InternalServerError) } -#[derive(Deserialize)] -#[allow(clippy::used_underscore_binding)] -struct TokenStructure { - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, -} +pub(crate) fn get_avatar(hash: &Option, id: u64) -> String { + match hash { + Some(hash) => { + let ext = if hash.starts_with("a_") { "gif" } else { "png" }; -pub(crate) fn parse_api_token(token: &str) -> u64 { - if let Some(base64_section) = token.split('.').nth(1) { - if let Ok(decoded_base64) = - base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) - { - if let Ok(token_structure) = serde_json::from_slice::(&decoded_base64) { - return token_structure.id; - } + format!("https://cdn.discordapp.com/avatars/{id}/{hash}.{ext}?size=1024") } + _ => format!( + "https://cdn.discordapp.com/embed/avatars/{}.png", + (id >> 22) % 5 + ), } - - panic!("Got a malformed API token."); } 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 58% rename from src/webhooks/actix_web.rs rename to src/webhook/actix_web.rs index 9393140..43bff60 100644 --- a/src/webhooks/actix_web.rs +++ b/src/webhook/actix_web.rs @@ -1,28 +1,24 @@ -use crate::Incoming; +use crate::{IncomingVote, Vote}; use actix_web::{ dev::Payload, - error::{Error, ErrorBadRequest, ErrorUnauthorized}, + error::{Error, ErrorUnauthorized}, web::Json, FromRequest, HttpRequest, }; -use serde::de::DeserializeOwned; -use std::{ +use core::{ future::Future, pin::Pin, task::{ready, Context, Poll}, }; #[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,31 +26,26 @@ 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"))) + Poll::Ready(Err(ErrorUnauthorized("401"))) } } #[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..76d804f --- /dev/null +++ b/src/webhook/axum.rs @@ -0,0 +1,102 @@ +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::OK, ()).into_response(); + } + } + } + } + + (StatusCode::UNAUTHORIZED, ()).into_response() +} + +/// Creates a new [`axum`] [`Router`] for adding an on-vote event handler to your application logic. +/// +/// # Examples +/// +/// Basic usage: +/// +/// ```rust,no_run +/// use axum::{routing::get, Router, Server}; +/// use std::{net::SocketAddr, sync::Arc}; +/// use topgg::{Vote, VoteHandler}; +/// +/// struct MyVoteHandler {} +/// +/// #[axum::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 app = Router::new().route("/", get(index)).nest( +/// "/webhook", +/// topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), +/// ); +/// +/// let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); +/// +/// Server::bind(&addr) +/// .serve(app.into_make_service()) +/// .await +/// .unwrap(); +/// } +/// ``` +#[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..d7012d9 --- /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")] { + /// Wrapper for working with the [`axum`](https://crates.io/crates/axum) web framework. + #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] + pub mod axum; + } +} + +cfg_if::cfg_if! { + if #[cfg(feature = "warp")] { + /// Wrapper for working with the [`warp`](https://crates.io/crates/warp) web framework. + #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] + pub mod warp; + } +} diff --git a/src/webhooks/rocket.rs b/src/webhook/rocket.rs similarity index 57% rename from src/webhooks/rocket.rs rename to src/webhook/rocket.rs index 0bfb988..91f337b 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, -{ +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..2360767 --- /dev/null +++ b/src/webhook/vote.rs @@ -0,0 +1,151 @@ +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") +} + +const fn _true() -> bool { + true +} + +#[inline(always)] +fn deserialize_is_server<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(String::deserialize(deserializer).is_err()) +} + +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 struct representing a dispatched [Top.gg](https://top.gg) bot/server vote event. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct Vote { + /// The ID of the bot/server 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( + default = "_true", + deserialize_with = "deserialize_is_server", + rename = "bot" + )] + pub is_server: bool, + + /// Whether this vote is just a test coming from the bot/server owner or not. 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"))] { + /// A struct that represents 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: + /// + /// ```rust,no_run + /// match incoming_vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { + /// Some(vote) => { + /// println!("{:?}", vote); + /// + /// // respond with 200 OK... + /// }, + /// _ => { + /// println!("found an unauthorized attacker."); + /// + /// // respond with 401 UNAUTHORIZED... + /// } + /// } + /// ``` + #[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,no_run + /// #[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. The endpoint will always return a 200 (OK) HTTP status code after running this method. + async fn voted(&self, vote: Vote); + } + } +} diff --git a/src/webhooks/warp.rs b/src/webhook/warp.rs similarity index 61% rename from src/webhooks/warp.rs rename to src/webhook/warp.rs index 51c108b..13e1238 100644 --- a/src/webhooks/warp.rs +++ b/src/webhook/warp.rs @@ -1,34 +1,35 @@ -use super::Webhook; -use serde::de::DeserializeOwned; +use crate::{Vote, VoteHandler}; use std::sync::Arc; use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; -/// Creates a new warp [`Filter`] for receiving webhook events. +/// Creates a new `warp` [`Filter`] for adding an on-vote event handler to your application logic. /// -/// # Example +/// # Examples +/// +/// Basic usage: /// /// ```rust,no_run /// use std::{net::SocketAddr, sync::Arc}; -/// use topgg::{VoteEvent, Webhook}; +/// use topgg::{Vote, VoteHandler}; /// use warp::Filter; /// -/// struct MyVoteListener {} +/// struct MyVoteHandler {} /// /// #[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); +/// impl VoteHandler for MyVoteHandler { +/// async fn voted(&self, vote: Vote) { +/// println!("{:?}", vote); /// } /// } /// /// #[tokio::main] /// async fn main() { -/// let state = Arc::new(MyVoteListener {}); +/// let state = Arc::new(MyVoteHandler {}); /// -/// // POST /votes +/// // POST /webhook /// let webhook = topgg::warp::webhook( -/// "votes", -/// env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), +/// "webhook", +/// env!("TOPGG_WEBHOOK_PASSWORD").to_string(), /// Arc::clone(&state), /// ); /// @@ -40,14 +41,13 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// } /// ``` #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] -pub fn webhook( +pub fn webhook( endpoint: &'static str, password: String, state: Arc, ) -> impl Filter + Clone where - D: DeserializeOwned + Send, - T: Webhook, + T: VoteHandler, { let password = Arc::new(password); @@ -55,15 +55,15 @@ where .and(path(endpoint)) .and(header("Authorization")) .and(body::json()) - .then(move |auth: String, data: D| { + .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.callback(data).await; + current_state.voted(vote).await; - StatusCode::NO_CONTENT + StatusCode::OK } 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, -} From 38f49e525391bbe961b74f8cdd13f6fc6636ea32 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 17 Sep 2025 22:14:53 +0700 Subject: [PATCH 2/8] feat: adapt to v0 --- Cargo.toml | 35 +++- README.md | 22 +-- src/autoposter/client.rs | 4 +- src/autoposter/mod.rs | 174 +++++++++++++++----- src/autoposter/serenity_impl.rs | 36 ++--- src/autoposter/twilight_impl.rs | 8 +- src/bot.rs | 234 ++++++++++----------------- src/client.rs | 191 ++++++++++++++-------- src/error.rs | 30 ++-- src/lib.rs | 2 +- src/snowflake.rs | 261 +++++++++++++++--------------- src/user.rs | 106 ++++++------ src/util.rs | 277 +++++++++++++++++--------------- src/webhook/actix_web.rs | 6 +- src/webhook/axum.rs | 8 +- src/webhook/mod.rs | 4 +- src/webhook/vote.rs | 29 +--- src/webhook/warp.rs | 6 +- 18 files changed, 762 insertions(+), 671 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5eccf05..cee778b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "topgg" -version = "1.4.2" +version = "1.5.0" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] -description = "The official Rust wrapper for the Top.gg API" +description = "A simple API wrapper for Top.gg written in Rust." readme = "README.md" repository = "https://github.com/Top-gg-Community/rust-sdk" license = "MIT" @@ -12,12 +12,13 @@ categories = ["api-bindings", "web-programming::http-client"] exclude = [".gitattributes", ".github/", ".gitignore", "rustfmt.toml"] [dependencies] +base64 = { version = "0.22", optional = true } cfg-if = "1" paste = { version = "1", optional = true } reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } -urlencoding = { version = "2", optional = true } +urlencoding = "2" serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } @@ -28,11 +29,33 @@ chrono = { version = "0.4", default-features = false, optional = true, features serde_json = { version = "1", optional = true } rocket = { version = "0.5", default-features = false, features = ["json"], optional = true } -axum = { version = "0.7", default-features = false, optional = true, features = ["http1", "tokio"] } +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 } + +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros"] } +twilight-gateway = "0.15" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +cast-lossless = "allow" +cast-possible-truncation = "allow" +cast-possible-wrap = "allow" +cast-sign-loss = "allow" +inline-always = "allow" +module-name-repetitions = "allow" +must-use-candidate = "allow" +return-self-not-must-use = "allow" +similar-names = "allow" +single-match-else = "allow" +too-many-lines = "allow" +unnecessary-wraps = "allow" +unreadable-literal = "allow" + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] @@ -40,7 +63,7 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["chrono", "reqwest", "serde_json"] +api = ["base64", "chrono", "reqwest", "serde_json"] autoposter = ["api", "tokio"] serenity = ["dep:serenity", "paste"] @@ -49,7 +72,7 @@ serenity-cached = ["serenity", "serenity/cache"] twilight = ["twilight-model"] twilight-cached = ["twilight", "twilight-cache-inmemory"] -webhook = ["urlencoding"] +webhook = [] rocket = ["webhook", "dep:rocket"] axum = ["webhook", "async-trait", "serde_json", "dep:axum"] warp = ["webhook", "async-trait", "dep:warp"] diff --git a/README.md b/README.md index a258444..878ef01 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 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)! @@ -90,16 +90,16 @@ 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: ```rust,no_run -use core::time::Duration; +use std::time::Duration; use serenity::{client::{Client, Context, EventHandler}, model::{channel::Message, gateway::Ready}}; use topgg::Autoposter; @@ -147,16 +147,16 @@ 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: ```rust,no_run -use core::time::Duration; +use std::time::Duration; use topgg::Autoposter; use twilight_gateway::{Event, Intents, Shard, ShardId}; @@ -202,7 +202,7 @@ 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: @@ -247,7 +247,7 @@ 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: @@ -294,7 +294,7 @@ 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: @@ -339,7 +339,7 @@ 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: diff --git a/src/autoposter/client.rs b/src/autoposter/client.rs index be95464..35118a6 100644 --- a/src/autoposter/client.rs +++ b/src/autoposter/client.rs @@ -5,9 +5,7 @@ pub trait AsClientSealed { fn as_client(&self) -> Arc; } -/// A private trait that represents any datatype that can be interpreted as a [Top.gg API](https://docs.top.gg) Client. -/// -/// This can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. +/// Any datatype that can be interpreted as a [`Client`][crate::Client]. pub trait AsClient: AsClientSealed {} impl AsClientSealed for str { diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 4308260..9857d3b 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -1,9 +1,9 @@ use crate::{Result, Stats}; -use core::{ +use std::{ ops::{Deref, DerefMut}, + sync::Arc, time::Duration, }; -use std::sync::Arc; use tokio::{ sync::{mpsc, RwLock, RwLockWriteGuard, Semaphore}, task::{spawn, JoinHandle}, @@ -49,8 +49,7 @@ impl SharedStatsGuard<'_> { /// Directly replaces the current [`Stats`] inside with the other. #[inline(always)] pub fn replace(&mut self, other: Stats) { - let ref_mut = self.guard.deref_mut(); - *ref_mut = other; + *self.guard = other; } /// Sets the current [`Stats`] server count. @@ -60,10 +59,8 @@ impl SharedStatsGuard<'_> { } /// 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); - } + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + pub fn set_shard_count(&mut self, _shard_count: usize) {} } impl Deref for SharedStatsGuard<'_> { @@ -71,14 +68,14 @@ impl Deref for SharedStatsGuard<'_> { #[inline(always)] fn deref(&self) -> &Self::Target { - self.guard.deref() + &self.guard } } impl DerefMut for SharedStatsGuard<'_> { #[inline(always)] fn deref_mut(&mut self) -> &mut Self::Target { - self.guard.deref_mut() + &mut self.guard } } @@ -103,7 +100,7 @@ impl SharedStats { /// 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<'a>(&'a self) -> SharedStatsGuard<'a> { + pub async fn write(&self) -> SharedStatsGuard<'_> { SharedStatsGuard { sem: &self.sem, guard: self.stats.write().await, @@ -116,17 +113,24 @@ impl SharedStats { } } +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 guild/shard count. +/// The struct implementing this trait should own an [`SharedStats`] struct and update it accordingly whenever Discord updates them with new data regarding guild count. pub trait Handler: Send + Sync + 'static { /// The method that borrows [`SharedStats`] to the [`Autoposter`]. fn stats(&self) -> &SharedStats; } -/// A struct that lets you automate the process of posting bot statistics to [Top.gg](https://top.gg) in intervals. +/// Automatically update the stats in your Discord bot's Top.gg page every few minutes. /// -/// **NOTE:** This struct owns the thread handle that executes the automatic posting. The autoposter thread will stop once this struct is dropped. +/// **NOTE**: This struct owns the autoposter thread which means that it will stop once it gets dropped. #[must_use] pub struct Autoposter { handler: Arc, @@ -138,22 +142,16 @@ impl Autoposter where H: Handler, { - /// Creates an [`Autoposter`] struct as well as immediately starting the thread. The thread will never stop until this struct gets dropped. - /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. - /// - `handler` is a struct that handles the *retrieving stats* part before being sent to the [`Autoposter`]. This datatype is essentially the bridge between an external third-party Discord Bot library between this library. - /// - /// # Panics - /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). - pub fn new(client: &C, handler: H, interval: Duration) -> Self + /// Creates and starts an autoposter thread. + #[allow(unused_mut)] + pub fn new(client: &C, handler: H, mut interval: Duration) -> Self where C: AsClient, { - assert!( - interval.as_secs() >= 900, - "The interval mustn't be shorter than 15 minutes." - ); + #[cfg(not(test))] + if interval.as_secs() < 900 { + interval = Duration::from_secs(900); + } let client = client.as_client(); let handler = Arc::new(handler); @@ -180,22 +178,35 @@ where } } - /// Retrieves the [`Handler`] inside in the form of a [cloned][Arc::clone] [`Arc`][Arc]. + /// This autoposter's handler. #[inline(always)] pub fn handler(&self) -> Arc { Arc::clone(&self.handler) } - - /// Returns a future that resolves every time the [`Autoposter`] has attempted to post the bot's stats. If you want to use the receiver directly, call [`receiver`]. + + /// 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("receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await + 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`]. Subsequent calls to this function and [`recv`] after this call will panic. + + /// 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.") + self + .receiver + .take() + .expect("receiver() can only be called once.") } } @@ -204,20 +215,60 @@ impl Deref for Autoposter { #[inline(always)] fn deref(&self) -> &Self::Target { - self.handler.deref() + &self.handler } } #[cfg(feature = "serenity")] #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] impl Autoposter { - /// Creates an [`Autoposter`] struct from an existing built-in [serenity] [`Handler`] as well as immediately starting the thread. The thread will never stop until this struct gets dropped. + /// Creates and starts a serenity-based autoposter thread. /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. + /// # Example /// - /// # Panics + /// ```rust,no_run + /// use std::time::Duration; + /// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; + /// use topgg::Autoposter; + /// + /// struct AutoposterHandler; + /// + /// #[serenity::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()); /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). + /// // Posts once every 30 minutes + /// let mut autoposter = Autoposter::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(AutoposterHandler) + /// .event_handler_arc(autoposter.handler()) + /// .await + /// .unwrap(); + /// + /// let mut receiver = 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 @@ -230,13 +281,50 @@ impl Autoposter { #[cfg(feature = "twilight")] #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] impl Autoposter { - /// Creates an [`Autoposter`] struct from an existing built-in [twilight](https://twilight.rs) [`Handler`] as well as immediately starting the thread. The thread will never stop until this struct gets dropped. + /// Creates and starts a twilight-based autoposter thread. /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. + /// # Example /// - /// # Panics + /// ```rust,no_run + /// use std::time::Duration; + /// use topgg::{Autoposter, Client}; + /// use twilight_gateway::{Event, Intents, Shard, ShardId}; + /// + /// #[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 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; + /// } + /// }; + /// + /// autoposter.handle(&event).await; + /// + /// match event { + /// Event::Ready(_) => { + /// println!("Bot is now ready!"); + /// }, /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). + /// _ => {} + /// } + /// } + /// } + /// ``` #[inline(always)] pub fn twilight(client: &C, interval: Duration) -> Self where diff --git a/src/autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs index 22c19d5..eaca46f 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -17,12 +17,10 @@ cfg_if::cfg_if! { struct Cache { guilds: HashSet, } - } else { - use std::ops::Add; } } -/// A built-in [`Handler`] for the [serenity] library. +/// A built-in [`Handler`] for the serenity library. #[must_use] pub struct Serenity { #[cfg(not(feature = "serenity-cached"))] @@ -54,7 +52,11 @@ macro_rules! serenity_handler { } } - /// Handles an entire [serenity] [`FullEvent`] enum. This can be used in [serenity] frameworks. + /// Handles an entire serenity [`FullEvent`] enum. This can be used in serenity frameworks. + /// + /// # Panics + /// + /// The `serenity-cached` feature is enabled but the bot doesn't cache guilds. pub async fn handle(&$self, $context: &Context, event: &FullEvent) { match event { $( @@ -94,7 +96,7 @@ serenity_handler! { (self, context) => { ready { map(data_about_bot: Ready) { - self.handle_ready(&data_about_bot.guilds).await + self.handle_ready(&data_about_bot.guilds).await; } handle(guilds: &[UnavailableGuild]) { @@ -106,7 +108,7 @@ serenity_handler! { if #[cfg(not(feature = "serenity-cached"))] { let mut cache = self.cache.lock().await; - cache.guilds = guilds.into_iter().map(|x| x.id).collect(); + cache.guilds = guilds.iter().map(|x| x.id).collect(); } } } @@ -115,7 +117,7 @@ serenity_handler! { #[cfg(feature = "serenity-cached")] cache_ready { map(guilds: Vec) { - self.handle_cache_ready(guilds.len()).await + self.handle_cache_ready(guilds.len()).await; } handle(guild_count: usize) { @@ -125,27 +127,13 @@ serenity_handler! { } } - #[cfg(feature = "serenity-cached")] - shards_ready { - map(total_shards: u32) { - // turns either &u32 or u32 to a u32 :) - self.handle_shards_ready(total_shards.add(0)).await - } - - handle(shard_count: u32) { - let mut stats = self.stats.write().await; - - stats.set_shard_count(shard_count as _); - } - } - guild_create { map(guild: Guild, is_new: Option) { self.handle_guild_create( #[cfg(not(feature = "serenity-cached"))] guild.id, #[cfg(feature = "serenity-cached")] context.cache.guilds().len(), - #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the discord bot doesn't cache guilds"), - ).await + #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the bot doesn't cache guilds."), + ).await; } handle( @@ -177,7 +165,7 @@ serenity_handler! { self.handle_guild_delete( #[cfg(feature = "serenity-cached")] context.cache.guilds().len(), #[cfg(not(feature = "serenity-cached"))] incomplete.id - ).await + ).await; } handle( diff --git a/src/autoposter/twilight_impl.rs b/src/autoposter/twilight_impl.rs index df225ff..6e3cfd3 100644 --- a/src/autoposter/twilight_impl.rs +++ b/src/autoposter/twilight_impl.rs @@ -1,9 +1,9 @@ use crate::autoposter::{Handler, SharedStats}; -use std::{collections::HashSet, ops::DerefMut}; +use std::collections::HashSet; use tokio::sync::Mutex; use twilight_model::gateway::event::Event; -/// A built-in [`Handler`] for the [twilight](https://twilight.rs) library. +/// A built-in [`Handler`] for the twilight library. pub struct Twilight { cache: Mutex>, stats: SharedStats, @@ -18,13 +18,13 @@ impl Twilight { } } - /// Handles an entire [twilight](https://twilight.rs) [`Event`] enum. + /// 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 = cache.deref_mut(); + let cache_ref = &mut *cache; *cache_ref = ready.guilds.iter().map(|guild| guild.id.get()).collect(); stats.set_server_count(cache.len()); diff --git a/src/bot.rs b/src/bot.rs index 81670d2..e68bd65 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -14,29 +14,35 @@ where } util::debug_struct! { - /// A struct representing a Discord Bot listed on [Top.gg](https://top.gg). + /// A struct representing a Discord Bot listed on Top.gg. #[must_use] #[derive(Clone, Deserialize)] Bot { public { - /// The ID of this Discord bot. - #[serde(deserialize_with = "snowflake::deserialize")] + /// This bot's Discord ID. + #[serde(rename = "clientid", deserialize_with = "snowflake::deserialize")] id: u64, - /// The username of this Discord bot. + /// This bot's Top.gg ID. + #[serde(rename = "id", deserialize_with = "snowflake::deserialize")] + topgg_id: u64, + + /// This bot's username. username: String, - /// The discriminator of this Discord bot. + /// This bot's discriminator. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] discriminator: String, - /// The prefix of this Discord bot. + /// This bot's prefix. prefix: String, - /// The short description of this Discord bot. + /// This bot's short description. #[serde(rename = "shortdesc")] short_description: String, - /// The long description of this Discord bot. It can contain HTML and/or Markdown. + /// This bot's HTML/Markdown long description. #[serde( default, deserialize_with = "util::deserialize_optional_string", @@ -44,90 +50,95 @@ util::debug_struct! { )] long_description: Option, - /// The tags of this Discord bot. + /// This bot's tags. #[serde(default, deserialize_with = "util::deserialize_default")] tags: Vec, - /// The website URL of this Discord bot. + /// This bot's website URL. #[serde(default, deserialize_with = "util::deserialize_optional_string")] website: Option, - /// The link to this Discord bot's GitHub repository. + /// This bot's GitHub repository URL. #[serde(default, deserialize_with = "util::deserialize_optional_string")] github: Option, - /// A list of IDs of this Discord bot's owners. The main owner is the first ID in the array. + /// This bot's owner IDs. #[serde(deserialize_with = "snowflake::deserialize_vec")] owners: Vec, - /// A list of IDs of the guilds featured on this Discord bot's page. - #[serde(default, deserialize_with = "snowflake::deserialize_vec")] + /// This bot's guild IDs. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] guilds: Vec, - /// The URL for this Discord bot's banner image. - #[serde( - default, - deserialize_with = "util::deserialize_optional_string", - rename = "bannerUrl" - )] + /// This bot's banner image URL. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] banner_url: Option, - /// The date when this Discord bot was approved on [Top.gg](https://top.gg). - #[serde(rename = "date")] + /// This bot's approval date. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[deprecated(since = "1.5.0", note = "Actually refers to submission date. Use `submitted_at` instead.")] approved_at: DateTime, - /// Whether this Discord bot is [Top.gg](https://top.gg) certified or not. - #[serde(rename = "certifiedBot")] + /// This bot's submission date. + #[serde(rename = "date")] + submitted_at: DateTime, + + /// Whether this bot is certified or not. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] is_certified: bool, - /// A list of this Discord bot's shards. - #[serde(default, deserialize_with = "util::deserialize_default")] + /// This bot's shards. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] shards: Vec, - /// The amount of upvotes this Discord bot has. + /// The amount of votes this bot has. #[serde(rename = "points")] votes: usize, - /// The amount of upvotes this Discord bot has this month. + /// The amount of votes this bot has this month. #[serde(rename = "monthlyPoints")] monthly_votes: usize, - /// The support server invite URL of this Discord bot. + /// This bot's support URL. #[serde(default, deserialize_with = "deserialize_support_server")] support: Option, - } - private { - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - avatar: Option, + /// This bot's avatar URL. + avatar: String, + /// This bot's Top.gg vanity code. #[serde(default, deserialize_with = "util::deserialize_optional_string")] - invite: Option, + vanity: Option, - shard_count: Option, + /// This bot's posted server count. + #[serde(default)] + server_count: Option, + } + private { #[serde(default, deserialize_with = "util::deserialize_optional_string")] - vanity: Option, + invite: Option, } getters(self) { - /// Retrieves the creation date of this bot. + /// This bot's creation date. #[must_use] #[inline(always)] created_at: DateTime => { util::get_creation_date(self.id) } - /// Retrieves the avatar URL of this bot. - /// - /// Its format will either be PNG or GIF if animated. - #[must_use] - #[inline(always)] + /// This bot's avatar URL. + #[deprecated(since = "1.5.0", note = "Just directly use the public `avatar` property.")] avatar: String => { - util::get_avatar(&self.avatar, self.id) + self.avatar.clone() } - /// The invite URL of this Discord bot. + /// This bot's invite URL. #[must_use] invite: String => { match &self.invite { @@ -139,14 +150,13 @@ util::debug_struct! { } } - /// The amount of shards this Discord bot has according to posted stats. - #[must_use] - #[inline(always)] + /// This bot's shard count. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] shard_count: usize => { - self.shard_count.unwrap_or(self.shards.len()) + 0 } - /// Retrieves the URL of this Discord bot's [Top.gg](https://top.gg) page. + /// This bot's Top.gg page URL. #[must_use] #[inline(always)] url: String => { @@ -162,84 +172,41 @@ util::debug_struct! { util::debug_struct! { /// A struct representing a Discord bot's statistics. /// - /// # Examples - /// - /// Solely from a server count: - /// - /// ```rust,no_run - /// use topgg::Stats; - /// - /// let _stats = Stats::from(12345); - /// ``` - /// - /// Server count with a shard count: - /// - /// ```rust,no_run - /// 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: + /// # Example /// /// ```rust,no_run /// use topgg::Stats; /// - /// // the shard posting this data has 456 servers. - /// let _stats = Stats::from_shards([123, 456, 789], Some(1)); + /// let _stats = Stats { + /// server_count: Some(12345), + /// }; /// ``` #[must_use] #[derive(Clone, Serialize, Deserialize)] Stats { - protected { - #[serde(skip_serializing_if = "Option::is_none")] - shard_count: Option, + public { + /// The amount of servers this bot is in. `None` if such information is publicly unavailable. #[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) { - /// An array of this Discord bot's server count for each shard. - #[must_use] - #[inline(always)] + /// This bot's list of server count for each shard. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] shards: &[usize] => { - match self.shards { - Some(ref shards) => shards, - None => &[], - } + &[] } - /// The amount of shards this Discord bot has. - #[must_use] - #[inline(always)] + /// This bot's shard count. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] shard_count: usize => { - self.shard_count.unwrap_or(match self.shards { - Some(ref shards) => shards.len(), - None => 0, - }) + 0 } - /// The amount of servers this bot is in. `None` if such information is publy unavailable. - #[must_use] + /// The amount of servers this bot is in. `None` if such information is publicly unavailable. + #[deprecated(since = "1.5.0", note = "Just directly use the public `server_count` property.")] 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()) - } - }) - }) + self.server_count } } } @@ -251,60 +218,27 @@ impl Stats { #[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 _), - ) + Self { + server_count: Some(context.cache.guilds().len()), + } } /// 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 { + #[deprecated(since = "1.5.0", note = "Just directly use a struct declaration.")] + 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. - /// - /// # Examples - /// - /// Basic usage: - /// - /// ```rust,no_run - /// 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 + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + 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, + server_count: Some(shards.into_iter().sum()), } } } @@ -313,7 +247,9 @@ impl Stats { impl From for Stats { #[inline(always)] fn from(server_count: usize) -> Self { - Self::from_count(server_count, None) + Self { + server_count: Some(server_count), + } } } diff --git a/src/client.rs b/src/client.rs index 5c7fc44..e43f1ca 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,3 +1,4 @@ +#[allow(deprecated)] use crate::{ bot::{Bot, IsWeekend}, user::{User, Voted, Voter}, @@ -33,20 +34,23 @@ macro_rules! api { }; } -#[derive(Debug)] pub struct InnerClient { http: reqwest::Client, token: String, + id: u64, } -// this is implemented here because 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(mut token: String) -> Self { + let id = util::parse_api_token(&token); + token.insert_str(0, "Bearer "); Self { http: reqwest::Client::new(), token, + id, } } @@ -113,6 +117,10 @@ impl InnerClient { } pub(crate) async fn post_stats(&self, new_stats: &Stats) -> Result<()> { + if new_stats.server_count.unwrap_or(0) == 0 { + return Err(Error::InvalidRequest); + } + self .send_inner( Method::POST, @@ -124,17 +132,24 @@ impl InnerClient { } } -/// A struct representing a [Top.gg API](https://docs.top.gg) client instance. +/// Interact with the API's endpoints. #[must_use] -#[derive(Debug)] pub struct Client { inner: SyncedClient, } impl Client { - /// Creates a brand new client instance from a [Top.gg](https://top.gg) token. + /// Creates a new instance. + /// + /// # Panics /// - /// To get your [Top.gg](https://top.gg) token, [view this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). + /// Panics if the client uses an invalid API token. + /// + /// # Example + /// + /// ```rust,no_run + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// ``` #[inline(always)] pub fn new(token: String) -> Self { let inner = InnerClient::new(token); @@ -146,45 +161,36 @@ impl Client { } /// 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](https://docs.top.gg) 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://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]) - pub async fn get_user(&self, id: I) -> Result + #[allow(clippy::unused_async, clippy::missing_errors_doc, deprecated)] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + pub async fn get_user(&self, _id: I) -> Result where I: Snowflake, { - self - .inner - .send(Method::GET, api!("/users/{}", id.as_snowflake()), None) - .await + Err(Error::NotFound) } - /// Fetches a listed Discord bot from a Discord ID. + /// Fetches a Discord bot from its 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](https://docs.top.gg) token (unauthorized) + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. /// /// # 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The requested Discord bot is not listed on [Top.gg](https://top.gg) ([`NotFound`][crate::Error::NotFound]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// Returns [`Err`] if: + /// - The specified bot does not exist. ([`NotFound`][crate::Error::NotFound]) + /// - 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 + /// let bot = client.get_bot(264811613708746752).await.unwrap(); + /// ``` pub async fn get_bot(&self, id: I) -> Result where I: Snowflake, @@ -199,14 +205,20 @@ impl Client { /// /// # Panics /// - /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if the client uses an invalid API token. /// /// # 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// 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]) + /// + /// # Example + /// + /// ```rust,no_run + /// let stats = client.get_stats().await.unwrap(); + /// ``` pub async fn get_stats(&self) -> Result { self .inner @@ -214,56 +226,95 @@ impl Client { .await } - /// Posts your Discord bot's statistics. + /// Updates your Discord bot's statistics. /// /// # Panics /// - /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if the client uses an invalid API token. /// /// # 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// Returns [`Err`] if: + /// - 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 + /// use topgg::Stats; + /// + /// client.post_stats(Stats { + /// server_count: Some(bot.server_count()), + /// }).await.unwrap(); + /// ``` #[inline(always)] pub async fn post_stats(&self, new_stats: Stats) -> Result<()> { self.inner.post_stats(&new_stats).await } - /// Fetches your Discord bot's last 1000 voters. + /// Fetches your project's recent unique voters. + /// + /// The amount of voters returned can't exceed 100, so you would need to use the `page` argument for this. /// /// # Panics /// - /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if the client uses an invalid API token. /// /// # 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) - pub async fn get_voters(&self) -> Result> { + /// 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]) + /// + /// # Example + /// + /// ```rust,no_run + /// // Page number + /// let voters = client.get_voters(1).await.unwrap(); + /// + /// for voter in voters { + /// println!("{}", voter.username); + /// } + /// ``` + pub async fn get_voters(&self, mut page: usize) -> Result> { + if page < 1 { + page = 1; + } + self .inner - .send(Method::GET, api!("/bots/votes"), None) + .send( + Method::GET, + api!("/bots/{}/votes?page={}", self.inner.id, page), + None, + ) .await } - /// Checks if the specified user has voted your Discord bot. + /// Checks if a Top.gg user has voted for your Discord bot in the past 12 hours. /// /// # Panics /// - /// Panics if any of the following conditions are met: - /// - The user ID argument is a string and it's not a valid ID (expected things like `"123456789"`) - /// - The client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. /// /// # 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// Returns [`Err`] if: + /// - The specified user has not logged in to Top.gg. ([`NotFound`][crate::Error::NotFound]) + /// - 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 + /// let has_voted = client.has_voted(8226924471638491136).await.unwrap(); + /// ``` pub async fn has_voted(&self, user_id: I) -> Result where I: Snowflake, @@ -279,18 +330,24 @@ impl Client { .map(|res| res.voted != 0) } - /// Checks if the weekend multiplier is active. + /// Checks if the weekend multiplier is active, where a single vote counts as two. /// /// # Panics /// - /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if the client uses an invalid API token. /// /// # 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](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// 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]) + /// + /// # Example + /// + /// ```rust,no_run + /// let is_weekend = client.is_weekend().await.unwrap(); + /// ``` pub async fn is_weekend(&self) -> Result { self .inner diff --git a/src/error.rs b/src/error.rs index 17fe2e4..6e5fa4c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,21 +1,23 @@ -use core::{fmt, result}; -use std::error; +use std::{error, fmt, result}; -/// A struct representing an error coming from this SDK - unexpected or not. +/// An error coming from this SDK. #[derive(Debug)] pub enum Error { - /// An unexpected internal error coming from the client itself, preventing it from sending a request to [Top.gg](https://top.gg). + /// HTTP request failure from the client-side. InternalClientError(reqwest::Error), - /// An unexpected error coming from [Top.gg](https://top.gg)'s servers themselves. + /// HTTP request failure from the server-side. InternalServerError, - /// The requested resource does not exist. (404) + /// Attempted to send an invalid request to the API. + InvalidRequest, + + /// Such query does not exist. NotFound, - /// The client is being ratelimited from sending more HTTP requests. + /// Ratelimited from sending more requests. Ratelimit { - /// The amount of seconds before the ratelimit is lifted. + /// How long the client should wait in seconds before it could send requests again without receiving a 429. retry_after: u16, }, } @@ -23,13 +25,13 @@ pub enum Error { impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::InternalClientError(err) => write!(f, "internal client error: {err}"), - Self::InternalServerError => write!(f, "internal server error"), - Self::NotFound => write!(f, "not found"), + Self::InternalClientError(err) => write!(f, "Internal Client Error: {err}"), + Self::InternalServerError => write!(f, "Internal Server Error"), + Self::InvalidRequest => write!(f, "Invalid Request"), + Self::NotFound => write!(f, "Not Found"), Self::Ratelimit { retry_after } => write!( f, - "this client is ratelimited, try again in {} seconds", - retry_after / 60 + "Blocked by the API for an hour. Please try again in {retry_after} seconds", ), } } @@ -45,5 +47,5 @@ impl error::Error for Error { } } -/// The [`Result`][std::result::Result] type primarily used in this SDK. +/// The result type primarily used in this SDK. pub type Result = result::Result; diff --git a/src/lib.rs b/src/lib.rs index 0749924..5bb770b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,12 +2,12 @@ #![cfg_attr(docsrs, feature(doc_cfg))] mod snowflake; +mod util; cfg_if::cfg_if! { if #[cfg(feature = "api")] { mod client; mod error; - mod util; #[cfg(feature = "autoposter")] pub(crate) use client::InnerClient; diff --git a/src/snowflake.rs b/src/snowflake.rs index ab5dae6..f0d5372 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -8,153 +8,154 @@ where String::deserialize(deserializer).and_then(|s| s.parse().map_err(D::Error::custom)) } -#[inline(always)] -#[cfg(feature = "api")] -pub(crate) fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Deserialize::deserialize(deserializer) - .map(|s: Vec| s.into_iter().filter_map(|next| next.parse().ok()).collect()) -} - -/// A trait that represents any datatype that can be interpreted as a Discord snowflake/ID. -pub trait Snowflake { - /// The method that converts this value to a [`u64`]. - fn as_snowflake(&self) -> u64; -} - -macro_rules! impl_snowflake( - ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { - $(#[$attr])? - impl Snowflake for $t { - #[inline(always)] - fn as_snowflake(&$self) -> u64 { - $body - } - } - } -); - -impl_snowflake!(self, u64, *self); - -macro_rules! impl_string( - ($($t:ty),+) => {$( - impl_snowflake!(self, $t, (*self).parse().expect("invalid snowflake as it's not numeric")); - )+} -); - -impl_string!(&str, String); - cfg_if::cfg_if! { if #[cfg(feature = "api")] { - macro_rules! impl_topgg_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(self, &$t, (*self).id); - )+} - ); - - impl_topgg_idstruct!( - crate::bot::Bot, - crate::user::User, - crate::user::Voter - ); - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "serenity")] { - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, - &serenity::model::guild::Member, - (*self).user.id.get() - ); + #[inline(always)] + pub(crate) fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Deserialize::deserialize(deserializer) + .map(|s: Vec| s.into_iter().filter_map(|next| next.parse().ok()).collect()) + } - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, - &serenity::model::guild::PartialMember, - (*self).user.as_ref().expect("user property in PartialMember is None").id.get() - ); + /// Any data type that can be interpreted as a Discord ID. + pub trait Snowflake { + /// Converts this value to a [`u64`]. + fn as_snowflake(&self) -> u64; + } - macro_rules! impl_serenity_id( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, (*self).get()); - )+} + macro_rules! impl_snowflake( + ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { + $(#[$attr])? + impl Snowflake for $t { + #[inline(always)] + fn as_snowflake(&$self) -> u64 { + $body + } + } + } ); - impl_serenity_id!( - serenity::model::id::GenericId, - serenity::model::id::UserId - ); + impl_snowflake!(self, u64, *self); - macro_rules! impl_serenity_idstruct( + macro_rules! impl_string( ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, (*self).id.get()); + impl_snowflake!(self, $t, self.parse().expect("Invalid snowflake as it's not numeric.")); )+} ); - impl_serenity_idstruct!( - serenity::model::gateway::PresenceUser, - serenity::model::user::CurrentUser, - serenity::model::user::User - ); - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "serenity-cached")] { - use std::ops::Deref; - - macro_rules! impl_serenity_cacheref( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity-cached")))] self, $t, Snowflake::as_snowflake(&self.deref())); - )+} - ); + impl_string!(&str, String); - impl_serenity_cacheref!( - serenity::cache::UserRef<'_>, - serenity::cache::MemberRef<'_>, - serenity::cache::CurrentUserRef<'_> - ); - } -} + cfg_if::cfg_if! { + if #[cfg(feature = "api")] { + macro_rules! impl_topgg_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(self, &$t, self.id); + )+} + ); -cfg_if::cfg_if! { - if #[cfg(feature = "twilight")] { - #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] - impl Snowflake for twilight_model::id::Id { - #[inline(always)] - fn as_snowflake(&self) -> u64 { - self.get() + impl_topgg_idstruct!( + crate::bot::Bot, + crate::user::Voter + ); } } - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, twilight_model::gateway::presence::UserOrId, match self { - twilight_model::gateway::presence::UserOrId::User(user) => user.id.get(), - twilight_model::gateway::presence::UserOrId::UserId { id } => id.get(), - }); + cfg_if::cfg_if! { + if #[cfg(feature = "serenity")] { + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, + &serenity::model::guild::Member, + self.user.id.get() + ); + + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, + &serenity::model::guild::PartialMember, + self.user.as_ref().expect("User property in PartialMember is None.").id.get() + ); + + macro_rules! impl_serenity_id( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, self.get()); + )+} + ); + + impl_serenity_id!( + serenity::model::id::GenericId, + serenity::model::id::UserId + ); + + macro_rules! impl_serenity_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, self.id.get()); + )+} + ); + + impl_serenity_idstruct!( + serenity::model::gateway::PresenceUser, + serenity::model::user::CurrentUser, + serenity::model::user::User + ); + } + } - macro_rules! impl_twilight_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, (*self).id.get()); - )+} - ); + cfg_if::cfg_if! { + if #[cfg(feature = "serenity-cached")] { + use std::ops::Deref; + + macro_rules! impl_serenity_cacheref( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity-cached")))] self, $t, Snowflake::as_snowflake(&self.deref())); + )+} + ); + + impl_serenity_cacheref!( + serenity::cache::UserRef<'_>, + serenity::cache::MemberRef<'_>, + serenity::cache::CurrentUserRef<'_> + ); + } + } - impl_twilight_idstruct!( - twilight_model::user::CurrentUser, - twilight_model::user::User, - twilight_model::user::UserProfile, - twilight_model::gateway::payload::incoming::invite_create::PartialUser - ); - } -} + cfg_if::cfg_if! { + if #[cfg(feature = "twilight")] { + #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] + impl Snowflake for twilight_model::id::Id { + #[inline(always)] + fn as_snowflake(&self) -> u64 { + self.get() + } + } + + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, twilight_model::gateway::presence::UserOrId, match self { + twilight_model::gateway::presence::UserOrId::User(user) => user.id.get(), + twilight_model::gateway::presence::UserOrId::UserId { id } => id.get(), + }); + + macro_rules! impl_twilight_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, self.id.get()); + )+} + ); + + impl_twilight_idstruct!( + twilight_model::user::CurrentUser, + twilight_model::user::User, + twilight_model::gateway::payload::incoming::invite_create::PartialUser + ); + } + } -cfg_if::cfg_if! { - if #[cfg(feature = "twilight-cached")] { - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "twilight-cached")))] self, - &twilight_cache_inmemory::model::CachedMember, - (*self).user_id().get() - ); + cfg_if::cfg_if! { + if #[cfg(feature = "twilight-cached")] { + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "twilight-cached")))] self, + &twilight_cache_inmemory::model::CachedMember, + self.user_id().get() + ); + } + } } -} +} \ No newline at end of file diff --git a/src/user.rs b/src/user.rs index 42d3dfc..7e21866 100644 --- a/src/user.rs +++ b/src/user.rs @@ -3,95 +3,89 @@ use chrono::{DateTime, Utc}; use serde::Deserialize; /// A struct representing a user's social links. +#[allow(clippy::doc_markdown)] #[derive(Clone, Debug, Deserialize)] +#[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] pub struct Socials { - /// A URL of this user's GitHub account. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] + /// This user's GitHub account URL. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] pub github: Option, - /// A URL of this user's Instagram account. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] + /// This user's Instagram account URL. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] pub instagram: Option, - /// A URL of this user's Reddit account. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] + /// This user's Reddit account URL. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] pub reddit: Option, - /// A URL of this user's Twitter account. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] + /// This user's Twitter account URL. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] pub twitter: Option, - /// A URL of this user's YouTube channel. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] + /// This user's YouTube channel URL. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] pub youtube: Option, } util::debug_struct! { - /// A struct representing a user logged into [Top.gg](https://top.gg). - #[must_use] + /// A struct representing a user logged into Top.gg. #[derive(Clone, Deserialize)] + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] User { public { - /// The Discord ID of this user. - #[serde(deserialize_with = "snowflake::deserialize")] + /// This user's ID. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] id: u64, - /// The username of this user. + /// This user's username. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] username: String, /// The user's bio. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] + #[serde(default, deserialize_with = "util::deserialize_deprecated")] bio: Option, - /// A URL of this user's profile banner image. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] + /// This user's profile banner image. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] banner: Option, - /// A struct of this user's social links. - #[serde(rename = "social")] + /// This user's social links. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] socials: Option, - /// Whether this user is a [Top.gg](https://top.gg) supporter or not. - #[serde(rename = "supporter")] + /// Whether this user is a Top.gg supporter or not. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] is_supporter: bool, - /// Whether this user is a [Top.gg](https://top.gg) certified developer or not. - #[serde(rename = "certifiedDev")] + /// Whether this user is a Top.gg certified developer or not. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] is_certified_dev: bool, - /// Whether this user is a [Top.gg](https://top.gg) moderator or not. - #[serde(rename = "mod")] + /// Whether this user is a Top.gg moderator or not. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] is_moderator: bool, - /// Whether this user is a [Top.gg](https://top.gg) website moderator or not. - #[serde(rename = "webMod")] + /// Whether this user is a Top.gg website moderator or not. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] is_web_moderator: bool, - /// Whether this user is a [Top.gg](https://top.gg) website administrator or not. - #[serde(rename = "admin")] + /// Whether this user is a Top.gg website administrator or not. + #[serde(default, deserialize_with = "util::deserialize_deprecated")] is_admin: bool, } - private { - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - avatar: Option, - } - getters(self) { - /// Retrieves the creation date of this user. - #[must_use] - #[inline(always)] + /// This user's creation date. + #[allow(clippy::missing_panics_doc)] created_at: DateTime => { - util::get_creation_date(self.id) + panic!("The User struct is deprecated as it's no longer supported by API v0.") } - /// Retrieves the Discord avatar URL of this user. - /// - /// Its format will either be PNG or GIF if animated. - #[must_use] - #[inline(always)] + /// This user's avatar URL. + #[allow(clippy::missing_panics_doc)] avatar: String => { - util::get_avatar(&self.avatar, self.id) + panic!("The User struct is deprecated as it's no longer supported by API v0.") } } } @@ -103,38 +97,34 @@ pub(crate) struct Voted { } util::debug_struct! { - /// A struct representing a user who has voted on a Discord bot listed on [Top.gg](https://top.gg). (See [`Client::get_voters`][crate::Client::get_voters]) + /// A struct representing 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 { - /// The Discord ID of this user. + /// This voter's ID. #[serde(deserialize_with = "snowflake::deserialize")] id: u64, - /// The username of this user. + /// This voter's username. username: String, - } - private { - avatar: Option, + /// This voter's avatar URL. + avatar: String, } getters(self) { - /// Retrieves the creation date of this user. + /// This voter's creation date. #[must_use] #[inline(always)] created_at: DateTime => { util::get_creation_date(self.id) } - /// Retrieves the Discord avatar URL of this user. - /// - /// Its format will either be PNG or GIF if animated. - #[must_use] - #[inline(always)] + /// This voter's avatar URL. + #[deprecated(since = "1.5.0", note = "Just directly use the public `avatar` property.")] avatar: String => { - util::get_avatar(&self.avatar, self.id) + self.avatar.clone() } } } diff --git a/src/util.rs b/src/util.rs index edaa15c..6d548f7 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,141 +1,164 @@ -use crate::Error; -use chrono::{DateTime, TimeZone, Utc}; -use reqwest::Response; -use serde::{de::DeserializeOwned, Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer}; -const DISCORD_EPOCH: u64 = 1_420_070_400_000; +#[inline(always)] +pub(crate) fn deserialize_deprecated<'de, D, T>(_deserializer: D) -> Result +where + T: Default + Deserialize<'de>, + D: Deserializer<'de>, +{ + Ok(T::default()) +} -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 - )* - })? - } - ) => { - $(#[$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, - )*)? - } +cfg_if::cfg_if! { + if #[cfg(feature = "api")] { + use crate::{snowflake, Error}; + use base64::Engine; + use chrono::{DateTime, TimeZone, Utc}; + use reqwest::Response; + use serde::de::DeserializeOwned; - $(impl $struct_name { - $( - $(#[$getter_attr])* - pub fn $getter_name(&$self) -> $getter_type $getter_code - )* - })? + const DISCORD_EPOCH: u64 = 1_420_070_400_000; - impl std::fmt::Debug for $struct_name { - fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { - fmt - .debug_struct(stringify!($struct_name)) + 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 { $($( - .field(stringify!($pub_prop_name), &self.$pub_prop_name) + $(#[$pub_prop_attr])* + pub $pub_prop_name: $pub_prop_type, )*)? $($( - .field(stringify!($getter_name), &self.$getter_name()) + $(#[$protected_prop_attr])* + pub(crate) $protected_prop_name: $protected_prop_type, )*)? - .finish() - } + $($( + $(#[$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, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok(match ::deserialize(deserializer) { - Ok(s) => { - if s.is_empty() { - None - } else { - Some(s) + + pub(crate) use debug_struct; + + #[inline(always)] + pub(crate) fn deserialize_optional_string<'de, D>( + deserializer: D, + ) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Ok( + String::deserialize(deserializer) + .ok() + .filter(|s| !s.is_empty()), + ) + } + + #[inline(always)] + pub(crate) fn deserialize_default<'de, D, T>(deserializer: D) -> Result + where + T: Default + Deserialize<'de>, + D: Deserializer<'de>, + { + 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 + T: DeserializeOwned, + { + if let Ok(bytes) = response.bytes().await { + if let Ok(json) = serde_json::from_slice(&bytes) { + return Ok(json); + } } + + Err(Error::InternalServerError) } - _ => None, - }) -} - -#[inline(always)] -pub(crate) fn deserialize_default<'de, D, T>(deserializer: D) -> Result -where - T: Default + Deserialize<'de>, - D: Deserializer<'de>, -{ - Option::deserialize(deserializer).map(|res| res.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 - T: DeserializeOwned, -{ - if let Ok(bytes) = response.bytes().await { - if let Ok(json) = serde_json::from_slice(&bytes) { - return Ok(json); + + #[derive(Deserialize)] + #[allow(clippy::used_underscore_binding)] + struct TokenStructure { + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, } - } - - Err(Error::InternalServerError) -} - -pub(crate) fn get_avatar(hash: &Option, 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") + + pub(crate) fn parse_api_token(token: &str) -> u64 { + if let Some(base64_section) = token.split('.').nth(1) { + if let Ok(decoded_base64) = + base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) + { + if let Ok(token_structure) = serde_json::from_slice::(&decoded_base64) { + return token_structure.id; + } + } + } + + panic!("Got a malformed API token."); } - _ => format!( - "https://cdn.discordapp.com/embed/avatars/{}.png", - (id >> 22) % 5 - ), } -} +} \ No newline at end of file diff --git a/src/webhook/actix_web.rs b/src/webhook/actix_web.rs index 43bff60..10109e7 100644 --- a/src/webhook/actix_web.rs +++ b/src/webhook/actix_web.rs @@ -1,11 +1,11 @@ use crate::{IncomingVote, Vote}; use actix_web::{ dev::Payload, - error::{Error, ErrorUnauthorized}, + error::{Error, ErrorBadRequest}, web::Json, FromRequest, HttpRequest, }; -use core::{ +use std::{ future::Future, pin::Pin, task::{ready, Context, Poll}, @@ -34,7 +34,7 @@ impl Future for IncomingVoteFut { } } - Poll::Ready(Err(ErrorUnauthorized("401"))) + Poll::Ready(Err(ErrorBadRequest("400"))) } } diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs index 76d804f..354459a 100644 --- a/src/webhook/axum.rs +++ b/src/webhook/axum.rs @@ -37,7 +37,7 @@ where if let Ok(vote) = serde_json::from_str(&body) { webhook.state.voted(vote).await; - return (StatusCode::OK, ()).into_response(); + return (StatusCode::NO_CONTENT, ()).into_response(); } } } @@ -46,9 +46,9 @@ where (StatusCode::UNAUTHORIZED, ()).into_response() } -/// Creates a new [`axum`] [`Router`] for adding an on-vote event handler to your application logic. -/// -/// # Examples +/// Creates a new axum [`Router`] for receiving vote events. +/// +/// # Example /// /// Basic usage: /// diff --git a/src/webhook/mod.rs b/src/webhook/mod.rs index d7012d9..674de01 100644 --- a/src/webhook/mod.rs +++ b/src/webhook/mod.rs @@ -10,7 +10,7 @@ mod rocket; cfg_if::cfg_if! { if #[cfg(feature = "axum")] { - /// Wrapper for working with the [`axum`](https://crates.io/crates/axum) web framework. + /// Extra helpers for working with axum. #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] pub mod axum; } @@ -18,7 +18,7 @@ cfg_if::cfg_if! { cfg_if::cfg_if! { if #[cfg(feature = "warp")] { - /// Wrapper for working with the [`warp`](https://crates.io/crates/warp) web framework. + /// Extra helpers for working with warp. #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] pub mod warp; } diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index 2360767..ec161e3 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -1,4 +1,4 @@ -use crate::snowflake; +use crate::{snowflake, util}; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; @@ -10,18 +10,6 @@ where String::deserialize(deserializer).map(|s| s == "test") } -const fn _true() -> bool { - true -} - -#[inline(always)] -fn deserialize_is_server<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - Ok(String::deserialize(deserializer).is_err()) -} - fn deserialize_query_string<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -45,7 +33,7 @@ where ) } -/// A struct representing a dispatched [Top.gg](https://top.gg) bot/server vote event. +/// A struct representing a dispatched Top.gg bot/server vote event. #[must_use] #[derive(Clone, Debug, Deserialize)] pub struct Vote { @@ -62,11 +50,8 @@ pub struct Vote { pub voter_id: u64, /// Whether this vote's receiver is a server or not (bot otherwise). - #[serde( - default = "_true", - deserialize_with = "deserialize_is_server", - rename = "bot" - )] + #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[deprecated(since = "1.5.0", note = "No longer supported.")] pub is_server: bool, /// Whether this vote is just a test coming from the bot/server owner or not. Most of the time this would be `false`. @@ -99,7 +84,7 @@ cfg_if::cfg_if! { impl IncomingVote { /// Authenticates a valid password with this request. Returns a [`Some(Vote)`][`Vote`] if succeeds, otherwise `None`. /// - /// # Examples + /// # Example /// /// Basic usage: /// @@ -108,7 +93,7 @@ cfg_if::cfg_if! { /// Some(vote) => { /// println!("{:?}", vote); /// - /// // respond with 200 OK... + /// // respond with 204 NO CONTENT... /// }, /// _ => { /// println!("found an unauthorized attacker."); @@ -144,7 +129,7 @@ cfg_if::cfg_if! { #[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. The endpoint will always return a 200 (OK) HTTP status code after running this method. + /// 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 index 13e1238..1dd4beb 100644 --- a/src/webhook/warp.rs +++ b/src/webhook/warp.rs @@ -2,9 +2,9 @@ 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. +/// Creates a new warp [`Filter`] for adding an on-vote event handler to your application logic. /// -/// # Examples +/// # Example /// /// Basic usage: /// @@ -63,7 +63,7 @@ where if auth == *current_password { current_state.voted(vote).await; - StatusCode::OK + StatusCode::NO_CONTENT } else { StatusCode::UNAUTHORIZED } From c88d996978141a7fbe7df41f4b27119c5596ce23 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 22 Sep 2025 19:42:50 +0700 Subject: [PATCH 3/8] refactor: use serde(skip) --- Cargo.toml | 6 +- src/autoposter/mod.rs | 2 +- src/bot.rs | 129 +++++++++++++++++-- src/client.rs | 48 ++++++- src/lib.rs | 4 +- src/snowflake.rs | 2 +- src/test.rs | 56 +++++++++ src/user.rs | 36 +++--- src/util.rs | 285 ++++++++++++++++++++---------------------- src/webhook/axum.rs | 2 +- src/webhook/vote.rs | 8 +- 11 files changed, 389 insertions(+), 189 deletions(-) create mode 100644 src/test.rs diff --git a/Cargo.toml b/Cargo.toml index cee778b..fb33b69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ paste = { version = "1", optional = true } reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } -urlencoding = "2" +urlencoding = { version = "2", optional = true } serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } @@ -72,8 +72,8 @@ serenity-cached = ["serenity", "serenity/cache"] twilight = ["twilight-model"] twilight-cached = ["twilight", "twilight-cache-inmemory"] -webhook = [] +webhook = ["urlencoding"] rocket = ["webhook", "dep:rocket"] axum = ["webhook", "async-trait", "serde_json", "dep:axum"] warp = ["webhook", "async-trait", "dep:warp"] -actix-web = ["webhook", "dep:actix-web"] +actix-web = ["webhook", "dep:actix-web"] \ No newline at end of file diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 9857d3b..63e8b0d 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -33,7 +33,7 @@ cfg_if::cfg_if! { } } -/// A struct representing a thread-safe form of the [`Stats`] struct to be used in autoposter [`Handler`]s. +/// A thread-safe form of the [`Stats`] struct to be used in autoposter [`Handler`]s. pub struct SharedStats { sem: Semaphore, stats: RwLock, diff --git a/src/bot.rs b/src/bot.rs index e68bd65..36297ba 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,6 +1,12 @@ -use crate::{snowflake, util}; +use crate::{snowflake, util, Client}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Deserializer, Serialize}; +use std::{ + cmp::min, + fmt::Write, + future::{Future, IntoFuture}, + pin::Pin, +}; #[inline(always)] pub(crate) fn deserialize_support_server<'de, D>( @@ -14,7 +20,7 @@ where } util::debug_struct! { - /// A struct representing a Discord Bot listed on Top.gg. + /// A Discord Bot listed on Top.gg. #[must_use] #[derive(Clone, Deserialize)] Bot { @@ -31,7 +37,7 @@ util::debug_struct! { username: String, /// This bot's discriminator. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] discriminator: String, @@ -67,17 +73,17 @@ util::debug_struct! { owners: Vec, /// This bot's guild IDs. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] guilds: Vec, /// This bot's banner image URL. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] banner_url: Option, /// This bot's approval date. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] #[deprecated(since = "1.5.0", note = "Actually refers to submission date. Use `submitted_at` instead.")] approved_at: DateTime, @@ -86,12 +92,12 @@ util::debug_struct! { submitted_at: DateTime, /// Whether this bot is certified or not. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] is_certified: bool, /// This bot's shards. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] shards: Vec, @@ -169,8 +175,13 @@ util::debug_struct! { } } +#[derive(Deserialize)] +pub(crate) struct Bots { + pub(crate) results: Vec, +} + util::debug_struct! { - /// A struct representing a Discord bot's statistics. + /// A Discord bot's statistics. /// /// # Example /// @@ -257,3 +268,103 @@ impl From for Stats { pub(crate) struct IsWeekend { pub(crate) is_weekend: bool, } + +/// Query configuration for [`get_bots`][crate::Client::get_bots]. +#[must_use] +pub struct GetBots<'a> { + client: &'a Client, + query: String, + sort: Option<&'static str>, +} + +macro_rules! get_bots_method { + ($( + $(#[$details:meta])* + $input_name:ident: $input_type:ty $(= $property:ident($($format:tt)*))?; + )*) => {$( + $(#[$details])* + #[allow(unused, unused_mut)] + pub fn $input_name(mut self, $input_name: $input_type) -> Self { + $(write!(&mut self.$property, $($format)*).unwrap();)? + self + } + )*}; +} + +macro_rules! get_bots_sort { + ($( + $(#[$details:meta])* + $func_name:ident: $api_name:ident, + )*) => {$( + $(#[$details])* + pub fn $func_name(mut self) -> Self { + self.sort.replace(stringify!($api_name)); + self + } + )*}; +} + +impl<'a> GetBots<'a> { + #[inline(always)] + pub(crate) fn new(client: &'a Client) -> Self { + Self { + client, + query: String::from('?'), + sort: None, + } + } + + get_bots_sort! { + /// Sorts results based on each bot's ID. + sort_by_id: id, + + /// 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 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. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + username: &str; + + /// Queries only bots that has this prefix. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + prefix: &str; + + /// Queries only bots that has this vote count. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + votes: usize; + + /// Queries only bots that has this monthly vote count. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + monthly_votes: usize; + + /// Queries only bots that has this Top.gg vanity URL. + #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + vanity: &str; + } +} + +impl<'a> IntoFuture for GetBots<'a> { + type Output = crate::Result>; + type IntoFuture = Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + let mut query = self.query; + + if let Some(sort) = self.sort { + write!(&mut query, "sort={sort}&").unwrap(); + } + + Box::pin(self.client.get_bots_inner(query)) + } +} diff --git a/src/client.rs b/src/client.rs index e43f1ca..14d53f4 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,6 @@ #[allow(deprecated)] use crate::{ - bot::{Bot, IsWeekend}, + bot::{Bot, Bots, GetBots, IsWeekend}, user::{User, Voted, Voter}, util, Error, Result, Snowflake, Stats, }; @@ -294,6 +294,52 @@ impl Client { .await } + pub(crate) async fn get_bots_inner(&self, query: String) -> Result> { + self + .inner + .send::(Method::GET, api!("/bots{}", query), None) + .await + .map(|res| res.results) + } + + /// Queries/searches through the Top.gg database to look for matching listed Discord bots. + /// + /// # Panics + /// + /// Panics if any of the client uses an invalid [Top.gg API](https://docs.top.gg) 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 client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Examples + /// + /// Basic usage: + /// + /// ```rust,no_run + /// use topgg::{Client, GetBots}; + /// + /// let client = Client::new(env!("TOPGG_TOKEN").to_string()); + /// + /// let bots = client + /// .get_bots() + /// .limit(250) + /// .skip(50) + /// .sort_by_monthly_votes() + /// .await; + /// + /// for bot in bots { + /// println!("{:?}", bot); + /// } + /// ``` + #[inline(always)] + 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. /// /// # Panics diff --git a/src/lib.rs b/src/lib.rs index 5bb770b..403ec1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,12 +2,14 @@ #![cfg_attr(docsrs, feature(doc_cfg))] mod snowflake; -mod util; +#[cfg(test)] +mod test; cfg_if::cfg_if! { if #[cfg(feature = "api")] { mod client; mod error; + mod util; #[cfg(feature = "autoposter")] pub(crate) use client::InnerClient; diff --git a/src/snowflake.rs b/src/snowflake.rs index f0d5372..e29981c 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -158,4 +158,4 @@ cfg_if::cfg_if! { } } } -} \ No newline at end of file +} diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..5622641 --- /dev/null +++ b/src/test.rs @@ -0,0 +1,56 @@ +use crate::{Client, Stats}; +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.username, "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_stats(Stats { + server_count: Some(2) + }) + .await + .unwrap(); + } + + delayed! { + assert_eq!(client.get_stats().await.unwrap().server_count, Some(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(); + } +} diff --git a/src/user.rs b/src/user.rs index 7e21866..a0fa12f 100644 --- a/src/user.rs +++ b/src/user.rs @@ -2,76 +2,76 @@ use crate::{snowflake, util}; use chrono::{DateTime, Utc}; use serde::Deserialize; -/// A struct representing a user's social links. +/// A user's social links. #[allow(clippy::doc_markdown)] #[derive(Clone, Debug, Deserialize)] #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] pub struct Socials { /// This user's GitHub account URL. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] pub github: Option, /// This user's Instagram account URL. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] pub instagram: Option, /// This user's Reddit account URL. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] pub reddit: Option, /// This user's Twitter account URL. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] pub twitter: Option, /// This user's YouTube channel URL. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] pub youtube: Option, } util::debug_struct! { - /// A struct representing a user logged into Top.gg. + /// A user logged into Top.gg. #[derive(Clone, Deserialize)] #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] User { public { /// This user's ID. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] id: u64, /// This user's username. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] username: String, /// The user's bio. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] bio: Option, /// This user's profile banner image. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] banner: Option, /// This user's social links. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] socials: Option, /// Whether this user is a Top.gg supporter or not. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] is_supporter: bool, /// Whether this user is a Top.gg certified developer or not. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] is_certified_dev: bool, /// Whether this user is a Top.gg moderator or not. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] is_moderator: bool, /// Whether this user is a Top.gg website moderator or not. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] is_web_moderator: bool, /// Whether this user is a Top.gg website administrator or not. - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] is_admin: bool, } @@ -97,7 +97,7 @@ pub(crate) struct Voted { } util::debug_struct! { - /// A struct representing a user who has voted on a Discord bot listed on Top.gg. (See [`Client::get_voters`][crate::Client::get_voters]) + /// 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 { diff --git a/src/util.rs b/src/util.rs index 6d548f7..a1ad420 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,164 +1,149 @@ -use serde::{Deserialize, Deserializer}; +use crate::{snowflake, Error}; +use base64::Engine; +use chrono::{DateTime, TimeZone, Utc}; +use reqwest::Response; +use serde::{de::DeserializeOwned, Deserialize, Deserializer}; -#[inline(always)] -pub(crate) fn deserialize_deprecated<'de, D, T>(_deserializer: D) -> Result -where - T: Default + Deserialize<'de>, - D: Deserializer<'de>, -{ - Ok(T::default()) -} +const DISCORD_EPOCH: u64 = 1_420_070_400_000; -cfg_if::cfg_if! { - if #[cfg(feature = "api")] { - use crate::{snowflake, Error}; - use base64::Engine; - use chrono::{DateTime, TimeZone, Utc}; - use reqwest::Response; - use serde::de::DeserializeOwned; +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, + )*)? + } - const DISCORD_EPOCH: u64 = 1_420_070_400_000; + $( + #[allow(deprecated)] + impl $struct_name { + $( + $(#[$getter_attr])* + pub fn $getter_name(&$self) -> $getter_type $getter_code + )* + } + )? - 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 { + #[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)) $($( - $(#[$pub_prop_attr])* - pub $pub_prop_name: $pub_prop_type, + .field(stringify!($pub_prop_name), &self.$pub_prop_name) )*)? $($( - $(#[$protected_prop_attr])* - pub(crate) $protected_prop_name: $protected_prop_type, + .field(stringify!($getter_name), &self.$getter_name()) )*)? - $($( - $(#[$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, - ) -> Result, D::Error> - where - D: Deserializer<'de>, - { - Ok( - String::deserialize(deserializer) - .ok() - .filter(|s| !s.is_empty()), - ) - } - - #[inline(always)] - pub(crate) fn deserialize_default<'de, D, T>(deserializer: D) -> Result - where - T: Default + Deserialize<'de>, - D: Deserializer<'de>, - { - 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 - T: DeserializeOwned, - { - if let Ok(bytes) = response.bytes().await { - if let Ok(json) = serde_json::from_slice(&bytes) { - return Ok(json); - } + .finish() } - - Err(Error::InternalServerError) } - - #[derive(Deserialize)] - #[allow(clippy::used_underscore_binding)] - struct TokenStructure { - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, + }; +} + +pub(crate) use debug_struct; + +#[inline(always)] +pub(crate) fn deserialize_optional_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok( + String::deserialize(deserializer) + .ok() + .filter(|s| !s.is_empty()), + ) +} + +#[inline(always)] +pub(crate) fn deserialize_default<'de, D, T>(deserializer: D) -> Result +where + T: Default + Deserialize<'de>, + D: Deserializer<'de>, +{ + 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 + T: DeserializeOwned, +{ + if let Ok(bytes) = response.bytes().await { + if let Ok(json) = serde_json::from_slice(&bytes) { + return Ok(json); } - - pub(crate) fn parse_api_token(token: &str) -> u64 { - if let Some(base64_section) = token.split('.').nth(1) { - if let Ok(decoded_base64) = - base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) - { - if let Ok(token_structure) = serde_json::from_slice::(&decoded_base64) { - return token_structure.id; - } - } + } + + Err(Error::InternalServerError) +} + +#[derive(Deserialize)] +#[allow(clippy::used_underscore_binding)] +struct TokenStructure { + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, +} + +pub(crate) fn parse_api_token(token: &str) -> u64 { + if let Some(base64_section) = token.split('.').nth(1) { + if let Ok(decoded_base64) = + base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) + { + if let Ok(token_structure) = serde_json::from_slice::(&decoded_base64) { + return token_structure.id; } - - panic!("Got a malformed API token."); } } -} \ No newline at end of file + + panic!("Got a malformed API token."); +} diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs index 354459a..e4102bd 100644 --- a/src/webhook/axum.rs +++ b/src/webhook/axum.rs @@ -47,7 +47,7 @@ where } /// Creates a new axum [`Router`] for receiving vote events. -/// +/// /// # Example /// /// Basic usage: diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index ec161e3..b48b995 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -1,4 +1,4 @@ -use crate::{snowflake, util}; +use crate::snowflake; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; @@ -33,7 +33,7 @@ where ) } -/// A struct representing a dispatched Top.gg bot/server vote event. +/// A dispatched Top.gg bot/server vote event. #[must_use] #[derive(Clone, Debug, Deserialize)] pub struct Vote { @@ -50,7 +50,7 @@ pub struct Vote { pub voter_id: u64, /// Whether this vote's receiver is a server or not (bot otherwise). - #[serde(default, deserialize_with = "util::deserialize_deprecated")] + #[serde(skip)] #[deprecated(since = "1.5.0", note = "No longer supported.")] pub is_server: bool, @@ -70,7 +70,7 @@ pub struct Vote { cfg_if::cfg_if! { if #[cfg(any(feature = "actix-web", feature = "rocket"))] { - /// A struct that represents an **unauthenticated** request containing a [`Vote`] data. + /// 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] From 60e3e22cfb9da3f44d700016ecf8a9a747894068 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 22 Sep 2025 21:29:41 +0700 Subject: [PATCH 4/8] revert: revert more changes --- Cargo.toml | 20 +-- README.md | 115 ++++++++++++++---- src/autoposter/mod.rs | 207 +++++++++++++++++++++++++------- src/autoposter/serenity_impl.rs | 2 +- src/bot.rs | 188 +++++++++++++++++++++-------- src/client.rs | 119 +++++++++++++----- src/lib.rs | 4 +- src/test.rs | 56 --------- src/user.rs | 17 ++- src/util.rs | 14 +++ src/webhook/axum.rs | 65 ++++++++-- src/webhook/rocket.rs | 2 +- src/webhook/vote.rs | 175 +++++++++++++++++++++++++-- src/webhook/warp.rs | 40 +++++- 14 files changed, 778 insertions(+), 246 deletions(-) delete mode 100644 src/test.rs diff --git a/Cargo.toml b/Cargo.toml index fb33b69..4fe75a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ paste = { version = "1", optional = true } reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } -urlencoding = { version = "2", optional = true } +urlencoding = "2" serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } @@ -31,12 +31,12 @@ 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] @@ -66,14 +66,16 @@ default = ["api"] 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-cached = ["twilight", "twilight-cache-inmemory"] -webhook = ["urlencoding"] -rocket = ["webhook", "dep:rocket"] +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"] \ No newline at end of file +actix-web = ["webhook", "dep:actix-web"] + +_internal-doctest = [] \ No newline at end of file diff --git a/README.md b/README.md index 878ef01..a54743e 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 @@ -14,27 +17,28 @@ Make sure to have a [Top.gg API](https://docs.top.gg) token handy. If not, then 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,7 +136,9 @@ async fn main() { } ``` -### Autoposting with [serenity](https://crates.io/crates/serenity) +
+
+
+autoposter, twilight: Automating the process of periodically posting your Discord bot's statistics with the twilight library In your `Cargo.toml`: @@ -156,7 +213,7 @@ topgg = { version = "1.5", features = ["autoposter", "twilight-cached"] } In your code: ```rust,no_run -use std::time::Duration; +use core::time::Duration; use topgg::Autoposter; use twilight_gateway::{Event, Intents, Shard, ShardId}; @@ -196,7 +253,9 @@ 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 bot/server's vote events In your `Cargo.toml`: @@ -241,7 +300,9 @@ 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 bot/server's vote events In your `Cargo.toml`: @@ -288,7 +349,9 @@ async fn main() { } ``` -### Writing a [rocket](https://rocket.rs) webhook for listening to votes +
+
+rocket: Writing a rocket webhook for listening to your bot/server's vote events In your `Cargo.toml`: @@ -333,7 +396,9 @@ fn main() { } ``` -### Writing a [warp](https://crates.io/crates/warp) webhook for listening to votes +
+
+warp: Writing a warp webhook for listening to your bot/server's vote events In your `Cargo.toml`: @@ -376,3 +441,5 @@ async fn main() { warp::serve(routes).run(addr).await } ``` + +
diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 63e8b0d..8593948 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -5,7 +5,7 @@ use std::{ time::Duration, }; use tokio::{ - sync::{mpsc, RwLock, RwLockWriteGuard, Semaphore}, + sync::{mpsc, RwLock, RwLockWriteGuard}, task::{spawn, JoinHandle}, time::sleep, }; @@ -16,37 +16,35 @@ pub use client::AsClient; pub(crate) use client::AsClientSealed; cfg_if::cfg_if! { - if #[cfg(feature = "serenity")] { + if #[cfg(any(feature = "serenity", feature = "serenity-cached"))] { mod serenity_impl; - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] + #[cfg_attr(docsrs, doc(cfg(any(feature = "serenity", feature = "serenity-cached"))))] pub use serenity_impl::Serenity; } } cfg_if::cfg_if! { - if #[cfg(feature = "twilight")] { + if #[cfg(any(feature = "twilight", feature = "twilight-cached"))] { mod twilight_impl; - #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] + #[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 { - sem: Semaphore, 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> { - sem: &'a Semaphore, guard: RwLockWriteGuard<'a, Stats>, } impl SharedStatsGuard<'_> { - /// Directly replaces the current [`Stats`] inside with the other. + /// Directly replaces the current [`Stats`] inside with another. #[inline(always)] pub fn replace(&mut self, other: Stats) { *self.guard = other; @@ -54,13 +52,22 @@ impl SharedStatsGuard<'_> { /// 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) { - self.guard.server_count = Some(server_count); + 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. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] - pub fn set_shard_count(&mut self, _shard_count: usize) {} + #[inline(always)] + pub fn set_shard_count(&mut self, shard_count: usize) { + self.guard.shard_count = Some(shard_count); + } } impl Deref for SharedStatsGuard<'_> { @@ -79,21 +86,11 @@ impl DerefMut for SharedStatsGuard<'_> { } } -impl Drop for SharedStatsGuard<'_> { - #[inline(always)] - fn drop(&mut self) { - if self.sem.available_permits() < 1 { - self.sem.add_permits(1); - } - } -} - 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 { - sem: Semaphore::const_new(0), stats: RwLock::new(Stats::from(0)), } } @@ -102,15 +99,9 @@ impl SharedStats { #[inline(always)] pub async fn write(&self) -> SharedStatsGuard<'_> { SharedStatsGuard { - sem: &self.sem, guard: self.stats.write().await, } } - - #[inline(always)] - async fn wait(&self) { - self.sem.acquire().await.unwrap().forget(); - } } impl Default for SharedStats { @@ -122,7 +113,7 @@ impl Default for SharedStats { /// 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 guild count. +/// 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; @@ -143,12 +134,12 @@ where H: Handler, { /// Creates and starts an autoposter thread. - #[allow(unused_mut)] + #[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(test))] + #[cfg(not(any(test, feature = "_internal-doctest")))] if interval.as_secs() < 900 { interval = Duration::from_secs(900); } @@ -161,8 +152,6 @@ where handler: Arc::clone(&handler), thread: spawn(async move { loop { - handler.stats().wait().await; - { let stats = handler.stats().stats.read().await; @@ -219,21 +208,40 @@ impl Deref for Autoposter { } } -#[cfg(feature = "serenity")] -#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] +#[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,no_run + /// ```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; /// - /// #[serenity::async_trait] + /// #[async_trait::async_trait] /// impl EventHandler for AutoposterHandler { /// async fn ready(&self, _: Context, ready: Ready) { /// println!("{} is now ready!", ready.user.name); @@ -244,9 +252,17 @@ impl Autoposter { /// async fn main() { /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); /// - /// // Posts once every 30 minutes + /// # /* /// 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; /// @@ -256,18 +272,50 @@ impl Autoposter { /// .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 @@ -278,22 +326,62 @@ impl Autoposter { } } -#[cfg(feature = "twilight")] -#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] +#[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,no_run + /// ```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, @@ -302,10 +390,16 @@ impl Autoposter { /// ); /// /// 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; /// } /// @@ -318,11 +412,42 @@ impl Autoposter { /// 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)] diff --git a/src/autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs index eaca46f..26e3117 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -79,7 +79,7 @@ macro_rules! serenity_handler { )* } - #[serenity::async_trait] + #[async_trait::async_trait] #[allow(unused_variables)] impl EventHandler for Serenity { $( diff --git a/src/bot.rs b/src/bot.rs index 36297ba..4ba8ce3 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,6 +1,6 @@ use crate::{snowflake, util, Client}; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use std::{ cmp::min, fmt::Write, @@ -8,15 +8,15 @@ use std::{ pin::Pin, }; -#[inline(always)] -pub(crate) fn deserialize_support_server<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - util::deserialize_optional_string(deserializer) - .map(|inner| inner.map(|support| format!("https://discord.com/invite/{support}"))) +/// A Discord bot's reviews on Top.gg. +#[derive(Clone, Debug, Deserialize)] +pub struct BotReviews { + /// This bot's average review score out of 5. + #[serde(rename = "averageScore")] + pub score: f64, + + /// This bot's review count. + pub count: usize, } util::debug_struct! { @@ -38,7 +38,6 @@ util::debug_struct! { /// This bot's discriminator. #[serde(skip)] - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] discriminator: String, /// This bot's prefix. @@ -72,14 +71,12 @@ util::debug_struct! { #[serde(deserialize_with = "snowflake::deserialize_vec")] owners: Vec, - /// This bot's guild IDs. + /// This bot's server IDs. #[serde(skip)] - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] guilds: Vec, /// This bot's banner image URL. #[serde(skip)] - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] banner_url: Option, /// This bot's approval date. @@ -93,12 +90,10 @@ util::debug_struct! { /// Whether this bot is certified or not. #[serde(skip)] - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] is_certified: bool, /// This bot's shards. #[serde(skip)] - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] shards: Vec, /// The amount of votes this bot has. @@ -110,7 +105,7 @@ util::debug_struct! { monthly_votes: usize, /// This bot's support URL. - #[serde(default, deserialize_with = "deserialize_support_server")] + #[serde(default, deserialize_with = "util::deserialize_optional_string")] support: Option, /// This bot's avatar URL. @@ -123,6 +118,10 @@ util::debug_struct! { /// This bot's posted server count. #[serde(default)] server_count: Option, + + /// This bot's reviews. + #[serde(rename = "reviews")] + review: BotReviews, } private { @@ -157,7 +156,6 @@ util::debug_struct! { } /// This bot's shard count. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] shard_count: usize => { 0 } @@ -183,41 +181,86 @@ pub(crate) struct Bots { util::debug_struct! { /// A Discord bot's statistics. /// - /// # Example + /// # Examples + /// + /// Solely from a server count: + /// + /// ```rust + /// use topgg::Stats; /// - /// ```rust,no_run + /// let _stats = Stats::from(12345); + /// ``` + /// + /// Server count with a shard count: + /// + /// ```rust /// use topgg::Stats; /// - /// let _stats = Stats { - /// server_count: Some(12345), - /// }; + /// 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 { - public { - /// The amount of servers this bot is in. `None` if such information is publicly unavailable. + 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. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + #[must_use] + #[inline(always)] shards: &[usize] => { - &[] + match self.shards { + Some(ref shards) => shards, + None => &[], + } } /// This bot's shard count. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] + #[must_use] + #[inline(always)] shard_count: usize => { - 0 + 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. - #[deprecated(since = "1.5.0", note = "Just directly use the public `server_count` property.")] + #[must_use] server_count: Option => { - self.server_count + self.server_count.or_else(|| { + self.shards.as_ref().and_then(|shards| { + if shards.is_empty() { + None + } else { + Some(shards.iter().copied().sum()) + } + }) + }) } } } @@ -225,31 +268,63 @@ util::debug_struct! { impl Stats { /// Creates a [`Stats`] struct from the cache of a serenity [`Context`][serenity::client::Context]. - #[inline(always)] #[cfg(feature = "serenity-cached")] #[cfg_attr(docsrs, doc(cfg(feature = "serenity-cached")))] pub fn from_context(context: &serenity::client::Context) -> Self { - Self { - server_count: Some(context.cache.guilds().len()), - } + 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. - #[deprecated(since = "1.5.0", note = "Just directly use a struct declaration.")] - pub const fn from_count(server_count: usize, _shard_count: Option) -> Self { + 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. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] - pub fn from_shards(shards: A, _shard_index: Option) -> Self + /// + /// # 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(shards.into_iter().sum()), + server_count: Some(total_server_count), + shard_count: Some(shards_list.len()), + shards: Some(shards_list), + shard_id: shard_index, } } } @@ -258,9 +333,7 @@ impl Stats { impl From for Stats { #[inline(always)] fn from(server_count: usize) -> Self { - Self { - server_count: Some(server_count), - } + Self::from_count(server_count, None) } } @@ -274,6 +347,7 @@ pub(crate) struct IsWeekend { pub struct GetBots<'a> { client: &'a Client, query: String, + search: String, sort: Option<&'static str>, } @@ -310,6 +384,7 @@ impl<'a> GetBots<'a> { Self { client, query: String::from('?'), + search: String::new(), sort: None, } } @@ -333,24 +408,25 @@ impl<'a> GetBots<'a> { skip: u16 = query("offset={}&", min(skip, 499)); /// Queries only bots that has this username. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] - username: &str; + 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. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] - prefix: &str; + prefix: &str = search("prefix%3A%20{}%20", urlencoding::encode(prefix)); /// Queries only bots that has this vote count. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] - votes: usize; + votes: usize = search("points%3A%20{votes}%20"); /// Queries only bots that has this monthly vote count. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] - monthly_votes: usize; + monthly_votes: usize = search("monthlyPoints%3A%20{monthly_votes}%20"); + + /// Queries only Top.gg certified bots or not. + certified: bool = search("certifiedBot%3A%20{certified}%20"); /// Queries only bots that has this Top.gg vanity URL. - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] - vanity: &str; + vanity: &str = search("vanity%3A%20{}%20", urlencoding::encode(vanity)); } } @@ -361,6 +437,12 @@ impl<'a> IntoFuture for GetBots<'a> { fn into_future(self) -> Self::IntoFuture { let mut query = self.query; + if self.search.is_empty() { + query.pop(); + } else { + write!(&mut query, "search={}", self.search).unwrap(); + } + if let Some(sort) = self.sort { write!(&mut query, "sort={sort}&").unwrap(); } diff --git a/src/client.rs b/src/client.rs index 14d53f4..353b50e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,3 @@ -#[allow(deprecated)] use crate::{ bot::{Bot, Bots, GetBots, IsWeekend}, user::{User, Voted, Voter}, @@ -83,6 +82,7 @@ impl InnerClient { Ok(response) } else { Err(match status { + 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 { @@ -117,8 +117,8 @@ impl InnerClient { } pub(crate) async fn post_stats(&self, new_stats: &Stats) -> Result<()> { - if new_stats.server_count.unwrap_or(0) == 0 { - return Err(Error::InvalidRequest); + if new_stats.server_count.unwrap_or_default() == 0 { + return Ok(()); } self @@ -147,7 +147,7 @@ impl Client { /// /// # Example /// - /// ```rust,no_run + /// ```rust /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); /// ``` #[inline(always)] @@ -161,7 +161,32 @@ impl Client { } /// Fetches a user from a Discord ID. - #[allow(clippy::unused_async, clippy::missing_errors_doc, deprecated)] + /// + /// # 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 @@ -188,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 @@ -216,8 +246,13 @@ impl Client { /// /// # Example /// - /// ```rust,no_run + /// ```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_stats(&self) -> Result { self @@ -242,12 +277,16 @@ impl Client { /// /// # Example /// - /// ```rust,no_run + /// ```rust /// use topgg::Stats; - /// - /// client.post_stats(Stats { - /// server_count: Some(bot.server_count()), - /// }).await.unwrap(); + /// # + /// # #[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_stats(&self, new_stats: Stats) -> Result<()> { @@ -271,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 @@ -306,7 +357,7 @@ impl Client { /// /// # Panics /// - /// Panics if any of the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized). + /// Panics if any of the client uses an invalid Top.gg API token (unauthorized). /// /// # Errors /// @@ -315,25 +366,27 @@ impl Client { /// - 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]) /// - /// # Examples + /// # Example /// /// Basic usage: /// - /// ```rust,no_run - /// use topgg::{Client, GetBots}; - /// - /// let client = Client::new(env!("TOPGG_TOKEN").to_string()); - /// + /// ```rust + /// # #[tokio::main] + /// # async fn main() { + /// # let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// # /// let bots = client /// .get_bots() /// .limit(250) /// .skip(50) /// .sort_by_monthly_votes() - /// .await; + /// .await + /// .unwrap(); /// /// for bot in bots { /// println!("{:?}", bot); /// } + /// # } /// ``` #[inline(always)] pub fn get_bots(&self) -> GetBots<'_> { @@ -358,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 @@ -391,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 diff --git a/src/lib.rs b/src/lib.rs index 403ec1e..365aa35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,9 +1,7 @@ -#![doc = include_str!("../README.md")] +#![cfg_attr(docsrs, doc = include_str!("../README.md"))] #![cfg_attr(docsrs, feature(doc_cfg))] mod snowflake; -#[cfg(test)] -mod test; cfg_if::cfg_if! { if #[cfg(feature = "api")] { diff --git a/src/test.rs b/src/test.rs deleted file mode 100644 index 5622641..0000000 --- a/src/test.rs +++ /dev/null @@ -1,56 +0,0 @@ -use crate::{Client, Stats}; -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.username, "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_stats(Stats { - server_count: Some(2) - }) - .await - .unwrap(); - } - - delayed! { - assert_eq!(client.get_stats().await.unwrap().server_count, Some(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(); - } -} diff --git a/src/user.rs b/src/user.rs index a0fa12f..cb3ff95 100644 --- a/src/user.rs +++ b/src/user.rs @@ -5,7 +5,6 @@ use serde::Deserialize; /// A user's social links. #[allow(clippy::doc_markdown)] #[derive(Clone, Debug, Deserialize)] -#[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] pub struct Socials { /// This user's GitHub account URL. #[serde(skip)] @@ -31,7 +30,6 @@ pub struct Socials { util::debug_struct! { /// A user logged into Top.gg. #[derive(Clone, Deserialize)] - #[deprecated(since = "1.5.0", note = "No longer supported by API v0.")] User { public { /// This user's ID. @@ -75,17 +73,24 @@ util::debug_struct! { is_admin: bool, } + private { + #[serde(skip)] + avatar: Option, + } + getters(self) { /// This user's creation date. - #[allow(clippy::missing_panics_doc)] + #[must_use] + #[inline(always)] created_at: DateTime => { - panic!("The User struct is deprecated as it's no longer supported by API v0.") + util::get_creation_date(self.id) } /// This user's avatar URL. - #[allow(clippy::missing_panics_doc)] + #[must_use] + #[inline(always)] avatar: String => { - panic!("The User struct is deprecated as it's no longer supported by API v0.") + util::get_avatar(self.avatar.as_ref(), self.id) } } } diff --git a/src/util.rs b/src/util.rs index a1ad420..a9d798f 100644 --- a/src/util.rs +++ b/src/util.rs @@ -127,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/webhook/axum.rs b/src/webhook/axum.rs index e4102bd..be652c6 100644 --- a/src/webhook/axum.rs +++ b/src/webhook/axum.rs @@ -52,14 +52,30 @@ where /// /// Basic usage: /// -/// ```rust,no_run -/// use axum::{routing::get, Router, Server}; -/// use std::{net::SocketAddr, sync::Arc}; +/// ```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 {} /// -/// #[axum::async_trait] +/// #[async_trait::async_trait] /// impl VoteHandler for MyVoteHandler { /// async fn voted(&self, vote: Vote) { /// println!("{:?}", vote); @@ -74,17 +90,50 @@ where /// async fn main() { /// let state = Arc::new(MyVoteHandler {}); /// -/// let app = Router::new().route("/", get(index)).nest( +/// let router = Router::new().route("/", get(index)).nest( /// "/webhook", /// topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), /// ); /// -/// let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); +/// let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); /// -/// Server::bind(&addr) -/// .serve(app.into_make_service()) +/// # 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)] diff --git a/src/webhook/rocket.rs b/src/webhook/rocket.rs index 91f337b..b389ab4 100644 --- a/src/webhook/rocket.rs +++ b/src/webhook/rocket.rs @@ -7,7 +7,7 @@ use rocket::{ }; #[cfg_attr(docsrs, doc(cfg(feature = "rocket")))] -#[rocket::async_trait] +#[async_trait::async_trait] impl<'r> FromData<'r> for IncomingVote { type Error = (); diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index b48b995..78937c6 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -84,23 +84,172 @@ cfg_if::cfg_if! { impl IncomingVote { /// Authenticates a valid password with this request. Returns a [`Some(Vote)`][`Vote`] if succeeds, otherwise `None`. /// - /// # Example + /// # Examples /// - /// Basic usage: + /// Basic usage with actix-web: /// - /// ```rust,no_run - /// match incoming_vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { - /// Some(vote) => { - /// println!("{:?}", vote); + /// ```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 + /// # } /// - /// // respond with 204 NO CONTENT... - /// }, - /// _ => { - /// println!("found an unauthorized attacker."); + /// #[post("/webhook")] + /// async fn voted(incoming_vote: IncomingVote) -> Result<&'static str, Error> { + /// match incoming_vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { + /// Some(vote) => { + /// println!("{:?}", vote); /// - /// // respond with 401 UNAUTHORIZED... + /// 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)] @@ -120,7 +269,9 @@ cfg_if::cfg_if! { /// 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,no_run + /// ```rust + /// # use topgg::Vote; + /// # /// #[async_trait::async_trait] /// pub trait VoteHandler: Send + Sync + 'static { /// async fn voted(&self, vote: Vote); diff --git a/src/webhook/warp.rs b/src/webhook/warp.rs index 1dd4beb..e4be02a 100644 --- a/src/webhook/warp.rs +++ b/src/webhook/warp.rs @@ -8,10 +8,14 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// /// Basic usage: /// -/// ```rust,no_run -/// use std::{net::SocketAddr, sync::Arc}; +/// ```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 {} /// @@ -35,9 +39,37 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// /// let routes = warp::get().map(|| "Hello, World!").or(webhook); /// -/// let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); +/// 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).run(addr).await +/// 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")))] From 8edb2ecffed8837a30c3cc85631e6fab570607e8 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 24 Sep 2025 13:27:47 +0700 Subject: [PATCH 5/8] doc: refer to project --- src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 353b50e..6872b79 100644 --- a/src/client.rs +++ b/src/client.rs @@ -393,7 +393,7 @@ impl Client { 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 /// From 23a102d85e396c5b14d378dbe0e38d27105c41c9 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 30 Sep 2025 22:31:20 +0700 Subject: [PATCH 6/8] doc: use BOT_TOKEN instead of DISCORD_TOKEN --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a54743e..e8d9c93 100644 --- a/README.md +++ b/README.md @@ -180,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) @@ -224,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, ); From bd4b5b1ad1b1c32b14c0048ff4c4f783a6e8d7e4 Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 3 Oct 2025 16:54:37 +0700 Subject: [PATCH 7/8] doc: tweak documentation --- src/webhook/vote.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index 78937c6..c5c5c08 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -33,11 +33,11 @@ where ) } -/// A dispatched Top.gg bot/server vote event. +/// A dispatched Top.gg project vote event. #[must_use] #[derive(Clone, Debug, Deserialize)] pub struct Vote { - /// The ID of the bot/server that received a vote. + /// The ID of the project that received a vote. #[serde( deserialize_with = "snowflake::deserialize", alias = "bot", From 30bf2bca943375effdc32b9d9e065bedac75ea92 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 7 Oct 2025 16:35:26 +0700 Subject: [PATCH 8/8] doc: simplify docs --- README.md | 8 ++++---- src/bot.rs | 4 ++-- src/user.rs | 10 +++++----- src/webhook/vote.rs | 4 ++-- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e8d9c93..2cd08d2 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ async fn main() {
-axum: Writing an axum webhook for listening to your bot/server's vote events +axum: Writing an axum webhook for listening to your project's vote events In your `Cargo.toml`: @@ -351,7 +351,7 @@ async fn main() {
-rocket: Writing a rocket webhook for listening to your bot/server's vote events +rocket: Writing a rocket webhook for listening to your project's vote events In your `Cargo.toml`: @@ -398,7 +398,7 @@ fn main() {
-warp: Writing a warp webhook for listening to your bot/server's vote events +warp: Writing a warp webhook for listening to your project's vote events In your `Cargo.toml`: diff --git a/src/bot.rs b/src/bot.rs index 4ba8ce3..9eaf146 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -88,7 +88,7 @@ util::debug_struct! { #[serde(rename = "date")] submitted_at: DateTime, - /// Whether this bot is certified or not. + /// Whether this bot is certified. #[serde(skip)] is_certified: bool, @@ -422,7 +422,7 @@ impl<'a> GetBots<'a> { /// 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 or not. + /// Queries only Top.gg certified bots. certified: bool = search("certifiedBot%3A%20{certified}%20"); /// Queries only bots that has this Top.gg vanity URL. diff --git a/src/user.rs b/src/user.rs index cb3ff95..58ba96d 100644 --- a/src/user.rs +++ b/src/user.rs @@ -52,23 +52,23 @@ util::debug_struct! { #[serde(skip)] socials: Option, - /// Whether this user is a Top.gg supporter or not. + /// Whether this user is a Top.gg supporter. #[serde(skip)] is_supporter: bool, - /// Whether this user is a Top.gg certified developer or not. + /// Whether this user is a Top.gg certified developer. #[serde(skip)] is_certified_dev: bool, - /// Whether this user is a Top.gg moderator or not. + /// Whether this user is a Top.gg moderator. #[serde(skip)] is_moderator: bool, - /// Whether this user is a Top.gg website moderator or not. + /// Whether this user is a Top.gg website moderator. #[serde(skip)] is_web_moderator: bool, - /// Whether this user is a Top.gg website administrator or not. + /// Whether this user is a Top.gg website administrator. #[serde(skip)] is_admin: bool, } diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index c5c5c08..303dce9 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -54,7 +54,7 @@ pub struct Vote { #[deprecated(since = "1.5.0", note = "No longer supported.")] pub is_server: bool, - /// Whether this vote is just a test coming from the bot/server owner or not. Most of the time this would be `false`. + /// 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, @@ -63,7 +63,7 @@ pub struct Vote { #[serde(default, rename = "isWeekend")] pub is_weekend: bool, - /// query strings found on the vote page. + /// Query strings found on the vote page. #[serde(default, deserialize_with = "deserialize_query_string")] pub query: HashMap, }
-actix-web: Writing an actix-web webhook for listening to your bot/server's vote events +actix-web: Writing an actix-web webhook for listening to your project's vote events In your `Cargo.toml`: @@ -302,7 +302,7 @@ async fn main() -> io::Result<()> {
+autoposter, serenity: Automating the process of periodically posting your Discord bot's statistics with the serenity library In your `Cargo.toml`: @@ -99,7 +154,7 @@ topgg = { version = "1.5", features = ["autoposter", "serenity-cached"] } In your code: ```rust,no_run -use std::time::Duration; +use core::time::Duration; use serenity::{client::{Client, Context, EventHandler}, model::{channel::Message, gateway::Ready}}; use topgg::Autoposter; @@ -140,7 +195,9 @@ async fn main() { } ``` -### Autoposting with [twilight](https://twilight.rs) +