From 755bbe994a8e67c4932b932319e322c89b2393ea Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 21 Sep 2024 23:08:13 +0700 Subject: [PATCH 01/37] refactor: simplify some lines of code --- src/bot.rs | 12 +++--------- src/util.rs | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 81670d2..12af23c 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -212,20 +212,14 @@ util::debug_struct! { #[must_use] #[inline(always)] shards: &[usize] => { - match self.shards { - Some(ref shards) => shards, - None => &[], - } + self.shards.as_deref().unwrap_or_default() } /// 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, - }) + self.shard_count.unwrap_or(self.shards().len()) } /// The amount of servers this bot is in. `None` if such information is publy unavailable. @@ -236,7 +230,7 @@ util::debug_struct! { if shards.is_empty() { None } else { - Some(shards.iter().copied().sum()) + Some(shards.into_iter().copied().sum()) } }) }) diff --git a/src/util.rs b/src/util.rs index edaa15c..55056b1 100644 --- a/src/util.rs +++ b/src/util.rs @@ -83,7 +83,7 @@ pub(crate) fn deserialize_optional_string<'de, D>( where D: Deserializer<'de>, { - Ok(match ::deserialize(deserializer) { + Ok(match String::deserialize(deserializer) { Ok(s) => { if s.is_empty() { None From 41afa1804f9076a7a7f96f4df78ddb9bf95630c5 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sun, 13 Oct 2024 19:06:04 +0700 Subject: [PATCH 02/37] fix: proper avatar URL coverage --- src/autoposter/mod.rs | 9 ++++++--- src/bot.rs | 6 +++--- src/user.rs | 4 ++-- src/util.rs | 7 +++++-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 4308260..1193779 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -185,17 +185,20 @@ where 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.") + self + .receiver + .take() + .expect("receiver() can only be called once.") } } diff --git a/src/bot.rs b/src/bot.rs index 12af23c..fe25d9a 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -124,7 +124,7 @@ util::debug_struct! { #[must_use] #[inline(always)] avatar: String => { - util::get_avatar(&self.avatar, self.id) + util::get_avatar(&self.avatar, self.id, Some(&self.discriminator)) } /// The invite URL of this Discord bot. @@ -222,7 +222,7 @@ util::debug_struct! { self.shard_count.unwrap_or(self.shards().len()) } - /// The amount of servers this bot is in. `None` if such information is publy unavailable. + /// The amount of servers this bot is in. `None` if such information is publicly unavailable. #[must_use] server_count: Option => { self.server_count.or_else(|| { @@ -230,7 +230,7 @@ util::debug_struct! { if shards.is_empty() { None } else { - Some(shards.into_iter().copied().sum()) + Some(shards.iter().copied().sum()) } }) }) diff --git a/src/user.rs b/src/user.rs index 42d3dfc..c309a79 100644 --- a/src/user.rs +++ b/src/user.rs @@ -91,7 +91,7 @@ util::debug_struct! { #[must_use] #[inline(always)] avatar: String => { - util::get_avatar(&self.avatar, self.id) + util::get_avatar(&self.avatar, self.id, None) } } } @@ -134,7 +134,7 @@ util::debug_struct! { #[must_use] #[inline(always)] avatar: String => { - util::get_avatar(&self.avatar, self.id) + util::get_avatar(&self.avatar, self.id, None) } } } diff --git a/src/util.rs b/src/util.rs index 55056b1..e73feef 100644 --- a/src/util.rs +++ b/src/util.rs @@ -126,7 +126,7 @@ where Err(Error::InternalServerError) } -pub(crate) fn get_avatar(hash: &Option, id: u64) -> String { +pub(crate) fn get_avatar(hash: &Option, id: u64, discriminator: Option<&str>) -> String { match hash { Some(hash) => { let ext = if hash.starts_with("a_") { "gif" } else { "png" }; @@ -135,7 +135,10 @@ pub(crate) fn get_avatar(hash: &Option, id: u64) -> String { } _ => format!( "https://cdn.discordapp.com/embed/avatars/{}.png", - (id >> 22) % 5 + match discriminator.and_then(|d| d.parse::().ok()) { + Some(d) => d % 5, + None => (id >> 22) % 6, + } ), } } From 4ded956c06876b14485892f9cbf94c5c87002516 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sun, 13 Oct 2024 19:07:39 +0700 Subject: [PATCH 03/37] doc: ALRIGHT WE GET IT ITS A (DISCORD) BOT --- src/autoposter/mod.rs | 4 +-- src/autoposter/serenity_impl.rs | 2 +- src/bot.rs | 50 ++++++++++++++++----------------- src/client.rs | 12 ++++---- src/user.rs | 2 +- 5 files changed, 35 insertions(+), 35 deletions(-) diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 1193779..4aaa689 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -116,7 +116,7 @@ impl SharedStats { } } -/// A trait for handling events from third-party Discord Bot libraries. +/// A trait for handling events from third-party 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 { @@ -141,7 +141,7 @@ where /// 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. + /// - `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 bot library between this library. /// /// # Panics /// diff --git a/src/autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs index 22c19d5..db07dc3 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -144,7 +144,7 @@ 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 discord bot doesn't cache guilds"), + #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the bot doesn't cache guilds"), ).await } diff --git a/src/bot.rs b/src/bot.rs index fe25d9a..c9cc581 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -14,29 +14,29 @@ where } util::debug_struct! { - /// A struct representing a Discord Bot listed on [Top.gg](https://top.gg). + /// A struct representing a bot listed on [Top.gg](https://top.gg). #[must_use] #[derive(Clone, Deserialize)] Bot { public { - /// The ID of this Discord bot. + /// The ID of this bot. #[serde(deserialize_with = "snowflake::deserialize")] id: u64, - /// The username of this Discord bot. + /// The username of this bot. username: String, - /// The discriminator of this Discord bot. + /// The discriminator of this bot. discriminator: String, - /// The prefix of this Discord bot. + /// The prefix of this bot. prefix: String, - /// The short description of this Discord bot. + /// The short description of this bot. #[serde(rename = "shortdesc")] short_description: String, - /// The long description of this Discord bot. It can contain HTML and/or Markdown. + /// The long description of this bot. It can contain HTML and/or Markdown. #[serde( default, deserialize_with = "util::deserialize_optional_string", @@ -44,27 +44,27 @@ util::debug_struct! { )] long_description: Option, - /// The tags of this Discord bot. + /// The tags of this bot. #[serde(default, deserialize_with = "util::deserialize_default")] tags: Vec, - /// The website URL of this Discord bot. + /// The website URL of this bot. #[serde(default, deserialize_with = "util::deserialize_optional_string")] website: Option, - /// The link to this Discord bot's GitHub repository. + /// The link to this 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. + /// A list of IDs of this 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. + /// A list of IDs of the guilds featured on this bot's page. #[serde(default, deserialize_with = "snowflake::deserialize_vec")] guilds: Vec, - /// The URL for this Discord bot's banner image. + /// The URL for this bot's banner image. #[serde( default, deserialize_with = "util::deserialize_optional_string", @@ -72,27 +72,27 @@ util::debug_struct! { )] banner_url: Option, - /// The date when this Discord bot was approved on [Top.gg](https://top.gg). + /// The date when this 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. + /// Whether this bot is [Top.gg](https://top.gg) certified or not. #[serde(rename = "certifiedBot")] is_certified: bool, - /// A list of this Discord bot's shards. + /// A list of this bot's shards. #[serde(default, deserialize_with = "util::deserialize_default")] shards: Vec, - /// The amount of upvotes this Discord bot has. + /// The amount of upvotes this bot has. #[serde(rename = "points")] votes: usize, - /// The amount of upvotes this Discord bot has this month. + /// The amount of upvotes this bot has this month. #[serde(rename = "monthlyPoints")] monthly_votes: usize, - /// The support server invite URL of this Discord bot. + /// The support server invite URL of this bot. #[serde(default, deserialize_with = "deserialize_support_server")] support: Option, } @@ -127,7 +127,7 @@ util::debug_struct! { util::get_avatar(&self.avatar, self.id, Some(&self.discriminator)) } - /// The invite URL of this Discord bot. + /// The invite URL of this bot. #[must_use] invite: String => { match &self.invite { @@ -139,14 +139,14 @@ util::debug_struct! { } } - /// The amount of shards this Discord bot has according to posted stats. + /// The amount of shards this 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. + /// Retrieves the URL of this bot's [Top.gg](https://top.gg) page. #[must_use] #[inline(always)] url: String => { @@ -160,7 +160,7 @@ util::debug_struct! { } util::debug_struct! { - /// A struct representing a Discord bot's statistics. + /// A struct representing a bot's statistics. /// /// # Examples /// @@ -208,14 +208,14 @@ util::debug_struct! { } getters(self) { - /// An array of this Discord bot's server count for each shard. + /// An array of this bot's server count for each shard. #[must_use] #[inline(always)] shards: &[usize] => { self.shards.as_deref().unwrap_or_default() } - /// The amount of shards this Discord bot has. + /// The amount of shards this bot has. #[must_use] #[inline(always)] shard_count: usize => { diff --git a/src/client.rs b/src/client.rs index 5c7fc44..3726732 100644 --- a/src/client.rs +++ b/src/client.rs @@ -170,7 +170,7 @@ impl Client { .await } - /// Fetches a listed Discord bot from a Discord ID. + /// Fetches a listed bot from a Discord ID. /// /// # Panics /// @@ -183,7 +183,7 @@ impl Client { /// 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 requested 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 @@ -195,7 +195,7 @@ impl Client { .await } - /// Fetches your Discord bot's statistics. + /// Fetches your bot's statistics. /// /// # Panics /// @@ -214,7 +214,7 @@ impl Client { .await } - /// Posts your Discord bot's statistics. + /// Posts your bot's statistics. /// /// # Panics /// @@ -231,7 +231,7 @@ impl Client { self.inner.post_stats(&new_stats).await } - /// Fetches your Discord bot's last 1000 voters. + /// Fetches your bot's last 1000 voters. /// /// # Panics /// @@ -250,7 +250,7 @@ impl Client { .await } - /// Checks if the specified user has voted your Discord bot. + /// Checks if the specified user has voted your bot. /// /// # Panics /// diff --git a/src/user.rs b/src/user.rs index c309a79..2ed88c2 100644 --- a/src/user.rs +++ b/src/user.rs @@ -103,7 +103,7 @@ 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 bot listed on [Top.gg](https://top.gg). (See [`Client::get_voters`][crate::Client::get_voters]) #[must_use] #[derive(Clone, Deserialize)] Voter { From 7bff326bf56b72c169fb8fd45ec85eba3acbfa77 Mon Sep 17 00:00:00 2001 From: null8626 Date: Sat, 15 Feb 2025 17:20:16 +0700 Subject: [PATCH 04/37] meta: update copyright year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 2b2c389..07218e0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023-2024 Top.gg & null8626 +Copyright (c) 2023-2025 Top.gg & null8626 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From fb676428c3fa073f9cbeb4366961a0efe2aa4014 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 00:08:17 +0700 Subject: [PATCH 05/37] feat: deprecate features no longer available in Top.gg API v0 [skip ci] --- Cargo.toml | 2 +- src/autoposter/mod.rs | 10 +-- src/autoposter/serenity_impl.rs | 14 ---- src/bot.rs | 142 +++++++++----------------------- src/user.rs | 8 +- src/util.rs | 18 ++-- 6 files changed, 60 insertions(+), 134 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5eccf05..8de1565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "topgg" -version = "1.4.2" +version = "1.4.3" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] description = "The official Rust wrapper for the Top.gg API" diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 4aaa689..ac066e4 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -59,11 +59,11 @@ impl SharedStatsGuard<'_> { 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); - } + #[deprecated( + since = "1.4.3", + note = "No longer supported by Top.gg API v0. At the moment, this method has no effect." + )] + pub fn set_shard_count(&mut self, _shard_count: usize) {} } impl Deref for SharedStatsGuard<'_> { diff --git a/src/autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs index db07dc3..cc09201 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -125,20 +125,6 @@ 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( diff --git a/src/bot.rs b/src/bot.rs index c9cc581..fd187ea 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -13,6 +13,16 @@ where .map(|inner| inner.map(|support| format!("https://discord.com/invite/{support}"))) } +// TODO: remove these utility deprecation helpers soon + +#[inline(always)] +fn deserialize_discriminator<'de, D>(_deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + Ok(String::from('0')) +} + util::debug_struct! { /// A struct representing a bot listed on [Top.gg](https://top.gg). #[must_use] @@ -26,7 +36,8 @@ util::debug_struct! { /// The username of this bot. username: String, - /// The discriminator of this bot. + #[serde(deserialize_with = "deserialize_discriminator")] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be '0'.")] discriminator: String, /// The prefix of this bot. @@ -60,8 +71,8 @@ util::debug_struct! { #[serde(deserialize_with = "snowflake::deserialize_vec")] owners: Vec, - /// A list of IDs of the guilds featured on this bot's page. - #[serde(default, deserialize_with = "snowflake::deserialize_vec")] + #[serde(deserialize_with = "util::deserialize_immediate_default")] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be an empty vector.")] guilds: Vec, /// The URL for this bot's banner image. @@ -76,12 +87,12 @@ util::debug_struct! { #[serde(rename = "date")] approved_at: DateTime, - /// Whether this bot is [Top.gg](https://top.gg) certified or not. - #[serde(rename = "certifiedBot")] + #[serde(deserialize_with = "util::deserialize_immediate_default")] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be false.")] is_certified: bool, - /// A list of this bot's shards. - #[serde(default, deserialize_with = "util::deserialize_default")] + #[serde(deserialize_with = "util::deserialize_immediate_default")] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be an empty vector.")] shards: Vec, /// The amount of upvotes this bot has. @@ -104,8 +115,6 @@ util::debug_struct! { #[serde(default, deserialize_with = "util::deserialize_optional_string")] invite: Option, - shard_count: Option, - #[serde(default, deserialize_with = "util::deserialize_optional_string")] vanity: Option, } @@ -124,7 +133,7 @@ util::debug_struct! { #[must_use] #[inline(always)] avatar: String => { - util::get_avatar(&self.avatar, self.id, Some(&self.discriminator)) + util::get_avatar(&self.avatar, self.id) } /// The invite URL of this bot. @@ -139,11 +148,9 @@ util::debug_struct! { } } - /// The amount of shards this bot has according to posted stats. - #[must_use] - #[inline(always)] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always return 0.")] shard_count: usize => { - self.shard_count.unwrap_or(self.shards.len()) + 0 } /// Retrieves the URL of this bot's [Top.gg](https://top.gg) page. @@ -160,80 +167,27 @@ util::debug_struct! { } util::debug_struct! { - /// A struct representing a 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)] + #[deprecated(since = "1.4.3", note = "No longer has a use by Top.gg API v0. Soon, all you need is just your bot's server count (usize).")] Stats { protected { - #[serde(skip_serializing_if = "Option::is_none")] - shard_count: Option, #[serde(skip_serializing_if = "Option::is_none")] server_count: Option, } - private { - #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "util::deserialize_default")] - shards: Option>, - #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "util::deserialize_default")] - shard_id: Option, - } - getters(self) { - /// An array of this bot's server count for each shard. - #[must_use] - #[inline(always)] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always return an empty slice.")] shards: &[usize] => { - self.shards.as_deref().unwrap_or_default() + &[] } - /// The amount of shards this bot has. - #[must_use] - #[inline(always)] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always return 0.")] shard_count: usize => { - self.shard_count.unwrap_or(self.shards().len()) + 0 } - /// The amount of servers this bot is in. `None` if such information is publicly unavailable. - #[must_use] server_count: Option => { - self.server_count.or_else(|| { - self.shards.as_ref().and_then(|shards| { - if shards.is_empty() { - None - } else { - Some(shards.iter().copied().sum()) - } - }) - }) + self.server_count } } } @@ -251,59 +205,37 @@ impl Stats { ) } - /// 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.4.3", + note = "The shard_count argument no longer has an effect." + )] + 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.4.3", + note = "No longer supported by Top.gg API v0. At the moment, the shard_index argument has no effect." + )] + pub fn from_shards(shards: A, _shard_index: Option) -> Self where A: IntoIterator, { let mut total_server_count = 0; let shards = shards.into_iter(); - let mut shards_list = Vec::with_capacity(shards.size_hint().0); for server_count in shards { total_server_count += server_count; - shards_list.push(server_count); - } - - if let Some(index) = shard_index { - assert!(index < shards_list.len(), "Shard index out of range."); } Self { server_count: Some(total_server_count), - shard_count: Some(shards_list.len()), - shards: Some(shards_list), - shard_id: shard_index, } } } -/// Creates a [`Stats`] struct solely from a server count. impl From for Stats { #[inline(always)] fn from(server_count: usize) -> Self { diff --git a/src/user.rs b/src/user.rs index 2ed88c2..03683f8 100644 --- a/src/user.rs +++ b/src/user.rs @@ -55,8 +55,8 @@ util::debug_struct! { #[serde(rename = "supporter")] is_supporter: bool, - /// Whether this user is a [Top.gg](https://top.gg) certified developer or not. - #[serde(rename = "certifiedDev")] + #[serde(deserialize_with = "util::deserialize_immediate_default")] + #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be false.")] is_certified_dev: bool, /// Whether this user is a [Top.gg](https://top.gg) moderator or not. @@ -91,7 +91,7 @@ util::debug_struct! { #[must_use] #[inline(always)] avatar: String => { - util::get_avatar(&self.avatar, self.id, None) + util::get_avatar(&self.avatar, self.id) } } } @@ -134,7 +134,7 @@ util::debug_struct! { #[must_use] #[inline(always)] avatar: String => { - util::get_avatar(&self.avatar, self.id, None) + util::get_avatar(&self.avatar, self.id) } } } diff --git a/src/util.rs b/src/util.rs index e73feef..47ed3b4 100644 --- a/src/util.rs +++ b/src/util.rs @@ -3,6 +3,17 @@ use chrono::{DateTime, TimeZone, Utc}; use reqwest::Response; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; +// TODO: remove these utility deprecation helpers soon + +#[inline(always)] +pub(crate) fn deserialize_immediate_default<'de, D, T>(_deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Default, +{ + Ok(T::default()) +} + const DISCORD_EPOCH: u64 = 1_420_070_400_000; macro_rules! debug_struct { @@ -126,7 +137,7 @@ where Err(Error::InternalServerError) } -pub(crate) fn get_avatar(hash: &Option, id: u64, discriminator: Option<&str>) -> String { +pub(crate) fn get_avatar(hash: &Option, id: u64) -> String { match hash { Some(hash) => { let ext = if hash.starts_with("a_") { "gif" } else { "png" }; @@ -135,10 +146,7 @@ pub(crate) fn get_avatar(hash: &Option, id: u64, discriminator: Option<& } _ => format!( "https://cdn.discordapp.com/embed/avatars/{}.png", - match discriminator.and_then(|d| d.parse::().ok()) { - Some(d) => d % 5, - None => (id >> 22) % 6, - } + (id >> 22) % 6, ), } } From b8dc6bab557cc05d71e645858226c497bf82372b Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 16:18:35 +0700 Subject: [PATCH 06/37] feat: add get_bots() again --- Cargo.toml | 4 +-- README.md | 40 +++++++++++++++++++++ src/autoposter/mod.rs | 4 +-- src/bot.rs | 84 ++++++++++++++++++++++++++++++++++++++++++- src/client.rs | 48 ++++++++++++++++++++++++- 5 files changed, 174 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8de1565..afa1090 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,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 } @@ -49,7 +49,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..d6d2f19 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,46 @@ async fn main() { } ``` +### Fetching a bot from its 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); +} +``` + +### 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") + .await; + + for bot in bots { + println!("{:?}", bot); + } +} +``` + ### Posting your bot's statistics ```rust,no_run diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index ac066e4..21ae6ee 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -186,13 +186,13 @@ where 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 every time the [`Autoposter`] has attempted to post the bot's stats. If you want to use the receiver directly, call [`receiver`][Autoposter::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. + /// Takes the receiver responsible for [`recv`][Autoposter::recv]. Subsequent calls to this function and [`recv`][Autoposter::recv] after this call will panic. #[inline(always)] pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { self diff --git a/src/bot.rs b/src/bot.rs index fd187ea..93bf945 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,6 +1,11 @@ -use crate::{snowflake, util}; +use crate::{snowflake, util, Client}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Deserializer, Serialize}; +use std::{ + cmp::min, + future::{Future, IntoFuture}, + pin::Pin, +}; #[inline(always)] pub(crate) fn deserialize_support_server<'de, D>( @@ -166,6 +171,11 @@ util::debug_struct! { } } +#[derive(Deserialize)] +pub(crate) struct Bots { + pub(crate) results: Vec, +} + util::debug_struct! { #[derive(Clone, Serialize, Deserialize)] #[deprecated(since = "1.4.3", note = "No longer has a use by Top.gg API v0. Soon, all you need is just your bot's server count (usize).")] @@ -247,3 +257,75 @@ impl From for Stats { pub(crate) struct IsWeekend { pub(crate) is_weekend: bool, } + +// A struct for configuring the query in [`get_bots`][crate::Client::get_bots] before being sent to the [Top.gg API](https://docs.top.gg) by `await`ing it. +#[must_use] +pub struct GetBots<'a> { + client: &'a Client, + query: String, + search: String, +} + +macro_rules! get_bots_method { + ($( + $(#[doc = $doc:literal])* + $input_name:ident: $input_type:ty = $property:ident($($format:tt)*); + )*) => {$( + $(#[doc = $doc])* + pub fn $input_name(mut self, $input_name: $input_type) -> Self { + self.$property.push_str(&format!($($format)*)); + self + } + )*}; +} + +impl<'a> GetBots<'a> { + #[inline(always)] + pub(crate) fn new(client: &'a Client) -> Self { + Self { + client, + query: String::from('?'), + search: String::new(), + } + } + + get_bots_method! { + /// Sets the maximum amount of bots to be queried. + limit: u16 = query("limit={}&", min(limit, 500)); + + /// Sets the amount of bots to be skipped during the query. + skip: u16 = query("offset={}&", min(skip, 499)); + + /// Queries only Discord bots that matches this username. + username: &str = search("username%3A%20{}%20", urlencoding::encode(username)); + + /// Queries only Discord bots that matches this prefix. + prefix: &str = search("prefix%3A%20{}%20", urlencoding::encode(prefix)); + + /// Queries only Discord bots that has this vote count. + votes: usize = search("points%3A%20{votes}%20"); + + /// Queries only Discord bots that has this monthly vote count. + monthly_votes: usize = search("monthlyPoints%3A%20{monthly_votes}%20"); + + /// Queries only Discord bots that has this [Top.gg](https://top.gg) vanity URL. + vanity: &str = search("vanity%3A%20{}%20", urlencoding::encode(vanity)); + } +} + +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 !self.search.is_empty() { + query.push_str(&format!("search={}", self.search)); + } else { + query.pop(); + } + + Box::pin(self.client.get_bots_inner(query)) + } +} diff --git a/src/client.rs b/src/client.rs index 3726732..3c5b408 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,5 @@ use crate::{ - bot::{Bot, IsWeekend}, + bot::{Bot, Bots, GetBots, IsWeekend}, user::{User, Voted, Voter}, util, Error, Result, Snowflake, Stats, }; @@ -250,6 +250,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](https://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](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]) + /// + /// # 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) + /// .username("shiro") + /// .await; + /// + /// for bot in bots { + /// println!("{:?}", bot); + /// } + /// ``` + #[inline(always)] + pub fn get_bots(&self) -> GetBots<'_> { + GetBots::new(self) + } + /// Checks if the specified user has voted your bot. /// /// # Panics From aee55e011e10695971f04f15a49abf59c50b4f3e Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 16:47:24 +0700 Subject: [PATCH 07/37] feat: add sort_by methods in GetBots --- README.md | 1 + src/bot.rs | 27 +++++++++++++++++++++++++++ src/client.rs | 1 + 3 files changed, 29 insertions(+) diff --git a/README.md b/README.md index d6d2f19..c0fac8e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ async fn main() { .limit(250) .skip(50) .username("shiro") + .sort_by_monthly_votes() .await; for bot in bots { diff --git a/src/bot.rs b/src/bot.rs index 93bf945..3ef06d4 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -264,6 +264,7 @@ pub struct GetBots<'a> { client: &'a Client, query: String, search: String, + sort: Option<&'static str>, } macro_rules! get_bots_method { @@ -279,6 +280,19 @@ macro_rules! get_bots_method { )*}; } +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 + } + )*}; +} + impl<'a> GetBots<'a> { #[inline(always)] pub(crate) fn new(client: &'a Client) -> Self { @@ -286,9 +300,18 @@ impl<'a> GetBots<'a> { client, query: String::from('?'), search: String::new(), + sort: None, } } + get_bots_sort! { + /// 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. limit: u16 = query("limit={}&", min(limit, 500)); @@ -320,6 +343,10 @@ impl<'a> IntoFuture for GetBots<'a> { fn into_future(self) -> Self::IntoFuture { let mut query = self.query; + if let Some(sort) = self.sort { + query.push_str(&format!("sort={sort}&")); + } + if !self.search.is_empty() { query.push_str(&format!("search={}", self.search)); } else { diff --git a/src/client.rs b/src/client.rs index 3c5b408..e683e0c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -285,6 +285,7 @@ impl Client { /// .limit(250) /// .skip(50) /// .username("shiro") + /// .sort_by_monthly_votes() /// .await; /// /// for bot in bots { From f38c5f0e80e3a4e6eca0b6f549585cc36b7ed3ab Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 21:49:12 +0700 Subject: [PATCH 08/37] feat: add v0 --- src/autoposter/mod.rs | 103 +++-------------------------- src/autoposter/serenity_impl.rs | 37 +++++------ src/autoposter/twilight_impl.rs | 26 ++++---- src/bot.rs | 114 ++------------------------------ src/client.rs | 20 +++--- src/lib.rs | 3 +- src/snowflake.rs | 16 ++--- src/user.rs | 4 -- src/util.rs | 11 --- 9 files changed, 67 insertions(+), 267 deletions(-) diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 21ae6ee..510d848 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -1,11 +1,8 @@ -use crate::{Result, Stats}; -use core::{ - ops::{Deref, DerefMut}, - time::Duration, -}; +use crate::Result; +use core::{ops::Deref, time::Duration}; use std::sync::Arc; use tokio::{ - sync::{mpsc, RwLock, RwLockWriteGuard, Semaphore}, + sync::{mpsc, RwLock}, task::{spawn, JoinHandle}, time::sleep, }; @@ -33,95 +30,12 @@ cfg_if::cfg_if! { } } -/// 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); - } - - #[deprecated( - since = "1.4.3", - note = "No longer supported by Top.gg API v0. At the moment, this method has no effect." - )] - pub fn set_shard_count(&mut self, _shard_count: usize) {} -} - -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 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; + fn server_count(&self) -> &RwLock; } /// A struct that lets you automate the process of posting bot statistics to [Top.gg](https://top.gg) in intervals. @@ -163,12 +77,13 @@ where handler: Arc::clone(&handler), thread: spawn(async move { loop { - handler.stats().wait().await; - { - let stats = handler.stats().stats.read().await; + let server_count = handler.server_count().read().await; - if sender.send(client.post_stats(&stats).await).is_err() { + if sender + .send(client.post_server_count(*server_count).await) + .is_err() + { break; } }; diff --git a/src/autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs index cc09201..91bd114 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -1,4 +1,4 @@ -use crate::autoposter::{Handler, SharedStats}; +use crate::autoposter::Handler; use paste::paste; use serenity::{ client::{Context, EventHandler, FullEvent}, @@ -8,6 +8,7 @@ use serenity::{ id::GuildId, }, }; +use tokio::sync::RwLock; cfg_if::cfg_if! { if #[cfg(not(feature = "serenity-cached"))] { @@ -17,8 +18,6 @@ cfg_if::cfg_if! { struct Cache { guilds: HashSet, } - } else { - use std::ops::Add; } } @@ -27,7 +26,7 @@ cfg_if::cfg_if! { pub struct Serenity { #[cfg(not(feature = "serenity-cached"))] cache: Mutex, - stats: SharedStats, + server_count: RwLock, } macro_rules! serenity_handler { @@ -50,7 +49,7 @@ macro_rules! serenity_handler { cache: Mutex::const_new(Cache { guilds: HashSet::new(), }), - stats: SharedStats::new(), + server_count: RwLock::new(0), } } @@ -98,9 +97,9 @@ serenity_handler! { } handle(guilds: &[UnavailableGuild]) { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(guilds.len()); + *server_count = guilds.len(); cfg_if::cfg_if! { if #[cfg(not(feature = "serenity-cached"))] { @@ -119,9 +118,9 @@ serenity_handler! { } handle(guild_count: usize) { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(guild_count); + *server_count = guild_count; } } @@ -141,17 +140,17 @@ serenity_handler! { cfg_if::cfg_if! { if #[cfg(feature = "serenity-cached")] { if is_new { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(guild_count); + *server_count = guild_count; } } else { let mut cache = self.cache.lock().await; if cache.guilds.insert(guild_id) { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(cache.guilds.len()); + *server_count = cache.guilds.len(); } } } @@ -171,16 +170,16 @@ serenity_handler! { #[cfg(not(feature = "serenity-cached"))] guild_id: GuildId) { cfg_if::cfg_if! { if #[cfg(feature = "serenity-cached")] { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(guild_count); + *server_count = guild_count; } else { let mut cache = self.cache.lock().await; if cache.guilds.remove(&guild_id) { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(cache.guilds.len()); + *server_count = cache.guilds.len(); } } } @@ -191,7 +190,7 @@ serenity_handler! { impl Handler for Serenity { #[inline(always)] - fn stats(&self) -> &SharedStats { - &self.stats + fn server_count(&self) -> &RwLock { + &self.server_count } } diff --git a/src/autoposter/twilight_impl.rs b/src/autoposter/twilight_impl.rs index df225ff..5c5b185 100644 --- a/src/autoposter/twilight_impl.rs +++ b/src/autoposter/twilight_impl.rs @@ -1,12 +1,12 @@ -use crate::autoposter::{Handler, SharedStats}; +use crate::autoposter::Handler; use std::{collections::HashSet, ops::DerefMut}; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use twilight_model::gateway::event::Event; /// A built-in [`Handler`] for the [twilight](https://twilight.rs) library. pub struct Twilight { cache: Mutex>, - stats: SharedStats, + server_count: RwLock, } impl Twilight { @@ -14,7 +14,7 @@ impl Twilight { pub(super) fn new() -> Self { Self { cache: Mutex::const_new(HashSet::new()), - stats: SharedStats::new(), + server_count: RwLock::new(0), } } @@ -22,21 +22,21 @@ impl Twilight { 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 mut cache: tokio::sync::MutexGuard<'_, HashSet> = self.cache.lock().await; + let mut server_count = self.server_count.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()); + *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; + let mut server_count = self.server_count.write().await; - stats.set_server_count(cache.len()); + *server_count = cache.len(); } } @@ -44,9 +44,9 @@ impl Twilight { let mut cache = self.cache.lock().await; if cache.remove(&guild_delete.id.get()) { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(cache.len()); + *server_count = cache.len(); } } @@ -57,7 +57,7 @@ impl Twilight { impl Handler for Twilight { #[inline(always)] - fn stats(&self) -> &SharedStats { - &self.stats + fn server_count(&self) -> &RwLock { + &self.server_count } } diff --git a/src/bot.rs b/src/bot.rs index 3ef06d4..8fc2269 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -18,16 +18,6 @@ where .map(|inner| inner.map(|support| format!("https://discord.com/invite/{support}"))) } -// TODO: remove these utility deprecation helpers soon - -#[inline(always)] -fn deserialize_discriminator<'de, D>(_deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - Ok(String::from('0')) -} - util::debug_struct! { /// A struct representing a bot listed on [Top.gg](https://top.gg). #[must_use] @@ -41,10 +31,6 @@ util::debug_struct! { /// The username of this bot. username: String, - #[serde(deserialize_with = "deserialize_discriminator")] - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be '0'.")] - discriminator: String, - /// The prefix of this bot. prefix: String, @@ -76,10 +62,6 @@ util::debug_struct! { #[serde(deserialize_with = "snowflake::deserialize_vec")] owners: Vec, - #[serde(deserialize_with = "util::deserialize_immediate_default")] - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be an empty vector.")] - guilds: Vec, - /// The URL for this bot's banner image. #[serde( default, @@ -92,14 +74,6 @@ util::debug_struct! { #[serde(rename = "date")] approved_at: DateTime, - #[serde(deserialize_with = "util::deserialize_immediate_default")] - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be false.")] - is_certified: bool, - - #[serde(deserialize_with = "util::deserialize_immediate_default")] - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be an empty vector.")] - shards: Vec, - /// The amount of upvotes this bot has. #[serde(rename = "points")] votes: usize, @@ -153,11 +127,6 @@ util::debug_struct! { } } - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always return 0.")] - shard_count: usize => { - 0 - } - /// Retrieves the URL of this bot's [Top.gg](https://top.gg) page. #[must_use] #[inline(always)] @@ -171,88 +140,17 @@ util::debug_struct! { } } +#[derive(Serialize, Deserialize)] +pub(crate) struct Stats { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) server_count: Option, +} + #[derive(Deserialize)] pub(crate) struct Bots { pub(crate) results: Vec, } -util::debug_struct! { - #[derive(Clone, Serialize, Deserialize)] - #[deprecated(since = "1.4.3", note = "No longer has a use by Top.gg API v0. Soon, all you need is just your bot's server count (usize).")] - Stats { - protected { - #[serde(skip_serializing_if = "Option::is_none")] - server_count: Option, - } - - getters(self) { - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always return an empty slice.")] - shards: &[usize] => { - &[] - } - - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always return 0.")] - shard_count: usize => { - 0 - } - - server_count: Option => { - self.server_count - } - } - } -} - -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 _), - ) - } - - #[deprecated( - since = "1.4.3", - note = "The shard_count argument no longer has an effect." - )] - pub const fn from_count(server_count: usize, _shard_count: Option) -> Self { - Self { - server_count: Some(server_count), - } - } - - #[deprecated( - since = "1.4.3", - note = "No longer supported by Top.gg API v0. At the moment, the shard_index argument has no effect." - )] - pub fn from_shards(shards: A, _shard_index: Option) -> Self - where - A: IntoIterator, - { - let mut total_server_count = 0; - let shards = shards.into_iter(); - - for server_count in shards { - total_server_count += server_count; - } - - Self { - server_count: Some(total_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/client.rs b/src/client.rs index e683e0c..800ea68 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,7 +1,7 @@ use crate::{ - bot::{Bot, Bots, GetBots, IsWeekend}, + bot::{Bot, Bots, GetBots, IsWeekend, Stats}, user::{User, Voted, Voter}, - util, Error, Result, Snowflake, Stats, + util, Error, Result, Snowflake, }; use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; use serde::{de::DeserializeOwned, Deserialize}; @@ -112,12 +112,15 @@ impl InnerClient { } } - pub(crate) async fn post_stats(&self, new_stats: &Stats) -> Result<()> { + pub(crate) async fn post_server_count(&self, server_count: usize) -> Result<()> { self .send_inner( Method::POST, api!("/bots/stats"), - serde_json::to_vec(new_stats).unwrap(), + serde_json::to_vec(&Stats { + server_count: Some(server_count), + }) + .unwrap(), ) .await .map(|_| ()) @@ -195,7 +198,7 @@ impl Client { .await } - /// Fetches your bot's statistics. + /// Fetches your bot's posted server count. /// /// # Panics /// @@ -207,11 +210,12 @@ impl Client { /// - 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 { + pub async fn get_server_count(&self) -> Result> { self .inner .send(Method::GET, api!("/bots/stats"), None) .await + .map(|stats: Stats| stats.server_count) } /// Posts your bot's statistics. @@ -227,8 +231,8 @@ impl Client { /// - 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_stats(&self, new_stats: Stats) -> Result<()> { - self.inner.post_stats(&new_stats).await + pub async fn post_server_count(&self, server_count: usize) -> Result<()> { + self.inner.post_server_count(server_count).await } /// Fetches your bot's last 1000 voters. diff --git a/src/lib.rs b/src/lib.rs index 0749924..df7cafc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,6 @@ cfg_if::cfg_if! { pub mod user; #[doc(inline)] - pub use bot::Stats; pub use client::Client; pub use error::{Error, Result}; pub use snowflake::Snowflake; // for doc purposes @@ -33,7 +32,7 @@ cfg_if::cfg_if! { pub mod autoposter; #[doc(inline)] - pub use autoposter::{Autoposter, SharedStats}; + pub use autoposter::Autoposter; } } diff --git a/src/snowflake.rs b/src/snowflake.rs index ab5dae6..1c8e592 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -40,7 +40,7 @@ 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_snowflake!(self, $t, self.parse().expect("invalid snowflake as it's not numeric")); )+} ); @@ -50,7 +50,7 @@ cfg_if::cfg_if! { if #[cfg(feature = "api")] { macro_rules! impl_topgg_idstruct( ($($t:ty),+) => {$( - impl_snowflake!(self, &$t, (*self).id); + impl_snowflake!(self, &$t, self.id); )+} ); @@ -67,18 +67,18 @@ cfg_if::cfg_if! { impl_snowflake!( #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &serenity::model::guild::Member, - (*self).user.id.get() + 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() + 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_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, self.get()); )+} ); @@ -89,7 +89,7 @@ cfg_if::cfg_if! { macro_rules! impl_serenity_idstruct( ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, (*self).id.get()); + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, self.id.get()); )+} ); @@ -136,7 +136,7 @@ cfg_if::cfg_if! { macro_rules! impl_twilight_idstruct( ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, (*self).id.get()); + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, self.id.get()); )+} ); @@ -154,7 +154,7 @@ cfg_if::cfg_if! { impl_snowflake!( #[cfg_attr(docsrs, doc(cfg(feature = "twilight-cached")))] self, &twilight_cache_inmemory::model::CachedMember, - (*self).user_id().get() + self.user_id().get() ); } } diff --git a/src/user.rs b/src/user.rs index 03683f8..4ecc708 100644 --- a/src/user.rs +++ b/src/user.rs @@ -55,10 +55,6 @@ util::debug_struct! { #[serde(rename = "supporter")] is_supporter: bool, - #[serde(deserialize_with = "util::deserialize_immediate_default")] - #[deprecated(since = "1.4.3", note = "No longer supported by Top.gg API v0. At the moment, this will always be false.")] - is_certified_dev: bool, - /// Whether this user is a [Top.gg](https://top.gg) moderator or not. #[serde(rename = "mod")] is_moderator: bool, diff --git a/src/util.rs b/src/util.rs index 47ed3b4..5efde8e 100644 --- a/src/util.rs +++ b/src/util.rs @@ -3,17 +3,6 @@ use chrono::{DateTime, TimeZone, Utc}; use reqwest::Response; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; -// TODO: remove these utility deprecation helpers soon - -#[inline(always)] -pub(crate) fn deserialize_immediate_default<'de, D, T>(_deserializer: D) -> Result -where - D: Deserializer<'de>, - T: Default, -{ - Ok(T::default()) -} - const DISCORD_EPOCH: u64 = 1_420_070_400_000; macro_rules! debug_struct { From cc310a7c0a404d30964d1729fc8519906ca1c21e Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 23:44:05 +0700 Subject: [PATCH 09/37] feat: add tests --- .github/workflows/test.yml | 38 +++++++++++++++++++++++++ Cargo.toml | 3 ++ README.md | 3 +- src/autoposter/mod.rs | 4 +-- src/lib.rs | 4 ++- src/test.rs | 58 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 src/test.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..911a0b8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,38 @@ +name: Run tests +on: + push: + branches: [main, v0] + tags-ignore: ['**'] + paths: ['src/**/*.rs'] + pull_request: + tags-ignore: ['**'] + paths: ['src/**/*.rs'] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Rust + uses: actions-rs/toolchain@v1 + with: + profile: minimal + override: true + toolchain: stable + - name: Test documentation + run: | + export RUSTDOCFLAGS="-D warnings" + + cargo doc --all-features --no-deps + - name: Run linter + run: | + export RUSTFLAGS="-D warnings" + + cargo clippy --features autoposter,serenity + cargo clippy --features autoposter,serenity-cached + cargo clippy --features autoposter,twilight + cargo clippy --features autoposter,twilight-cached + cargo clippy --features webhook + cargo clippy --features rocket + cargo clippy --features axum + cargo clippy --features warp + cargo clippy --features actix-web \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index afa1090..427ca4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,9 @@ 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"] } + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] diff --git a/README.md b/README.md index c0fac8e..2fb32b3 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,6 @@ async fn main() { 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); @@ -103,7 +102,7 @@ async fn main() { let server_count = 12345; client - .post_stats(Stats::from(server_count)) + .post_server_count(server_count) .await .unwrap(); } diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 510d848..0ed7dca 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -32,9 +32,9 @@ cfg_if::cfg_if! { /// A trait for handling events from third-party 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 ideally should own a `RwLock` 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`]. + /// The method that borrows `RwLock` to the [`Autoposter`]. fn server_count(&self) -> &RwLock; } diff --git a/src/lib.rs b/src/lib.rs index df7cafc..d12376e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,8 @@ #![cfg_attr(docsrs, feature(doc_cfg))] mod snowflake; +#[cfg(test)] +mod test; cfg_if::cfg_if! { if #[cfg(feature = "api")] { @@ -42,4 +44,4 @@ cfg_if::cfg_if! { pub use webhook::*; } -} +} \ No newline at end of file diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..ff70288 --- /dev/null +++ b/src/test.rs @@ -0,0 +1,58 @@ +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 user = client.get_user(661200758510977084).await.unwrap(); + + assert_eq!(user.username, "null"); + assert_eq!(user.id, 661200758510977084); + } + + 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) + .username("shiro") + .sort_by_monthly_votes() + .await + .unwrap(); + } + + delayed! { + client + .post_server_count(2) + .await + .unwrap(); + } + + delayed! { + assert_eq!(client.get_server_count().await.unwrap().unwrap(), 2); + } + + delayed! { + let _has_voted = client.has_voted(661200758510977084).await.unwrap(); + } + + delayed! { + let _is_weekend = client.is_weekend().await.unwrap(); + } +} From 00649aa49b5d7ad360b7cbcebae52e1eb1aa5e0c Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 18 Feb 2025 23:52:53 +0700 Subject: [PATCH 10/37] refactor: use iter instead of into_iter --- src/autoposter/serenity_impl.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs index 91bd114..b546c2e 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -105,7 +105,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(); } } } From 43ec7090d29be7c1d1333739cd059223173dfb0b Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 00:27:16 +0700 Subject: [PATCH 11/37] refactor: ignore unreachable_patterns --- src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib.rs b/src/lib.rs index d12376e..29b0af3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] +#![cfg_attr(feature = "webhook", allow(unreachable_patterns))] mod snowflake; #[cfg(test)] From 33bfceb742acc375546a8076c1ad8db45f855c16 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 01:13:53 +0700 Subject: [PATCH 12/37] doc: update docs for server count [skip ci] --- src/client.rs | 2 +- src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client.rs b/src/client.rs index 800ea68..c49ec84 100644 --- a/src/client.rs +++ b/src/client.rs @@ -218,7 +218,7 @@ impl Client { .map(|stats: Stats| stats.server_count) } - /// Posts your bot's statistics. + /// Posts your bot's server count. /// /// # Panics /// diff --git a/src/lib.rs b/src/lib.rs index 29b0af3..d94a6f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,4 +45,4 @@ cfg_if::cfg_if! { pub use webhook::*; } -} \ No newline at end of file +} From b50a48c277429835b2d2e4dfe9446618f04a0c72 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 14:21:49 +0700 Subject: [PATCH 13/37] okay veld --- src/bot.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/bot.rs b/src/bot.rs index 8fc2269..6d6b134 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -203,6 +203,9 @@ impl<'a> GetBots<'a> { } 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, From 7ed5e6550d51aa7da414956583bfe706b3caea90 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 14:22:48 +0700 Subject: [PATCH 14/37] style: prettier --- src/bot.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot.rs b/src/bot.rs index 6d6b134..5dbbe1e 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -205,7 +205,7 @@ impl<'a> GetBots<'a> { 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, From 4ce932c9b9b49129a40c46479e21b3b9050e39d3 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 15:20:19 +0700 Subject: [PATCH 15/37] doc: tweak documentation for GetBots --- src/bot.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 5dbbe1e..c1fa934 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -156,7 +156,7 @@ pub(crate) struct IsWeekend { pub(crate) is_weekend: bool, } -// A struct for configuring the query in [`get_bots`][crate::Client::get_bots] before being sent to the [Top.gg API](https://docs.top.gg) by `await`ing it. +/// A struct for configuring the query in [`get_bots`][crate::Client::get_bots] before being sent to the [Top.gg API](https://docs.top.gg) by `await`ing it. #[must_use] pub struct GetBots<'a> { client: &'a Client, @@ -214,16 +214,16 @@ impl<'a> GetBots<'a> { } get_bots_method! { - /// Sets the maximum amount of bots to be queried. + /// 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. + /// 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 Discord bots that matches this username. + /// Queries only Discord bots that has this username. username: &str = search("username%3A%20{}%20", urlencoding::encode(username)); - /// Queries only Discord bots that matches this prefix. + /// Queries only Discord bots that has this prefix. prefix: &str = search("prefix%3A%20{}%20", urlencoding::encode(prefix)); /// Queries only Discord bots that has this vote count. From 86cc49695d0f04872e35f20ebac9eeff68b83e1a Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 19 Feb 2025 19:34:29 +0700 Subject: [PATCH 16/37] fix: remove get_user and fix several bugs --- .github/workflows/test.yml | 6 +- Cargo.toml | 3 +- README.md | 17 ----- src/bot.rs | 8 ++- src/client.rs | 44 ++++-------- src/lib.rs | 4 +- src/snowflake.rs | 3 +- src/test.rs | 25 +++---- src/user.rs | 137 ------------------------------------- src/util.rs | 29 +++++++- src/voter.rs | 46 +++++++++++++ 11 files changed, 113 insertions(+), 209 deletions(-) delete mode 100644 src/user.rs create mode 100644 src/voter.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 911a0b8..e7ab596 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,4 +35,8 @@ jobs: cargo clippy --features rocket cargo clippy --features axum cargo clippy --features warp - cargo clippy --features actix-web \ No newline at end of file + cargo clippy --features actix-web +# - name: Run tests +# run: cargo test +# env: +# TOPGG_TOKEN: ${{ secrets.TOPGG_TOKEN }} \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 427ca4b..497b225 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ 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 } @@ -43,7 +44,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"] diff --git a/README.md b/README.md index 2fb32b3..e8c71a8 100644 --- a/README.md +++ b/README.md @@ -34,23 +34,6 @@ This library provides several feature flags that can be enabled/disabled in `Car ## Examples -### Fetching a user from its Discord ID - -```rust,no_run -use topgg::Client; - -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); - let user = client.get_user(661200758510977084).await.unwrap(); - - assert_eq!(user.username, "null"); - assert_eq!(user.id, 661200758510977084); - - println!("{:?}", user); -} -``` - ### Fetching a bot from its Discord ID ```rust,no_run diff --git a/src/bot.rs b/src/bot.rs index c1fa934..3186240 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -24,10 +24,14 @@ util::debug_struct! { #[derive(Clone, Deserialize)] Bot { public { - /// The ID of this bot. - #[serde(deserialize_with = "snowflake::deserialize")] + /// The application ID of this bot. + #[serde(rename = "clientid", deserialize_with = "snowflake::deserialize")] id: u64, + /// The Top.gg user ID of this bot. + #[serde(rename = "id", deserialize_with = "snowflake::deserialize")] + topgg_id: u64, + /// The username of this bot. username: String, diff --git a/src/client.rs b/src/client.rs index c49ec84..1cdad7e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,7 +1,8 @@ use crate::{ bot::{Bot, Bots, GetBots, IsWeekend, Stats}, - user::{User, Voted, Voter}, - util, Error, Result, Snowflake, + util, + voter::{Voted, Voter}, + Error, Result, Snowflake, }; use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; use serde::{de::DeserializeOwned, Deserialize}; @@ -36,16 +37,16 @@ macro_rules! api { #[derive(Debug)] pub struct InnerClient { http: reqwest::Client, + id: u64, token: String, } // 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 { - token.insert_str(0, "Bearer "); - + pub(crate) fn new(token: String) -> Self { Self { http: reqwest::Client::new(), + id: util::id_from_token(&token), token, } } @@ -148,31 +149,6 @@ impl Client { Self { inner } } - /// Fetches a user from a Discord ID. - /// - /// # Panics - /// - /// Panics if any of the following conditions are met: - /// - The ID argument is a string but not numeric - /// - The client uses an invalid [Top.gg API](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 - where - I: Snowflake, - { - self - .inner - .send(Method::GET, api!("/users/{}", id.as_snowflake()), None) - .await - } - /// Fetches a listed bot from a Discord ID. /// /// # Panics @@ -250,7 +226,7 @@ impl Client { pub async fn get_voters(&self) -> Result> { self .inner - .send(Method::GET, api!("/bots/votes"), None) + .send(Method::GET, api!("/bots/{}/votes", self.inner.id), None) .await } @@ -323,7 +299,11 @@ impl Client { .inner .send::( Method::GET, - api!("/bots/check?userId={}", user_id.as_snowflake()), + api!( + "/bots/{}/check?userId={}", + self.inner.id, + user_id.as_snowflake() + ), None, ) .await diff --git a/src/lib.rs b/src/lib.rs index d94a6f8..2fbcaa4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,8 +18,8 @@ cfg_if::cfg_if! { /// Bot-related traits and structs. pub mod bot; - /// User-related structs. - pub mod user; + /// Voter-related structs. + pub mod voter; #[doc(inline)] pub use client::Client; diff --git a/src/snowflake.rs b/src/snowflake.rs index 1c8e592..8a2f881 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -56,8 +56,7 @@ cfg_if::cfg_if! { impl_topgg_idstruct!( crate::bot::Bot, - crate::user::User, - crate::user::Voter + crate::voter::Voter ); } } diff --git a/src/test.rs b/src/test.rs index ff70288..8e099b9 100644 --- a/src/test.rs +++ b/src/test.rs @@ -12,13 +12,6 @@ macro_rules! delayed { async fn api() { let client = Client::new(env!("TOPGG_TOKEN").to_string()); - delayed! { - let user = client.get_user(661200758510977084).await.unwrap(); - - assert_eq!(user.username, "null"); - assert_eq!(user.id, 661200758510977084); - } - delayed! { let bot = client.get_bot(264811613708746752).await.unwrap(); @@ -37,15 +30,19 @@ async fn api() { .unwrap(); } - delayed! { - client - .post_server_count(2) - .await - .unwrap(); - } + // delayed! { + // client + // .post_server_count(2) + // .await + // .unwrap(); + // } ERROR + + // delayed! { + // assert_eq!(client.get_server_count().await.unwrap().unwrap(), 2); + // } ERROR delayed! { - assert_eq!(client.get_server_count().await.unwrap().unwrap(), 2); + let _voters = client.get_voters().await.unwrap(); } delayed! { diff --git a/src/user.rs b/src/user.rs deleted file mode 100644 index 4ecc708..0000000 --- a/src/user.rs +++ /dev/null @@ -1,137 +0,0 @@ -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) 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 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 5efde8e..3ac1fc5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,5 @@ -use crate::Error; +use crate::{snowflake, Error}; +use base64::{prelude::BASE64_STANDARD, Engine}; use chrono::{DateTime, TimeZone, Utc}; use reqwest::Response; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; @@ -139,3 +140,29 @@ pub(crate) fn get_avatar(hash: &Option, id: u64) -> String { ), } } + +#[derive(Deserialize)] +struct TokenInformation { + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, +} + +pub(crate) fn id_from_token(token: &str) -> u64 { + let mut by_dots = token.split('.').skip(1); + + if let Some(slice) = by_dots.next() { + let mut portion = String::from(slice); + + for _ in 0..4 - (slice.len() % 4) { + portion.push('='); + } + + if let Ok(decoded) = BASE64_STANDARD.decode(portion) { + if let Ok(decoded_json) = serde_json::from_slice::(&decoded) { + return decoded_json.id; + } + } + } + + panic!("Got malformed Top.gg API token."); +} diff --git a/src/voter.rs b/src/voter.rs new file mode 100644 index 0000000..aa7d8c7 --- /dev/null +++ b/src/voter.rs @@ -0,0 +1,46 @@ +use crate::{snowflake, util}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +#[derive(Deserialize)] +pub(crate) struct Voted { + pub(crate) voted: u8, +} + +util::debug_struct! { + /// A struct representing a user who has voted on a 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) + } + } + } +} From a05052f9427926e94a19b684b4ba2ab13abbdbff Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 20 Feb 2025 14:45:37 +0700 Subject: [PATCH 17/37] ci: uncomment tests [skip ci] --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7ab596..60e398c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: cargo clippy --features axum cargo clippy --features warp cargo clippy --features actix-web -# - name: Run tests -# run: cargo test -# env: -# TOPGG_TOKEN: ${{ secrets.TOPGG_TOKEN }} \ No newline at end of file + - name: Run tests + run: cargo test --lib + env: + TOPGG_TOKEN: ${{ secrets.TOPGG_TOKEN }} \ No newline at end of file From cefca8d61951c369ffb3af40624b3039d46640b5 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Fri, 21 Feb 2025 10:55:11 +0700 Subject: [PATCH 18/37] feat: name instead of username --- src/bot.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bot.rs b/src/bot.rs index 3186240..ff9bf37 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -33,7 +33,8 @@ util::debug_struct! { topgg_id: u64, /// The username of this bot. - username: String, + #[serde(rename = "username")] + name: String, /// The prefix of this bot. prefix: String, From d119d73455d055cccc9b704690cda7b60f747694 Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 26 Feb 2025 17:36:59 +0700 Subject: [PATCH 19/37] refactor: rework BotQuery --- README.md | 4 +- src/bot.rs | 164 ++++++++++++++++++++++---------------------------- src/client.rs | 93 ++++++++++++++-------------- src/error.rs | 25 ++++---- src/test.rs | 22 +++---- src/util.rs | 16 +---- src/voter.rs | 23 +++---- 7 files changed, 156 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index e8c71a8..8c42da3 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ 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.name, "Luca"); assert_eq!(bot.id, 264811613708746752); println!("{:?}", bot); @@ -64,7 +64,7 @@ async fn main() { .get_bots() .limit(250) .skip(50) - .username("shiro") + .name("shiro") .sort_by_monthly_votes() .await; diff --git a/src/bot.rs b/src/bot.rs index ff9bf37..81aa7ac 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,49 +1,39 @@ use crate::{snowflake, util, Client}; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Serialize}; use std::{ cmp::min, + collections::HashMap, future::{Future, IntoFuture}, 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}"))) -} - util::debug_struct! { - /// A struct representing a bot listed on [Top.gg](https://top.gg). + /// Represents a Discord bot listed on Top.gg. #[must_use] #[derive(Clone, Deserialize)] Bot { public { - /// The application ID of this bot. + /// This bot's Discord ID. #[serde(rename = "clientid", deserialize_with = "snowflake::deserialize")] id: u64, - /// The Top.gg user ID of this bot. + /// This bot's Top.gg ID. #[serde(rename = "id", deserialize_with = "snowflake::deserialize")] topgg_id: u64, - /// The username of this bot. + /// This bot's username. #[serde(rename = "username")] name: String, - /// The prefix of this bot. + /// This bot's prefix. prefix: String, - /// The short description of this bot. + /// This bot's short description. #[serde(rename = "shortdesc")] short_description: String, - /// The long description of this bot. It can contain HTML and/or Markdown. + /// This bot's long description. It can contain HTML and/or Markdown. #[serde( default, deserialize_with = "util::deserialize_optional_string", @@ -51,88 +41,64 @@ util::debug_struct! { )] long_description: Option, - /// The tags of this bot. - #[serde(default, deserialize_with = "util::deserialize_default")] + /// This bot's tags. + #[serde(deserialize_with = "util::deserialize_default")] tags: Vec, - /// The website URL of this bot. + /// This bot's website URL. #[serde(default, deserialize_with = "util::deserialize_optional_string")] website: Option, - /// The link to this 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 bot's owners. The main owner is the first ID in the array. + /// This bot's owners IDs. #[serde(deserialize_with = "snowflake::deserialize_vec")] owners: Vec, - /// The URL for this bot's banner image. - #[serde( - default, - deserialize_with = "util::deserialize_optional_string", - rename = "bannerUrl" - )] - banner_url: Option, - - /// The date when this bot was approved on [Top.gg](https://top.gg). + /// This bot's submission date. #[serde(rename = "date")] - approved_at: DateTime, + submitted_at: DateTime, - /// The amount of upvotes this bot has. + /// The amount of votes this bot has. #[serde(rename = "points")] votes: usize, - /// The amount of upvotes this 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 bot. - #[serde(default, deserialize_with = "deserialize_support_server")] + /// This bot's support URL. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] support: Option, - } - private { - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - avatar: Option, + /// This bot's avatar URL. + avatar: String, + /// This bot's invite URL. #[serde(default, deserialize_with = "util::deserialize_optional_string")] invite: Option, + /// This bot's posted server count. + #[serde(default)] + server_count: Option, + } + + private { #[serde(default, deserialize_with = "util::deserialize_optional_string")] vanity: 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)] - avatar: String => { - util::get_avatar(&self.avatar, self.id) - } - - /// The invite URL of this 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 - ), - } - } - - /// Retrieves the URL of this bot's [Top.gg](https://top.gg) page. + /// This bot's Top.gg page URL. #[must_use] #[inline(always)] url: String => { @@ -161,23 +127,23 @@ pub(crate) struct IsWeekend { pub(crate) is_weekend: bool, } -/// A struct for configuring the query in [`get_bots`][crate::Client::get_bots] before being sent to the [Top.gg API](https://docs.top.gg) by `await`ing it. +/// A struct for configuring the query in [`get_bots`][crate::Client::get_bots] before being sent to the API. #[must_use] -pub struct GetBots<'a> { +pub struct BotQuery<'a> { client: &'a Client, - query: String, - search: String, + query: HashMap<&'static str, String>, + search: HashMap<&'static str, String>, sort: Option<&'static str>, } macro_rules! get_bots_method { ($( $(#[doc = $doc:literal])* - $input_name:ident: $input_type:ty = $property:ident($($format:tt)*); + $lib_name:ident: $lib_type:ty = $property:ident($api_name:ident, $lib_value:expr); )*) => {$( $(#[doc = $doc])* - pub fn $input_name(mut self, $input_name: $input_type) -> Self { - self.$property.push_str(&format!($($format)*)); + pub fn $lib_name(mut self, $lib_name: $lib_type) -> Self { + self.$property.insert(stringify!($api_name), $lib_value); self } )*}; @@ -196,13 +162,13 @@ macro_rules! get_bots_sort { )*}; } -impl<'a> GetBots<'a> { +impl<'a> BotQuery<'a> { #[inline(always)] pub(crate) fn new(client: &'a Client) -> Self { Self { client, - query: String::from('?'), - search: String::new(), + query: HashMap::new(), + search: HashMap::new(), sort: None, } } @@ -211,8 +177,8 @@ impl<'a> GetBots<'a> { /// 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 submission date. + sort_by_submission_date: date, /// Sorts results based on each bot's monthly vote count. sort_by_monthly_votes: monthlyPoints, @@ -220,45 +186,59 @@ impl<'a> GetBots<'a> { 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)); + limit: u16 = query(limit, min(limit, 500).to_string()); /// Sets the amount of bots to be skipped during the query. This cannot be more than 499. - skip: u16 = query("offset={}&", min(skip, 499)); + skip: u16 = query(offset, min(skip, 499).to_string()); /// Queries only Discord bots that has this username. - username: &str = search("username%3A%20{}%20", urlencoding::encode(username)); + name: &str = search(username, urlencoding::encode(name).to_string()); /// Queries only Discord bots that has this prefix. - prefix: &str = search("prefix%3A%20{}%20", urlencoding::encode(prefix)); + prefix: &str = search(prefix, urlencoding::encode(prefix).to_string()); /// Queries only Discord bots that has this vote count. - votes: usize = search("points%3A%20{votes}%20"); + votes: usize = search(points, votes.to_string()); /// Queries only Discord bots that has this monthly vote count. - monthly_votes: usize = search("monthlyPoints%3A%20{monthly_votes}%20"); + monthly_votes: usize = search(monthlyPoints, monthly_votes.to_string()); - /// Queries only Discord bots that has this [Top.gg](https://top.gg) vanity URL. - vanity: &str = search("vanity%3A%20{}%20", urlencoding::encode(vanity)); + /// Queries only Discord bots that has this Top.gg vanity URL. + vanity: &str = search(vanity, urlencoding::encode(vanity).to_string()); } } -impl<'a> IntoFuture for GetBots<'a> { +impl<'a> IntoFuture for BotQuery<'a> { type Output = crate::Result>; type IntoFuture = Pin + Send + 'a>>; fn into_future(self) -> Self::IntoFuture { - let mut query = self.query; + let mut path = String::from("/bots?"); if let Some(sort) = self.sort { - query.push_str(&format!("sort={sort}&")); + path.push_str(&format!("sort={sort}&")); } if !self.search.is_empty() { - query.push_str(&format!("search={}", self.search)); - } else { - query.pop(); + let mut search = String::new(); + + for (key, value) in self.search { + search.push_str(&format!("{key}%3A%20{value}%20")); + } + + if !search.is_empty() { + search.truncate(search.len() - 3); + } + + path.push_str(&format!("search={search}&")); } - Box::pin(self.client.get_bots_inner(query)) + for (key, value) in self.query { + path.push_str(&format!("{key}={value}&")); + } + + path.pop(); + + Box::pin(self.client.get_bots_inner(path)) } } diff --git a/src/client.rs b/src/client.rs index 1cdad7e..fbad18c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,5 @@ use crate::{ - bot::{Bot, Bots, GetBots, IsWeekend, Stats}, + bot::{Bot, BotQuery, Bots, IsWeekend, Stats}, util, voter::{Voted, Voter}, Error, Result, Snowflake, @@ -114,10 +114,14 @@ impl InnerClient { } pub(crate) async fn post_server_count(&self, server_count: usize) -> Result<()> { + if server_count == 0 { + return Err(Error::InvalidRequest); + } + self .send_inner( Method::POST, - api!("/bots/stats"), + api!("/bots/{}/stats", self.id), serde_json::to_vec(&Stats { server_count: Some(server_count), }) @@ -149,21 +153,21 @@ impl Client { Self { inner } } - /// Fetches a listed 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) + /// - The provided ID is not numeric. + /// - 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 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]) + /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The requested bot does not exist. ([`NotFound`][crate::Error::NotFound]) + /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_bot(&self, id: I) -> Result where I: Snowflake, @@ -178,51 +182,52 @@ 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]) + /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_server_count(&self) -> Result> { self .inner - .send(Method::GET, api!("/bots/stats"), None) + .send(Method::GET, api!("/bots/{}/stats", self.inner.id), None) .await .map(|stats: Stats| stats.server_count) } - /// Posts your bot's server count. + /// Posts your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. /// /// # 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]) + /// - The bot is currently in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) + /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) #[inline(always)] pub async fn post_server_count(&self, server_count: usize) -> Result<()> { self.inner.post_server_count(server_count).await } - /// Fetches your bot's last 1000 voters. + /// Fetches your bot's last 1000 unique voters. /// /// # 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]) + /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_voters(&self) -> Result> { self .inner @@ -230,10 +235,10 @@ impl Client { .await } - pub(crate) async fn get_bots_inner(&self, query: String) -> Result> { + pub(crate) async fn get_bots_inner(&self, path: String) -> Result> { self .inner - .send::(Method::GET, api!("/bots{}", query), None) + .send::(Method::GET, api!("{}", path), None) .await .map(|res| res.results) } @@ -242,21 +247,21 @@ 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 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]) + /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) /// /// # Examples /// /// Basic usage: /// /// ```rust,no_run - /// use topgg::{Client, GetBots}; + /// use topgg::{Client, BotQuery}; /// /// let client = Client::new(env!("TOPGG_TOKEN").to_string()); /// @@ -264,7 +269,7 @@ impl Client { /// .get_bots() /// .limit(250) /// .skip(50) - /// .username("shiro") + /// .name("shiro") /// .sort_by_monthly_votes() /// .await; /// @@ -273,24 +278,24 @@ impl Client { /// } /// ``` #[inline(always)] - pub fn get_bots(&self) -> GetBots<'_> { - GetBots::new(self) + pub fn get_bots(&self) -> BotQuery<'_> { + BotQuery::new(self) } - /// Checks if the specified user has voted your bot. + /// Checks if the specified Discord user has voted your Discord bot. /// /// # 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) + /// - The provided ID is not numeric. + /// - 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]) + /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn has_voted(&self, user_id: I) -> Result where I: Snowflake, @@ -314,14 +319,14 @@ 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]) + /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn is_weekend(&self) -> Result { self .inner diff --git a/src/error.rs b/src/error.rs index 17fe2e4..c5d4b82 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,21 +1,24 @@ use core::{fmt, result}; use std::error; -/// A struct representing an error coming from this SDK - unexpected or not. +/// A struct representing 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). + /// An unexpected client-side error has occurred. InternalClientError(reqwest::Error), - /// An unexpected error coming from [Top.gg](https://top.gg)'s servers themselves. + /// An unexpected server-side error has occurred. InternalServerError, - /// The requested resource does not exist. (404) + /// Attempted to send an invalid request to the API. + InvalidRequest, + + /// The requested resource does not exist. NotFound, - /// The client is being ratelimited from sending more HTTP requests. + /// The client exceeded the API's ratelimits. Ratelimit { - /// The amount of seconds before the ratelimit is lifted. + /// How long the client should wait (in seconds) until it can make a request to the API again. retry_after: u16, }, } @@ -23,13 +26,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", ), } } diff --git a/src/test.rs b/src/test.rs index 8e099b9..7c3bf33 100644 --- a/src/test.rs +++ b/src/test.rs @@ -15,7 +15,7 @@ async fn api() { delayed! { let bot = client.get_bot(264811613708746752).await.unwrap(); - assert_eq!(bot.username, "Luca"); + assert_eq!(bot.name, "Luca"); assert_eq!(bot.id, 264811613708746752); } @@ -24,22 +24,22 @@ async fn api() { .get_bots() .limit(250) .skip(50) - .username("shiro") + .name("shiro") .sort_by_monthly_votes() .await .unwrap(); } - // delayed! { - // client - // .post_server_count(2) - // .await - // .unwrap(); - // } ERROR + delayed! { + client + .post_server_count(2) + .await + .unwrap(); + } - // delayed! { - // assert_eq!(client.get_server_count().await.unwrap().unwrap(), 2); - // } ERROR + delayed! { + assert_eq!(client.get_server_count().await.unwrap().unwrap(), 2); + } delayed! { let _voters = client.get_voters().await.unwrap(); diff --git a/src/util.rs b/src/util.rs index 3ac1fc5..ddaccef 100644 --- a/src/util.rs +++ b/src/util.rs @@ -127,20 +127,6 @@ where 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") - } - _ => format!( - "https://cdn.discordapp.com/embed/avatars/{}.png", - (id >> 22) % 6, - ), - } -} - #[derive(Deserialize)] struct TokenInformation { #[serde(deserialize_with = "snowflake::deserialize")] @@ -164,5 +150,5 @@ pub(crate) fn id_from_token(token: &str) -> u64 { } } - panic!("Got malformed Top.gg API token."); + panic!("Got a malformed Top.gg API token."); } diff --git a/src/voter.rs b/src/voter.rs index aa7d8c7..8ef591c 100644 --- a/src/voter.rs +++ b/src/voter.rs @@ -13,34 +13,25 @@ util::debug_struct! { #[derive(Clone, Deserialize)] Voter { public { - /// The Discord ID of this user. + /// This voter's Discord ID. #[serde(deserialize_with = "snowflake::deserialize")] id: u64, - /// The username of this user. - username: String, - } + /// This voter's username. + #[serde(rename = "username")] + name: 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)] - avatar: String => { - util::get_avatar(&self.avatar, self.id) - } } } } From 376cc56586e310a9aa3e9a2252709cb82abb7790 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 27 Feb 2025 14:44:31 +0700 Subject: [PATCH 20/37] feat: add reviews --- src/bot.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/bot.rs b/src/bot.rs index 81aa7ac..29921e6 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -8,6 +8,22 @@ use std::{ pin::Pin, }; +util::debug_struct! { + /// Represents a Discord bot's reviews on Top.gg. + #[must_use] + #[derive(Clone, Deserialize)] + BotReviews { + public { + /// This bot's average review score out of 5. + #[serde(rename = "averageScore")] + score: f64, + + /// This bot's review count. + count: usize, + } + } +} + util::debug_struct! { /// Represents a Discord bot listed on Top.gg. #[must_use] @@ -83,6 +99,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 { From d2c5ee5070691ee7d1c541044cd7e3ba18fec0af Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 4 Mar 2025 17:19:25 +0700 Subject: [PATCH 21/37] ci: remove test workflow --- .github/workflows/test.yml | 42 -------------------------------------- 1 file changed, 42 deletions(-) delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 60e398c..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Run tests -on: - push: - branches: [main, v0] - tags-ignore: ['**'] - paths: ['src/**/*.rs'] - pull_request: - tags-ignore: ['**'] - paths: ['src/**/*.rs'] -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup Rust - uses: actions-rs/toolchain@v1 - with: - profile: minimal - override: true - toolchain: stable - - name: Test documentation - run: | - export RUSTDOCFLAGS="-D warnings" - - cargo doc --all-features --no-deps - - name: Run linter - run: | - export RUSTFLAGS="-D warnings" - - cargo clippy --features autoposter,serenity - cargo clippy --features autoposter,serenity-cached - cargo clippy --features autoposter,twilight - cargo clippy --features autoposter,twilight-cached - cargo clippy --features webhook - cargo clippy --features rocket - cargo clippy --features axum - cargo clippy --features warp - cargo clippy --features actix-web - - name: Run tests - run: cargo test --lib - env: - TOPGG_TOKEN: ${{ secrets.TOPGG_TOKEN }} \ No newline at end of file From 2af90fcf91d38d9a192c0db4f85d0cddb2553fc4 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 4 Mar 2025 21:21:04 +0700 Subject: [PATCH 22/37] feat: add page to get_voters --- src/client.rs | 18 ++++++++++++++---- src/test.rs | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/client.rs b/src/client.rs index fbad18c..fbcf4b9 100644 --- a/src/client.rs +++ b/src/client.rs @@ -178,7 +178,7 @@ impl Client { .await } - /// Fetches your bot's posted server count. + /// Fetches your Discord bot's posted server count. /// /// # Panics /// @@ -216,7 +216,9 @@ impl Client { self.inner.post_server_count(server_count).await } - /// Fetches your bot's last 1000 unique voters. + /// Fetches your Discord bot'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 /// @@ -228,10 +230,18 @@ impl Client { /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) - pub async fn get_voters(&self) -> Result> { + pub async fn get_voters(&self, mut page: usize) -> Result> { + if page < 1 { + page = 1; + } + self .inner - .send(Method::GET, api!("/bots/{}/votes", self.inner.id), None) + .send( + Method::GET, + api!("/bots/{}/votes?page={}", self.inner.id, page), + None, + ) .await } diff --git a/src/test.rs b/src/test.rs index 7c3bf33..74d1cc8 100644 --- a/src/test.rs +++ b/src/test.rs @@ -42,7 +42,7 @@ async fn api() { } delayed! { - let _voters = client.get_voters().await.unwrap(); + let _voters = client.get_voters(1).await.unwrap(); } delayed! { From f4633a0d64ce9a1dda9c363fc890c0af5645a2e0 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 4 Mar 2025 21:29:48 +0700 Subject: [PATCH 23/37] doc: reword documentation --- Cargo.toml | 2 +- README.md | 40 +++++++++++----------- src/autoposter/client.rs | 4 +-- src/autoposter/mod.rs | 60 +++++++++++++++------------------ src/autoposter/serenity_impl.rs | 6 ++-- src/autoposter/twilight_impl.rs | 4 +-- src/bot.rs | 20 +++++------ src/client.rs | 32 +++++++++--------- src/error.rs | 10 +++--- src/snowflake.rs | 8 ++--- src/util.rs | 2 +- src/voter.rs | 2 +- src/webhook/axum.rs | 2 +- src/webhook/mod.rs | 4 +-- src/webhook/vote.rs | 30 ++++++++--------- src/webhook/warp.rs | 2 +- 16 files changed, 111 insertions(+), 117 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 497b225..b2e1ff4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "topgg" version = "1.4.3" 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" diff --git a/README.md b/README.md index 8c42da3..709f98b 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,13 @@ [crates-io-downloads-image]: https://img.shields.io/crates/d/topgg?style=flat-square [crates-io-url]: https://crates.io/crates/topgg -The official Rust SDK for the [Top.gg API](https://docs.top.gg). +A simple API wrapper for [Top.gg](https://top.gg) written in Rust. -## Getting Started +## Getting started -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`: +Make sure you already have an API token handy. See [this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff) on how to retrieve it. + +After that, add the following line to the `dependencies` section of your `Cargo.toml`: ```toml topgg = "1.4" @@ -20,17 +22,17 @@ For more information, please read [the documentation](https://docs.rs/topgg)! 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). +- **`api`**: Interact with the API's endpoints. + - **`autoposter`**: Automate the process of posting your bot's server count to the API. - **`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). + - **`actix-web`**: Extra helpers for working with actix-web. + - **`axum`**: Extra helpers for working with axum. + - **`rocket`**: Extra helpers for working with rocket. + - **`warp`**: Extra helpers for working with warp. +- **`serenity`**: Extra helpers for working with serenity (with bot caching disabled). + - **`serenity-cached`**: Extra helpers for working with serenity (with bot caching enabled). +- **`twilight`**: Extra helpers for working with twilight (with bot caching disabled). + - **`twilight-cached`**: Extra helpers for working with twilight (with bot caching enabled). ## Examples @@ -74,7 +76,7 @@ async fn main() { } ``` -### Posting your bot's statistics +### Posting your bot's server count ```rust,no_run use topgg::{Client, Stats}; @@ -106,7 +108,7 @@ async fn main() { } ``` -### Autoposting with [serenity](https://crates.io/crates/serenity) +### Autoposting with serenity In your `Cargo.toml`: @@ -163,7 +165,7 @@ async fn main() { } ``` -### Autoposting with [twilight](https://twilight.rs) +### Autoposting with twilight In your `Cargo.toml`: @@ -264,7 +266,7 @@ async fn main() -> io::Result<()> { } ``` -### Writing an [axum](https://crates.io/crates/axum) webhook for listening to votes +### Writing an axum webhook for listening to votes In your `Cargo.toml`: @@ -311,7 +313,7 @@ async fn main() { } ``` -### Writing a [rocket](https://rocket.rs) webhook for listening to votes +### Writing a rocket webhook for listening to votes In your `Cargo.toml`: @@ -356,7 +358,7 @@ fn main() { } ``` -### Writing a [warp](https://crates.io/crates/warp) webhook for listening to votes +### Writing a warp webhook for listening to votes In your `Cargo.toml`: 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 0ed7dca..06f5453 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -30,17 +30,17 @@ cfg_if::cfg_if! { } } -/// A trait for handling events from third-party bot libraries. +/// Handle events from third-party bot libraries. /// -/// The struct implementing this trait ideally should own a `RwLock` struct and update it accordingly whenever Discord updates them with new data regarding guild/shard count. +/// Structs that implement this ideally should own a `RwLock` instance and update it accordingly whenever Discord sends them new data regarding their server count. pub trait Handler: Send + Sync + 'static { - /// The method that borrows `RwLock` to the [`Autoposter`]. + /// Borrows the instance to the [`Autoposter`]. fn server_count(&self) -> &RwLock; } -/// A struct that lets you automate the process of posting bot statistics to [Top.gg](https://top.gg) in intervals. +/// Automate the process of posting your bot's server count to the API. /// -/// **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 thread that does the autoposting. It will stop once it gets dropped. #[must_use] pub struct Autoposter { handler: Arc, @@ -52,22 +52,18 @@ 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. + /// Creates an autoposter instance and immediately starts up the 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. - /// - `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 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 + /// - `client` can either be a reference to an existing [`Client`][crate::Client] or an API token ([`&str`][std::str]). + /// - `handler` is any struct that gives out server count information. + /// - `interval` is the interval between posting. Defaults to 15 minutes. + 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." - ); + if interval.as_secs() < 900 { + interval = Duration::from_secs(900); + } let client = client.as_client(); let handler = Arc::new(handler); @@ -95,19 +91,23 @@ where } } - /// Retrieves the [`Handler`] inside in the form of a [cloned][Arc::clone] [`Arc`][Arc]. + /// Retrieves 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`][Autoposter::receiver]. + /// Returns a future that resolves every time the autoposter posts your bot's server count. + /// + /// If you want to use the receiver directly, call [`receiver`][Autoposter::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 + 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`][Autoposter::recv]. Subsequent calls to this function and [`recv`][Autoposter::recv] after this call will panic. + /// Takes the receiver responsible for [`recv`][Autoposter::recv]. + /// + /// Subsequent calls to this method and [`recv`][Autoposter::recv] after this will panic. #[inline(always)] pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { self @@ -129,13 +129,10 @@ impl Deref for Autoposter { #[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 a serenity-based autoposter instance and immediately starts up the 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. - /// - /// # Panics - /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). + /// - `client` can either be a reference to an existing [`Client`][crate::Client] or an API token ([`&str`][std::str]). + /// - `interval` is the interval between posting. Defaults to 15 minutes. #[inline(always)] pub fn serenity(client: &C, interval: Duration) -> Self where @@ -148,13 +145,10 @@ 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. - /// - /// - `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 + /// Creates a twilight-based autoposter instance and immediately starts up the thread. /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). + /// - `client` can either be a reference to an existing [`Client`][crate::Client] or an API token ([`&str`][std::str]). + /// - `interval` is the interval between posting. Defaults to 15 minutes. #[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 b546c2e..798a03d 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -21,7 +21,7 @@ cfg_if::cfg_if! { } } -/// A built-in [`Handler`] for the [serenity] library. +/// Autoposter handler for working with the serenity library. #[must_use] pub struct Serenity { #[cfg(not(feature = "serenity-cached"))] @@ -53,7 +53,7 @@ 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. pub async fn handle(&$self, $context: &Context, event: &FullEvent) { match event { $( @@ -129,7 +129,7 @@ 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"), + #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the bot doesn't cache guilds."), ).await } diff --git a/src/autoposter/twilight_impl.rs b/src/autoposter/twilight_impl.rs index 5c5b185..8938a97 100644 --- a/src/autoposter/twilight_impl.rs +++ b/src/autoposter/twilight_impl.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, ops::DerefMut}; use tokio::sync::{Mutex, RwLock}; use twilight_model::gateway::event::Event; -/// A built-in [`Handler`] for the [twilight](https://twilight.rs) library. +/// Autoposter handler for working with the twilight. pub struct Twilight { cache: Mutex>, server_count: RwLock, @@ -18,7 +18,7 @@ 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) => { diff --git a/src/bot.rs b/src/bot.rs index 29921e6..2abc511 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -9,7 +9,7 @@ use std::{ }; util::debug_struct! { - /// Represents a Discord bot's reviews on Top.gg. + /// A Discord bot's reviews on Top.gg. #[must_use] #[derive(Clone, Deserialize)] BotReviews { @@ -25,7 +25,7 @@ util::debug_struct! { } util::debug_struct! { - /// Represents a Discord bot listed on Top.gg. + /// A Discord bot listed on Top.gg. #[must_use] #[derive(Clone, Deserialize)] Bot { @@ -69,7 +69,7 @@ util::debug_struct! { #[serde(default, deserialize_with = "util::deserialize_optional_string")] github: Option, - /// This bot's owners IDs. + /// This bot's owner IDs. #[serde(deserialize_with = "snowflake::deserialize_vec")] owners: Vec, @@ -147,7 +147,7 @@ pub(crate) struct IsWeekend { pub(crate) is_weekend: bool, } -/// A struct for configuring the query in [`get_bots`][crate::Client::get_bots] before being sent to the API. +/// Configure a Discord bot query before sending it to the API. #[must_use] pub struct BotQuery<'a> { client: &'a Client, @@ -208,22 +208,22 @@ impl<'a> BotQuery<'a> { /// Sets the maximum amount of bots to be queried. This cannot be more than 500. limit: u16 = query(limit, min(limit, 500).to_string()); - /// Sets the amount of bots to be skipped during the query. This cannot be more than 499. + /// Sets the amount of bots to be skipped. This cannot be more than 499. skip: u16 = query(offset, min(skip, 499).to_string()); - /// Queries only Discord bots that has this username. + /// Queries only bots that has this username. name: &str = search(username, urlencoding::encode(name).to_string()); - /// Queries only Discord bots that has this prefix. + /// Queries only bots that has this prefix. prefix: &str = search(prefix, urlencoding::encode(prefix).to_string()); - /// Queries only Discord bots that has this vote count. + /// Queries only bots that has this vote count. votes: usize = search(points, votes.to_string()); - /// Queries only Discord bots that has this monthly vote count. + /// Queries only bots that has this monthly vote count. monthly_votes: usize = search(monthlyPoints, monthly_votes.to_string()); - /// Queries only Discord bots that has this Top.gg vanity URL. + /// Queries only bots that has this Top.gg vanity URL. vanity: &str = search(vanity, urlencoding::encode(vanity).to_string()); } } diff --git a/src/client.rs b/src/client.rs index fbcf4b9..647c330 100644 --- a/src/client.rs +++ b/src/client.rs @@ -41,7 +41,7 @@ pub struct InnerClient { token: String, } -// 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(token: String) -> Self { Self { @@ -80,7 +80,7 @@ impl InnerClient { Ok(response) } else { Err(match status { - StatusCode::UNAUTHORIZED => panic!("Invalid Top.gg API token."), + StatusCode::UNAUTHORIZED => panic!("Invalid API token."), StatusCode::NOT_FOUND => Error::NotFound, StatusCode::TOO_MANY_REQUESTS => match util::parse_json::(response).await { Ok(ratelimit) => Error::Ratelimit { @@ -132,7 +132,7 @@ 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 { @@ -140,9 +140,9 @@ pub struct Client { } impl Client { - /// Creates a brand new client instance from a [Top.gg](https://top.gg) token. + /// Creates a new instance. /// - /// 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). + /// To retrieve your API token, [see 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); @@ -167,7 +167,7 @@ impl Client { /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - The requested bot does not exist. ([`NotFound`][crate::Error::NotFound]) - /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_bot(&self, id: I) -> Result where I: Snowflake, @@ -189,7 +189,7 @@ impl Client { /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_server_count(&self) -> Result> { self .inner @@ -210,13 +210,13 @@ impl Client { /// - The bot is currently in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) #[inline(always)] pub async fn post_server_count(&self, server_count: usize) -> Result<()> { self.inner.post_server_count(server_count).await } - /// Fetches your Discord bot's recent unique voters. + /// Fetches your Discord bot's recent 100 unique voters. /// /// The amount of voters returned can't exceed 100, so you would need to use the `page` argument for this. /// @@ -229,7 +229,7 @@ impl Client { /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_voters(&self, mut page: usize) -> Result> { if page < 1 { page = 1; @@ -253,18 +253,18 @@ impl Client { .map(|res| res.results) } - /// Queries/searches through the [Top.gg](https://top.gg) database to look for matching listed Discord bots. + /// Returns a [`BotQuery`] instance that allows you to configure a bot query before sending it to the API. /// /// # Panics /// - /// Panics if any of The client uses an invalid API token.. + /// Panics if any of The client uses an invalid API token. /// /// # Errors /// /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) /// /// # Examples /// @@ -305,7 +305,7 @@ impl Client { /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn has_voted(&self, user_id: I) -> Result where I: Snowflake, @@ -325,7 +325,7 @@ 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 /// @@ -336,7 +336,7 @@ impl Client { /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client exceeded the API's ratelimits. ([`Ratelimit`][crate::Error::Ratelimit]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn is_weekend(&self) -> Result { self .inner diff --git a/src/error.rs b/src/error.rs index c5d4b82..b606783 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ use core::{fmt, result}; use std::error; -/// A struct representing an error coming from this SDK. +/// An error coming from this SDK. #[derive(Debug)] pub enum Error { /// An unexpected client-side error has occurred. @@ -13,12 +13,12 @@ pub enum Error { /// Attempted to send an invalid request to the API. InvalidRequest, - /// The requested resource does not exist. + /// Such query does not exist. NotFound, - /// The client exceeded the API's ratelimits. + /// Ratelimited from sending more requests. Ratelimit { - /// How long the client should wait (in seconds) until it can make a request to the API again. + /// How long the client should wait (in seconds) before it can make a request to the API again. retry_after: u16, }, } @@ -48,5 +48,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/snowflake.rs b/src/snowflake.rs index 8a2f881..2c3fc00 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -18,9 +18,9 @@ where .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. +/// Any datatype that can be interpreted as a Discord ID. pub trait Snowflake { - /// The method that converts this value to a [`u64`]. + /// Converts this value to a [`u64`]. fn as_snowflake(&self) -> u64; } @@ -40,7 +40,7 @@ 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_snowflake!(self, $t, self.parse().expect("Invalid snowflake as it's not numeric.")); )+} ); @@ -72,7 +72,7 @@ cfg_if::cfg_if! { 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() + self.user.as_ref().expect("User property in PartialMember is None.").id.get() ); macro_rules! impl_serenity_id( diff --git a/src/util.rs b/src/util.rs index ddaccef..53274a3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -150,5 +150,5 @@ pub(crate) fn id_from_token(token: &str) -> u64 { } } - panic!("Got a malformed Top.gg API token."); + panic!("Got a malformed API token."); } diff --git a/src/voter.rs b/src/voter.rs index 8ef591c..e26ae1c 100644 --- a/src/voter.rs +++ b/src/voter.rs @@ -8,7 +8,7 @@ pub(crate) struct Voted { } util::debug_struct! { - /// A struct representing a user who has voted on a bot listed on [Top.gg](https://top.gg). (See [`Client::get_voters`][crate::Client::get_voters]) + /// A Top.gg voter. #[must_use] #[derive(Clone, Deserialize)] Voter { diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs index 76d804f..99c8d3b 100644 --- a/src/webhook/axum.rs +++ b/src/webhook/axum.rs @@ -46,7 +46,7 @@ where (StatusCode::UNAUTHORIZED, ()).into_response() } -/// Creates a new [`axum`] [`Router`] for adding an on-vote event handler to your application logic. +/// Creates a new axum [`Router`] for receiving vote events. /// /// # Examples /// 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..a46f923 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -31,7 +31,11 @@ where .map(|s| { let mut output = HashMap::new(); - for mut it in s.split('&').map(|pair| pair.split('=')) { + 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()); @@ -45,11 +49,11 @@ where ) } -/// A struct representing a dispatched [Top.gg](https://top.gg) bot/server vote event. +/// A dispatched Top.gg 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 Discord bot/server that received a vote. #[serde( deserialize_with = "snowflake::deserialize", alias = "bot", @@ -57,11 +61,11 @@ pub struct Vote { )] pub receiver_id: u64, - /// The ID of the user who voted. + /// The ID of the Top.gg 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). + /// Whether this vote's receiver is a Discord server. #[serde( default = "_true", deserialize_with = "deserialize_is_server", @@ -69,25 +73,22 @@ pub struct Vote { )] 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 done from the page settings. #[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`. + /// 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. + /// 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. + /// An unauthenticated dispatched Top.gg vote event. #[must_use] #[cfg_attr(docsrs, doc(cfg(any(feature = "actix-web", feature = "rocket"))))] #[derive(Clone)] @@ -97,7 +98,7 @@ cfg_if::cfg_if! { } impl IncomingVote { - /// Authenticates a valid password with this request. Returns a [`Some(Vote)`][`Vote`] if succeeds, otherwise `None`. + /// Authenticates a valid password with this request. /// /// # Examples /// @@ -132,7 +133,7 @@ cfg_if::cfg_if! { 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. + /// Vote event handler. /// /// It's described as follows (without [`async_trait`]'s macro expansion): /// ```rust,no_run @@ -144,7 +145,6 @@ 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. async fn voted(&self, vote: Vote); } } diff --git a/src/webhook/warp.rs b/src/webhook/warp.rs index 13e1238..8dca643 100644 --- a/src/webhook/warp.rs +++ b/src/webhook/warp.rs @@ -2,7 +2,7 @@ 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 receiving vote events. /// /// # Examples /// From 49d95f6684e3e4be98780a4ccc30965449b5de65 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Mar 2025 14:35:47 +0700 Subject: [PATCH 24/37] doc: add NotFound description in has_voted --- src/client.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 647c330..8c21338 100644 --- a/src/client.rs +++ b/src/client.rs @@ -164,9 +164,9 @@ impl Client { /// # Errors /// /// Errors if any of the following conditions are met: + /// - The requested bot does not exist. ([`NotFound`][crate::Error::NotFound]) /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The requested bot does not exist. ([`NotFound`][crate::Error::NotFound]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) pub async fn get_bot(&self, id: I) -> Result where @@ -303,6 +303,7 @@ impl Client { /// # Errors /// /// Errors if any of the following conditions are met: + /// - The specified user has not logged in to Top.gg. ([`NotFound`][crate::Error::NotFound]) /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) From 57bd4a736c0bc0addcba2b2ac7a5b97be8532886 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 6 Mar 2025 14:41:17 +0700 Subject: [PATCH 25/37] doc: make this consistent --- src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 8c21338..d2f692c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -164,7 +164,7 @@ impl Client { /// # Errors /// /// Errors if any of the following conditions are met: - /// - The requested bot does not exist. ([`NotFound`][crate::Error::NotFound]) + /// - The specified bot does not exist. ([`NotFound`][crate::Error::NotFound]) /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) From 7c69ed0f51d9561ffb8b6cec91d97fa80da01706 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 6 Mar 2025 17:30:53 +0700 Subject: [PATCH 26/37] doc: shorten documentation --- src/client.rs | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/client.rs b/src/client.rs index d2f692c..fb725fd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -157,13 +157,11 @@ impl Client { /// /// # Panics /// - /// Panics if any of the following conditions are met: /// - The provided ID is not numeric. /// - The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: /// - The specified bot does not exist. ([`NotFound`][crate::Error::NotFound]) /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) @@ -182,11 +180,10 @@ impl Client { /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) @@ -202,11 +199,10 @@ impl Client { /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: /// - The bot is currently in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) @@ -222,11 +218,10 @@ impl Client { /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) @@ -257,11 +252,10 @@ impl Client { /// /// # Panics /// - /// Panics if any of The client uses an invalid API token. + /// The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) @@ -296,13 +290,11 @@ impl Client { /// /// # Panics /// - /// Panics if any of the following conditions are met: /// - The provided ID is not numeric. /// - The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: /// - The specified user has not logged in to Top.gg. ([`NotFound`][crate::Error::NotFound]) /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) @@ -330,11 +322,10 @@ impl Client { /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) From 09ccf9fc1abeea2908eac80fda23d5c761a62741 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 20 Mar 2025 23:01:19 +0700 Subject: [PATCH 27/37] refactor: refactor from pedantic clippy --- Cargo.toml | 17 +++++++++++++++++ src/autoposter/mod.rs | 12 +++++++++--- src/autoposter/serenity_impl.rs | 12 ++++++++---- src/autoposter/twilight_impl.rs | 4 ++-- src/client.rs | 20 +++++--------------- src/util.rs | 31 ++----------------------------- 6 files changed, 43 insertions(+), 53 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b2e1ff4..102a0ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,23 @@ actix-web = { version = "4", default-features = false, optional = true } [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } +[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"] diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 06f5453..ca054d1 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -100,14 +100,20 @@ where /// Returns a future that resolves every time the autoposter posts your bot's server count. /// /// If you want to use the receiver directly, call [`receiver`][Autoposter::receiver]. + /// + /// # Panics + /// + /// Subsequent calls to this method. #[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`][Autoposter::recv]. /// - /// Subsequent calls to this method and [`recv`][Autoposter::recv] after this will panic. + /// # Panics + /// + /// Subsequent calls to this method. #[inline(always)] pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { self @@ -122,7 +128,7 @@ impl Deref for Autoposter { #[inline(always)] fn deref(&self) -> &Self::Target { - self.handler.deref() + &self.handler } } diff --git a/src/autoposter/serenity_impl.rs b/src/autoposter/serenity_impl.rs index 798a03d..b2d1dad 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -54,6 +54,10 @@ macro_rules! serenity_handler { } /// 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 { $( @@ -93,7 +97,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]) { @@ -114,7 +118,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) { @@ -130,7 +134,7 @@ serenity_handler! { #[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 + ).await; } handle( @@ -162,7 +166,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 8938a97..4012127 100644 --- a/src/autoposter/twilight_impl.rs +++ b/src/autoposter/twilight_impl.rs @@ -1,5 +1,5 @@ use crate::autoposter::Handler; -use std::{collections::HashSet, ops::DerefMut}; +use std::collections::HashSet; use tokio::sync::{Mutex, RwLock}; use twilight_model::gateway::event::Event; @@ -24,7 +24,7 @@ impl Twilight { 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 = cache.deref_mut(); + let cache_ref = &mut *cache; *cache_ref = ready.guilds.iter().map(|guild| guild.id.get()).collect(); *server_count = cache.len(); diff --git a/src/client.rs b/src/client.rs index fb725fd..5c69464 100644 --- a/src/client.rs +++ b/src/client.rs @@ -37,7 +37,6 @@ macro_rules! api { #[derive(Debug)] pub struct InnerClient { http: reqwest::Client, - id: u64, token: String, } @@ -46,7 +45,6 @@ impl InnerClient { pub(crate) fn new(token: String) -> Self { Self { http: reqwest::Client::new(), - id: util::id_from_token(&token), token, } } @@ -80,7 +78,7 @@ impl InnerClient { Ok(response) } else { Err(match status { - StatusCode::UNAUTHORIZED => panic!("Invalid API token."), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => panic!("Invalid API token."), StatusCode::NOT_FOUND => Error::NotFound, StatusCode::TOO_MANY_REQUESTS => match util::parse_json::(response).await { Ok(ratelimit) => Error::Ratelimit { @@ -121,7 +119,7 @@ impl InnerClient { self .send_inner( Method::POST, - api!("/bots/{}/stats", self.id), + "/bots/stats", serde_json::to_vec(&Stats { server_count: Some(server_count), }) @@ -190,7 +188,7 @@ impl Client { pub async fn get_server_count(&self) -> Result> { self .inner - .send(Method::GET, api!("/bots/{}/stats", self.inner.id), None) + .send(Method::GET, "/bots/stats", None) .await .map(|stats: Stats| stats.server_count) } @@ -232,11 +230,7 @@ impl Client { self .inner - .send( - Method::GET, - api!("/bots/{}/votes?page={}", self.inner.id, page), - None, - ) + .send(Method::GET, api!("/bots/votes?page={}", page), None) .await } @@ -307,11 +301,7 @@ impl Client { .inner .send::( Method::GET, - api!( - "/bots/{}/check?userId={}", - self.inner.id, - user_id.as_snowflake() - ), + api!("/bots/check?userId={}", user_id.as_snowflake()), None, ) .await diff --git a/src/util.rs b/src/util.rs index 53274a3..c54241b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,4 @@ -use crate::{snowflake, Error}; -use base64::{prelude::BASE64_STANDARD, Engine}; +use crate::Error; use chrono::{DateTime, TimeZone, Utc}; use reqwest::Response; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; @@ -102,7 +101,7 @@ where T: Default + Deserialize<'de>, D: Deserializer<'de>, { - Option::deserialize(deserializer).map(|res| res.unwrap_or_default()) + Option::deserialize(deserializer).map(Option::unwrap_or_default) } #[inline(always)] @@ -126,29 +125,3 @@ where Err(Error::InternalServerError) } - -#[derive(Deserialize)] -struct TokenInformation { - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, -} - -pub(crate) fn id_from_token(token: &str) -> u64 { - let mut by_dots = token.split('.').skip(1); - - if let Some(slice) = by_dots.next() { - let mut portion = String::from(slice); - - for _ in 0..4 - (slice.len() % 4) { - portion.push('='); - } - - if let Ok(decoded) = BASE64_STANDARD.decode(portion) { - if let Ok(decoded_json) = serde_json::from_slice::(&decoded) { - return decoded_json.id; - } - } - } - - panic!("Got a malformed API token."); -} From 48821beed57d345d7569fd48438cd0a6ce23e72c Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 5 May 2025 22:25:44 +0700 Subject: [PATCH 28/37] feat: remove url --- src/bot.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/bot.rs b/src/bot.rs index 2abc511..634e0c7 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -96,6 +96,10 @@ util::debug_struct! { #[serde(default, deserialize_with = "util::deserialize_optional_string")] invite: Option, + /// This bot's Top.gg vanity code. + #[serde(default, deserialize_with = "util::deserialize_optional_string")] + vanity: Option, + /// This bot's posted server count. #[serde(default)] server_count: Option, @@ -105,11 +109,6 @@ util::debug_struct! { review: BotReviews, } - private { - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - vanity: Option, - } - getters(self) { /// This bot's creation date. #[must_use] @@ -117,16 +116,6 @@ util::debug_struct! { created_at: DateTime => { util::get_creation_date(self.id) } - - /// This bot's Top.gg page URL. - #[must_use] - #[inline(always)] - url: String => { - format!( - "https://top.gg/bot/{}", - self.vanity.as_deref().unwrap_or(&self.id.to_string()) - ) - } } } } From f8888f060bff3a78f0ecf3972719f4efff981d0b Mon Sep 17 00:00:00 2001 From: null8626 Date: Fri, 23 May 2025 15:02:34 +0700 Subject: [PATCH 29/37] fix: fix: GET /bots/votes not working --- src/client.rs | 16 ++++++++++++++-- src/util.rs | 23 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/client.rs b/src/client.rs index 5c69464..e021c52 100644 --- a/src/client.rs +++ b/src/client.rs @@ -38,14 +38,18 @@ macro_rules! api { 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. impl InnerClient { pub(crate) fn new(token: String) -> Self { + let id = util::id_from_token(&token); + Self { http: reqwest::Client::new(), token, + id, } } @@ -141,6 +145,10 @@ impl Client { /// Creates a new instance. /// /// To retrieve your API token, [see this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). + /// + /// # Panics + /// + /// - The client uses an invalid API token. #[inline(always)] pub fn new(token: String) -> Self { let inner = InnerClient::new(token); @@ -188,7 +196,7 @@ impl Client { pub async fn get_server_count(&self) -> Result> { self .inner - .send(Method::GET, "/bots/stats", None) + .send(Method::GET, api!("/bots/stats"), None) .await .map(|stats: Stats| stats.server_count) } @@ -230,7 +238,11 @@ impl Client { self .inner - .send(Method::GET, api!("/bots/votes?page={}", page), None) + .send( + Method::GET, + api!("/bots/{}/votes?page={}", self.inner.id, page), + None, + ) .await } diff --git a/src/util.rs b/src/util.rs index c54241b..2c45794 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,4 +1,5 @@ -use crate::Error; +use crate::{snowflake, Error}; +use base64::Engine; use chrono::{DateTime, TimeZone, Utc}; use reqwest::Response; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; @@ -125,3 +126,23 @@ where Err(Error::InternalServerError) } + +#[derive(Deserialize)] +struct TokenStructure { + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, +} + +pub(crate) fn id_from_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."); +} From 55691744c9cc47f506d6d7436e3ff7613376405e Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 16 Jun 2025 18:12:30 +0700 Subject: [PATCH 30/37] feat: fully adapt to v0 --- Cargo.toml | 12 +++++------ README.md | 31 +++++++++++++-------------- src/autoposter/mod.rs | 15 ++++++------- src/autoposter/twilight_impl.rs | 2 +- src/bot.rs | 38 ++++----------------------------- src/client.rs | 3 +-- src/error.rs | 3 +-- src/snowflake.rs | 1 - src/test.rs | 1 - src/webhook/actix_web.rs | 2 +- 10 files changed, 36 insertions(+), 72 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 102a0ea..ec80bf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "topgg" -version = "1.4.3" +version = "2.0.0" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] description = "A simple API wrapper for Top.gg written in Rust." @@ -20,16 +20,16 @@ serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } urlencoding = "2" -serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } +serenity = { version = ">=0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } -twilight-model = { version = "0.15", optional = true } -twilight-cache-inmemory = { version = "0.15", optional = true } +twilight-model = { version = ">=0.16", optional = true } +twilight-cache-inmemory = { version = ">=0.16", optional = true } 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.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 } @@ -74,4 +74,4 @@ webhook = [] 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/README.md b/README.md index 709f98b..d285fdc 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Make sure you already have an API token handy. See [this tutorial](https://githu After that, add the following line to the `dependencies` section of your `Cargo.toml`: ```toml -topgg = "1.4" +topgg = "2" ``` For more information, please read [the documentation](https://docs.rs/topgg)! @@ -66,7 +66,6 @@ async fn main() { .get_bots() .limit(250) .skip(50) - .name("shiro") .sort_by_monthly_votes() .await; @@ -124,22 +123,14 @@ topgg = { version = "1.4", features = ["autoposter", "serenity-cached"] } In your code: ```rust,no_run -use core::time::Duration; -use serenity::{client::{Client, Context, EventHandler}, model::{channel::Message, gateway::Ready}}; +use std::time::Duration; +use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; use topgg::Autoposter; struct Handler; #[serenity::async_trait] impl EventHandler for Handler { - async fn message(&self, ctx: Context, msg: Message) { - if msg.content == "!ping" { - if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await { - println!("Error sending message: {why:?}"); - } - } - } - async fn ready(&self, _: Context, ready: Ready) { println!("{} is connected!", ready.user.name); } @@ -148,10 +139,10 @@ impl EventHandler for Handler { #[tokio::main] 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 mut autoposter = Autoposter::serenity(&topgg_client, Duration::from_secs(1800)); - let bot_token = env!("DISCORD_TOKEN").to_string(); - let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::GUILDS | GatewayIntents::MESSAGE_CONTENT; + let bot_token = env!("BOT_TOKEN").to_string(); + let intents = GatewayIntents::GUILDS; let mut client = Client::builder(&bot_token, intents) .event_handler(Handler) @@ -159,6 +150,14 @@ async fn main() { .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) = client.start().await { println!("Client error: {why:?}"); } @@ -181,7 +180,7 @@ topgg = { version = "1.4", 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}; diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index ca054d1..0fa1508 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -1,6 +1,5 @@ use crate::Result; -use core::{ops::Deref, time::Duration}; -use std::sync::Arc; +use std::{ops::Deref, time::Duration, sync::Arc}; use tokio::{ sync::{mpsc, RwLock}, task::{spawn, JoinHandle}, @@ -45,7 +44,7 @@ pub trait Handler: Send + Sync + 'static { pub struct Autoposter { handler: Arc, thread: JoinHandle<()>, - receiver: Option>>, + receiver: Option>>, } impl Autoposter @@ -77,7 +76,7 @@ where let server_count = handler.server_count().read().await; if sender - .send(client.post_server_count(*server_count).await) + .send(client.post_server_count(*server_count).await.map(|_| *server_count)) .is_err() { break; @@ -97,15 +96,15 @@ where Arc::clone(&self.handler) } - /// Returns a future that resolves every time the autoposter posts your bot's server count. + /// Returns a future that resolves every time the autoposter posts your bot's server count. The value contained inside is the server count that was just posted. /// /// If you want to use the receiver directly, call [`receiver`][Autoposter::receiver]. /// /// # Panics /// - /// Subsequent calls to this method. + /// Subsequent calls to this method after [`receiver`][Autoposter::receiver] is called. #[inline(always)] - pub async fn recv(&mut self) -> Option> { + 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 } @@ -115,7 +114,7 @@ where /// /// Subsequent calls to this method. #[inline(always)] - pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { + pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { self .receiver .take() diff --git a/src/autoposter/twilight_impl.rs b/src/autoposter/twilight_impl.rs index 4012127..4370bff 100644 --- a/src/autoposter/twilight_impl.rs +++ b/src/autoposter/twilight_impl.rs @@ -33,7 +33,7 @@ impl Twilight { Event::GuildCreate(guild_create) => { let mut cache = self.cache.lock().await; - if cache.insert(guild_create.0.id.get()) { + if cache.insert(guild_create.id().get()) { let mut server_count = self.server_count.write().await; *server_count = cache.len(); diff --git a/src/bot.rs b/src/bot.rs index 634e0c7..f89226f 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; use std::{ cmp::min, collections::HashMap, + fmt::Write, future::{Future, IntoFuture}, pin::Pin, }; @@ -141,7 +142,6 @@ pub(crate) struct IsWeekend { pub struct BotQuery<'a> { client: &'a Client, query: HashMap<&'static str, String>, - search: HashMap<&'static str, String>, sort: Option<&'static str>, } @@ -177,7 +177,6 @@ impl<'a> BotQuery<'a> { Self { client, query: HashMap::new(), - search: HashMap::new(), sort: None, } } @@ -199,21 +198,6 @@ impl<'a> BotQuery<'a> { /// Sets the amount of bots to be skipped. This cannot be more than 499. skip: u16 = query(offset, min(skip, 499).to_string()); - - /// Queries only bots that has this username. - name: &str = search(username, urlencoding::encode(name).to_string()); - - /// Queries only bots that has this prefix. - prefix: &str = search(prefix, urlencoding::encode(prefix).to_string()); - - /// Queries only bots that has this vote count. - votes: usize = search(points, votes.to_string()); - - /// Queries only bots that has this monthly vote count. - monthly_votes: usize = search(monthlyPoints, monthly_votes.to_string()); - - /// Queries only bots that has this Top.gg vanity URL. - vanity: &str = search(vanity, urlencoding::encode(vanity).to_string()); } } @@ -225,25 +209,11 @@ impl<'a> IntoFuture for BotQuery<'a> { let mut path = String::from("/bots?"); if let Some(sort) = self.sort { - path.push_str(&format!("sort={sort}&")); + write!(&mut path, "sort={sort}&").unwrap(); } - - if !self.search.is_empty() { - let mut search = String::new(); - - for (key, value) in self.search { - search.push_str(&format!("{key}%3A%20{value}%20")); - } - - if !search.is_empty() { - search.truncate(search.len() - 3); - } - - path.push_str(&format!("search={search}&")); - } - + for (key, value) in self.query { - path.push_str(&format!("{key}={value}&")); + write!(&mut path, "{key}={value}&").unwrap(); } path.pop(); diff --git a/src/client.rs b/src/client.rs index e021c52..52b5306 100644 --- a/src/client.rs +++ b/src/client.rs @@ -123,7 +123,7 @@ impl InnerClient { self .send_inner( Method::POST, - "/bots/stats", + api!("/bots/stats"), serde_json::to_vec(&Stats { server_count: Some(server_count), }) @@ -279,7 +279,6 @@ impl Client { /// .get_bots() /// .limit(250) /// .skip(50) - /// .name("shiro") /// .sort_by_monthly_votes() /// .await; /// diff --git a/src/error.rs b/src/error.rs index b606783..4c0a203 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,4 @@ -use core::{fmt, result}; -use std::error; +use std::{fmt, result, error}; /// An error coming from this SDK. #[derive(Debug)] diff --git a/src/snowflake.rs b/src/snowflake.rs index 2c3fc00..ecc2796 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -142,7 +142,6 @@ cfg_if::cfg_if! { impl_twilight_idstruct!( twilight_model::user::CurrentUser, twilight_model::user::User, - twilight_model::user::UserProfile, twilight_model::gateway::payload::incoming::invite_create::PartialUser ); } diff --git a/src/test.rs b/src/test.rs index 74d1cc8..9f61f80 100644 --- a/src/test.rs +++ b/src/test.rs @@ -24,7 +24,6 @@ async fn api() { .get_bots() .limit(250) .skip(50) - .name("shiro") .sort_by_monthly_votes() .await .unwrap(); diff --git a/src/webhook/actix_web.rs b/src/webhook/actix_web.rs index 43bff60..b9d841c 100644 --- a/src/webhook/actix_web.rs +++ b/src/webhook/actix_web.rs @@ -5,7 +5,7 @@ use actix_web::{ web::Json, FromRequest, HttpRequest, }; -use core::{ +use std::{ future::Future, pin::Pin, task::{ready, Context, Poll}, From 5763d2fd2b708d82c2a6354da5c286035a8ae977 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 17 Jun 2025 00:45:20 +0700 Subject: [PATCH 31/37] feat: add widgets --- src/autoposter/mod.rs | 9 +++++++-- src/bot.rs | 2 +- src/client.rs | 7 +++++-- src/error.rs | 2 +- src/lib.rs | 6 +++++- src/widget.rs | 10 ++++++++++ 6 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 src/widget.rs diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 0fa1508..5d1ca95 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -1,5 +1,5 @@ use crate::Result; -use std::{ops::Deref, time::Duration, sync::Arc}; +use std::{ops::Deref, sync::Arc, time::Duration}; use tokio::{ sync::{mpsc, RwLock}, task::{spawn, JoinHandle}, @@ -76,7 +76,12 @@ where let server_count = handler.server_count().read().await; if sender - .send(client.post_server_count(*server_count).await.map(|_| *server_count)) + .send( + client + .post_server_count(*server_count) + .await + .map(|_| *server_count), + ) .is_err() { break; diff --git a/src/bot.rs b/src/bot.rs index f89226f..46ad912 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -211,7 +211,7 @@ impl<'a> IntoFuture for BotQuery<'a> { if let Some(sort) = self.sort { write!(&mut path, "sort={sort}&").unwrap(); } - + for (key, value) in self.query { write!(&mut path, "{key}={value}&").unwrap(); } diff --git a/src/client.rs b/src/client.rs index 52b5306..9ae77fe 100644 --- a/src/client.rs +++ b/src/client.rs @@ -24,16 +24,19 @@ struct Ratelimit { retry_after: u16, } +#[macro_export] macro_rules! api { ($e:literal) => { - concat!("https://top.gg/api", $e) + concat!("https://top.gg/api/v1", $e) }; ($e:literal, $($rest:tt)*) => { - format!(api!($e), $($rest)*) + format!($crate::client::api!($e), $($rest)*) }; } +pub(crate) use api; + #[derive(Debug)] pub struct InnerClient { http: reqwest::Client, diff --git a/src/error.rs b/src/error.rs index 4c0a203..0caba82 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,4 @@ -use std::{fmt, result, error}; +use std::{error, fmt, result}; /// An error coming from this SDK. #[derive(Debug)] diff --git a/src/lib.rs b/src/lib.rs index 2fbcaa4..08d8331 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(feature = "webhook", allow(unreachable_patterns))] +#![allow(clippy::needless_pass_by_value)] mod snowflake; #[cfg(test)] @@ -8,7 +9,7 @@ mod test; cfg_if::cfg_if! { if #[cfg(feature = "api")] { - mod client; + pub(crate) mod client; mod error; mod util; @@ -21,6 +22,9 @@ cfg_if::cfg_if! { /// Voter-related structs. pub mod voter; + /// Widget generator functions. + pub mod widget; + #[doc(inline)] pub use client::Client; pub use error::{Error, Result}; diff --git a/src/widget.rs b/src/widget.rs new file mode 100644 index 0000000..daccacc --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,10 @@ +use crate::Snowflake; + +/// Generates a large widget URL. +#[inline(always)] +pub fn large(id: I) -> String +where + I: Snowflake, +{ + crate::client::api!("/widgets/large/{}", id.as_snowflake()) +} From 66b3e7abee4dea587d2245a6fe5b50ca6c8484ad Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 18 Jun 2025 20:24:54 +0700 Subject: [PATCH 32/37] feat: add small widgets --- src/lib.rs | 3 +++ src/widget.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 08d8331..4bf77e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,9 @@ cfg_if::cfg_if! { pub use client::Client; pub use error::{Error, Result}; pub use snowflake::Snowflake; // for doc purposes + + #[doc(inline)] + pub use widget::WidgetType; } } diff --git a/src/widget.rs b/src/widget.rs index daccacc..a22b625 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,10 +1,65 @@ use crate::Snowflake; +/// Widget type. +#[non_exhaustive] +pub enum WidgetType { + DiscordBot, + DiscordServer, +} + +impl WidgetType { + const fn as_path(&self) -> &'static str { + match self { + Self::DiscordBot => "discord/bot", + Self::DiscordServer => "discord/server", + } + } +} + /// Generates a large widget URL. #[inline(always)] -pub fn large(id: I) -> String +pub fn large(ty: WidgetType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!("/widgets/large/{}/{}", ty.as_path(), id.as_snowflake()) +} + +/// Generates a small widget URL for displaying votes. +#[inline(always)] +pub fn votes(ty: WidgetType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/small/votes/{}/{}", + ty.as_path(), + id.as_snowflake() + ) +} + +/// Generates a small widget URL for displaying an entity's owner. +#[inline(always)] +pub fn owner(ty: WidgetType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/small/owner/{}/{}", + ty.as_path(), + id.as_snowflake() + ) +} + +/// Generates a small widget URL for displaying social stats. +#[inline(always)] +pub fn social(ty: WidgetType, id: I) -> String where I: Snowflake, { - crate::client::api!("/widgets/large/{}", id.as_snowflake()) + crate::client::api!( + "/widgets/small/social/{}/{}", + ty.as_path(), + id.as_snowflake() + ) } From 6142f1fa93a019875ca455294f9b2af35dff7792 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 23 Jun 2025 23:31:52 +0700 Subject: [PATCH 33/37] docs: readme overhaul --- README.md | 322 ++++++++++++++++++++------------------- src/webhook/actix_web.rs | 27 ++-- src/webhook/axum.rs | 95 ++++++------ src/webhook/mod.rs | 50 ++++++ src/webhook/rocket.rs | 12 +- src/webhook/vote.rs | 86 +---------- src/webhook/warp.rs | 48 +++--- 7 files changed, 307 insertions(+), 333 deletions(-) diff --git a/README.md b/README.md index d285fdc..d83f496 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,90 @@ -# [topgg](https://crates.io/crates/topgg) [![crates.io][crates-io-image]][crates-io-url] [![crates.io downloads][crates-io-downloads-image]][crates-io-url] +# Top.gg Rust SDK -[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 +The community-maintained Rust library for Top.gg. -A simple API wrapper for [Top.gg](https://top.gg) written in Rust. +## Installation -## Getting started - -Make sure you already have an API token handy. See [this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff) on how to retrieve it. - -After that, add the following line to the `dependencies` section of your `Cargo.toml`: +In your `Cargo.toml`: ```toml +[dependencies] topgg = "2" ``` -For more information, please read [the documentation](https://docs.rs/topgg)! +## Setting up -## Features - -This library provides several feature flags that can be enabled/disabled in `Cargo.toml`. Such as: +```rust,no_run +use topgg::Client; -- **`api`**: Interact with the API's endpoints. - - **`autoposter`**: Automate the process of posting your bot's server count to the API. -- **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Vote` struct. - - **`actix-web`**: Extra helpers for working with actix-web. - - **`axum`**: Extra helpers for working with axum. - - **`rocket`**: Extra helpers for working with rocket. - - **`warp`**: Extra helpers for working with warp. -- **`serenity`**: Extra helpers for working with serenity (with bot caching disabled). - - **`serenity-cached`**: Extra helpers for working with serenity (with bot caching enabled). -- **`twilight`**: Extra helpers for working with twilight (with bot caching disabled). - - **`twilight-cached`**: Extra helpers for working with twilight (with bot caching enabled). +let client = Client::new(env!("TOPGG_TOKEN").to_string()); +``` -## Examples +## Usage -### Fetching a bot from its Discord ID +### Getting a bot ```rust,no_run -use topgg::Client; +let bot = client.get_bot(264811613708746752).await.unwrap(); +``` -#[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.name, "Luca"); - assert_eq!(bot.id, 264811613708746752); - - println!("{:?}", bot); +### Getting several bots + +```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); } ``` -### Querying several Discord bots +### Getting your bot's voters ```rust,no_run -use topgg::Client; +// Page number +let voters = client.get_voters(1).await.unwrap(); -#[tokio::main] -async fn main() { - 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); - } +for voter in voters { + println!("{}", voter.username); } ``` -### Posting your bot's server count +### Check if a user has voted for your bot ```rust,no_run -use topgg::{Client, Stats}; - -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); - - let server_count = 12345; - client - .post_server_count(server_count) - .await - .unwrap(); -} +let has_voted = client.has_voted(661200758510977084).await.unwrap(); ``` -### Checking if a user has voted your bot +### Getting your bot's server count ```rust,no_run -use topgg::Client; +let server_count = client.get_server_count().await.unwrap(); +``` -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); +### Posting your bot's server count - if client.has_voted(661200758510977084).await.unwrap() { - println!("checks out"); - } -} +```rust,no_run +client.post_server_count(bot.server_count()).await.unwrap(); ``` -### Autoposting with serenity +### Automatically posting your bot's server count every few minutes + +#### Serenity In your `Cargo.toml`: ```toml [dependencies] # using serenity with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "serenity"] } +topgg = { version = "2", features = ["autoposter", "serenity"] } # using serenity with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "serenity-cached"] } +topgg = { version = "2", features = ["autoposter", "serenity-cached"] } ``` In your code: @@ -132,19 +99,21 @@ struct Handler; #[serenity::async_trait] impl EventHandler for Handler { async fn ready(&self, _: Context, ready: Ready) { - println!("{} is connected!", ready.user.name); + println!("{} is now ready!", ready.user.name); } } #[tokio::main] async fn main() { - let topgg_client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - let mut autoposter = Autoposter::serenity(&topgg_client, Duration::from_secs(1800)); + 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 bot_token = env!("BOT_TOKEN").to_string(); let intents = GatewayIntents::GUILDS; - let mut client = Client::builder(&bot_token, intents) + let mut bot = Client::builder(&bot_token, intents) .event_handler(Handler) .event_handler_arc(autoposter.handler()) .await @@ -158,35 +127,35 @@ async fn main() { } }); - if let Err(why) = client.start().await { + if let Err(why) = bot.start().await { println!("Client error: {why:?}"); } } ``` -### Autoposting with twilight +#### Twilight In your `Cargo.toml`: ```toml [dependencies] # using twilight with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "twilight"] } +topgg = { version = "2", features = ["autoposter", "twilight"] } # using twilight with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "twilight-cached"] } +topgg = { version = "2", features = ["autoposter", "twilight-cached"] } ``` In your code: ```rust,no_run use std::time::Duration; -use topgg::Autoposter; +use topgg::{Autoposter, Client}; use twilight_gateway::{Event, Intents, Shard, ShardId}; #[tokio::main] async fn main() { - let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + let client = Client::new(env!("TOPGG_TOKEN").to_string()); let autoposter = Autoposter::twilight(&client, Duration::from_secs(1800)); let mut shard = Shard::new( @@ -211,7 +180,7 @@ async fn main() { match event { Event::Ready(_) => { - println!("Bot is ready!"); + println!("Bot is now ready!"); }, _ => {} @@ -220,13 +189,57 @@ async fn main() { } ``` -### Writing an [actix-web](https://actix.rs) webhook for listening to votes +### Checking if the weekend vote multiplier is active + +```rust,no_run +let is_weekend = client.is_weekend().await.unwrap(); +``` + +### Generating widget URLs + +#### Large + +```rust,no_run +use topgg::{Widget, WidgetType}; + +let widget_url = Widget::large(WidgetType::DiscordBot, 574652751745777665); +``` + +#### Votes + +```rust,no_run +use topgg::{Widget, WidgetType}; + +let widget_url = Widget::votes(WidgetType::DiscordBot, 574652751745777665); +``` + +#### Owner + +```rust,no_run +use topgg::{Widget, WidgetType}; + +let widget_url = Widget::owner(WidgetType::DiscordBot, 574652751745777665); +``` + +#### Social + +```rust,no_run +use topgg::{Widget, WidgetType}; + +let widget_url = Widget::social(WidgetType::DiscordBot, 574652751745777665); +``` + +### Webhooks + +#### Being notified whenever someone voted for your bot + +##### actix-web In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["actix-web"] } +topgg = { version = "2", default-features = false, features = ["actix-web"] } ``` In your code: @@ -236,57 +249,58 @@ use actix_web::{ error::{Error, ErrorUnauthorized}, get, post, App, HttpServer, }; +use topgg::{Incoming, Vote}; use std::io; -use topgg::IncomingVote; - -#[get("/")] -async fn index() -> &'static str { - "Hello, World!" -} -#[post("/webhook")] -async fn webhook(vote: IncomingVote) -> Result<&'static str, Error> { - match vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { +#[post("/votes")] +async fn voted(vote: Incoming) -> Result<&'static str, Error> { + match vote.authenticate(env!("MY_TOPGG_WEBHOOK_SECRET")) { Some(vote) => { - println!("{:?}", vote); + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); Ok("ok") - } + }, _ => Err(ErrorUnauthorized("401")), } } +#[get("/")] +async fn index() -> &'static str { + "Hello, World!" +} + #[actix_web::main] async fn main() -> io::Result<()> { - HttpServer::new(|| App::new().service(index).service(webhook)) + HttpServer::new(|| App::new().service(index).service(voted)) .bind("127.0.0.1:8080")? .run() .await } ``` -### Writing an axum webhook for listening to votes +##### axum In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["axum"] } +topgg = { version = "2", default-features = false, features = ["axum"] } ``` In your code: ```rust,no_run -use axum::{routing::get, Router, Server}; -use std::{net::SocketAddr, sync::Arc}; -use topgg::{Vote, VoteHandler}; +use axum::{routing::get, Router}; +use topgg::{Vote, Webhook}; +use tokio::net::TcpListener; +use std::sync::Arc; -struct MyVoteHandler {} +struct MyVoteListener {} -#[axum::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); +#[async_trait::async_trait] +impl Webhook for MyVoteListener { + async fn callback(&self, vote: Vote) { + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); } } @@ -296,100 +310,90 @@ async fn index() -> &'static str { #[tokio::main] async fn main() { - let state = Arc::new(MyVoteHandler {}); + let state = Arc::new(MyVoteListener {}); - let app = Router::new().route("/", get(index)).nest( - "/webhook", - topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), + let router = Router::new().route("/", get(index)).nest( + "/votes", + topgg::axum::webhook(env!("MY_TOPGG_WEBHOOK_SECRET").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()) - .await - .unwrap(); + axum::serve(listener, router).await.unwrap(); } ``` -### Writing a rocket webhook for listening to votes +##### rocket In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["rocket"] } +topgg = { version = "2", default-features = false, features = ["rocket"] } ``` In your code: ```rust,no_run -#![feature(decl_macro)] - -use rocket::{get, http::Status, post, routes}; -use topgg::IncomingVote; +use rocket::{get, http::Status, launch, post, routes}; +use topgg::{Incoming, Vote}; -#[get("/")] -fn index() -> &'static str { - "Hello, World!" -} - -#[post("/webhook", data = "")] -fn webhook(vote: IncomingVote) -> Status { - match vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { +#[post("/votes", data = "")] +fn voted(vote: Incoming) -> Status { + match vote.authenticate(env!("MY_TOPGG_WEBHOOK_SECRET")) { Some(vote) => { - println!("{:?}", vote); + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); - Status::Ok + Status::NoContent }, - _ => { - println!("found an unauthorized attacker."); - - Status::Unauthorized - } + _ => Status::Unauthorized, } } -fn main() { - rocket::ignite() - .mount("/", routes![index, webhook]) - .launch(); +#[get("/")] +fn index() -> &'static str { + "Hello, World!" +} + +#[launch] +fn start() -> _ { + rocket::build().mount("/", routes![index, voted]) } ``` -### Writing a warp webhook for listening to votes +##### warp In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["warp"] } +topgg = { version = "2", default-features = false, features = ["warp"] } ``` In your code: ```rust,no_run use std::{net::SocketAddr, sync::Arc}; -use topgg::{Vote, VoteHandler}; +use topgg::{Vote, Webhook}; use warp::Filter; -struct MyVoteHandler {} +struct MyVoteListener {} #[async_trait::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); +impl Webhook for MyVoteListener { + async fn callback(&self, vote: Vote) { + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); } } #[tokio::main] async fn main() { - let state = Arc::new(MyVoteHandler {}); + let state = Arc::new(MyVoteListener {}); - // POST /webhook + // POST /votes let webhook = topgg::warp::webhook( - "webhook", - env!("TOPGG_WEBHOOK_PASSWORD").to_string(), + "votes", + env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), Arc::clone(&state), ); @@ -399,4 +403,4 @@ async fn main() { warp::serve(routes).run(addr).await } -``` +``` \ No newline at end of file diff --git a/src/webhook/actix_web.rs b/src/webhook/actix_web.rs index b9d841c..6851b9f 100644 --- a/src/webhook/actix_web.rs +++ b/src/webhook/actix_web.rs @@ -1,4 +1,4 @@ -use crate::{IncomingVote, Vote}; +use crate::Incoming; use actix_web::{ dev::Payload, error::{Error, ErrorUnauthorized}, @@ -10,15 +10,19 @@ use std::{ pin::Pin, task::{ready, Context, Poll}, }; +use serde::de::DeserializeOwned; #[doc(hidden)] -pub struct IncomingVoteFut { +pub struct IncomingFut { req: HttpRequest, - json_fut: as FromRequest>::Future, + json_fut: as FromRequest>::Future, } -impl Future for IncomingVoteFut { - type Output = Result; +impl Future for IncomingFut +where + T: DeserializeOwned, +{ + type Output = Result, Error>; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { if let Ok(json) = ready!(Pin::new(&mut self.json_fut).poll(cx)) { @@ -26,9 +30,9 @@ impl Future for IncomingVoteFut { if let Some(authorization) = headers.get("Authorization") { if let Ok(authorization) = authorization.to_str() { - return Poll::Ready(Ok(IncomingVote { + return Poll::Ready(Ok(Incoming { authorization: authorization.to_owned(), - vote: json.into_inner(), + data: json.into_inner(), })); } } @@ -39,13 +43,16 @@ impl Future for IncomingVoteFut { } #[cfg_attr(docsrs, doc(cfg(feature = "actix-web")))] -impl FromRequest for IncomingVote { +impl FromRequest for Incoming +where + T: DeserializeOwned, +{ type Error = Error; - type Future = IncomingVoteFut; + type Future = IncomingFut; #[inline(always)] fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { - IncomingVoteFut { + IncomingFut { req: req.clone(), json_fut: Json::from_request(req, payload), } diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs index 99c8d3b..f436cf2 100644 --- a/src/webhook/axum.rs +++ b/src/webhook/axum.rs @@ -1,11 +1,12 @@ -use crate::VoteHandler; +use super::Webhook; use axum::{ extract::State, http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, + response::IntoResponse, routing::post, Router, }; +use serde::de::DeserializeOwned; use std::sync::Arc; struct WebhookState { @@ -23,29 +24,6 @@ impl Clone for WebhookState { } } -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 receiving vote events. /// /// # Examples @@ -53,48 +31,61 @@ where /// 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); +/// use axum::{routing::get, Router}; +/// use topgg::{Vote, Webhook}; +/// use tokio::net::TcpListener; +/// use std::sync::Arc; +/// +/// struct MyVoteListener {} +/// +/// #[async_trait::async_trait] +/// impl Webhook for MyVoteListener { +/// async fn callback(&self, vote: Vote) { +/// 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(MyVoteHandler {}); -/// -/// let app = Router::new().route("/", get(index)).nest( -/// "/webhook", -/// topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), +/// 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 addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); -/// -/// Server::bind(&addr) -/// .serve(app.into_make_service()) -/// .await -/// .unwrap(); +/// +/// 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 +pub fn webhook(password: String, state: Arc) -> Router where - T: VoteHandler, + D: DeserializeOwned + Send, + T: Webhook, { Router::new() - .route("/", post(handler::)) + .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/webhook/mod.rs b/src/webhook/mod.rs index 674de01..8a6f56d 100644 --- a/src/webhook/mod.rs +++ b/src/webhook/mod.rs @@ -23,3 +23,53 @@ cfg_if::cfg_if! { 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); + } + } +} \ No newline at end of file diff --git a/src/webhook/rocket.rs b/src/webhook/rocket.rs index 91f337b..4bc800e 100644 --- a/src/webhook/rocket.rs +++ b/src/webhook/rocket.rs @@ -1,24 +1,28 @@ -use crate::{IncomingVote, Vote}; +use crate::Incoming; 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> FromData<'r> for IncomingVote { +impl<'r, T> FromData<'r> for Incoming +where + T: DeserializeOwned, +{ 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") { - if let Outcome::Success(vote) = as FromData>::from_data(request, data).await { + if let Outcome::Success(data) = as FromData>::from_data(request, data).await { return Outcome::Success(Self { authorization: authorization.to_owned(), - vote: vote.into_inner(), + data: data.into_inner(), }); } } diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index a46f923..51ef6f6 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -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>, @@ -65,14 +53,6 @@ pub struct Vote { #[serde(deserialize_with = "snowflake::deserialize", rename = "user")] pub voter_id: u64, - /// Whether this vote's receiver is a Discord server. - #[serde( - default = "_true", - deserialize_with = "deserialize_is_server", - rename = "bot" - )] - pub is_server: bool, - /// Whether this vote is just a test done from the page settings. #[serde(deserialize_with = "deserialize_is_test", rename = "type")] pub is_test: bool, @@ -84,68 +64,4 @@ pub struct Vote { /// Query strings found on the vote page. #[serde(default, deserialize_with = "deserialize_query_string")] pub query: HashMap, -} - -cfg_if::cfg_if! { - if #[cfg(any(feature = "actix-web", feature = "rocket"))] { - /// An unauthenticated dispatched Top.gg vote event. - #[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. - /// - /// # 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"))] { - /// Vote event handler. - /// - /// 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 { - async fn voted(&self, vote: Vote); - } - } -} +} \ No newline at end of file diff --git a/src/webhook/warp.rs b/src/webhook/warp.rs index 8dca643..c7c96d9 100644 --- a/src/webhook/warp.rs +++ b/src/webhook/warp.rs @@ -1,8 +1,9 @@ -use crate::{Vote, VoteHandler}; +use super::Webhook; use std::sync::Arc; +use serde::de::DeserializeOwned; use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; -/// Creates a new `warp` [`Filter`] for receiving vote events. +/// Creates a new `warp` [`Filter`] for receiving webhook events. /// /// # Examples /// @@ -10,44 +11,45 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// /// ```rust,no_run /// use std::{net::SocketAddr, sync::Arc}; -/// use topgg::{Vote, VoteHandler}; +/// use topgg::{Vote, Webhook}; /// use warp::Filter; -/// -/// struct MyVoteHandler {} -/// +/// +/// struct MyVoteListener {} +/// /// #[async_trait::async_trait] -/// impl VoteHandler for MyVoteHandler { -/// async fn voted(&self, vote: Vote) { -/// println!("{:?}", vote); +/// impl Webhook for MyVoteListener { +/// async fn callback(&self, vote: Vote) { +/// println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); /// } /// } -/// +/// /// #[tokio::main] /// async fn main() { -/// let state = Arc::new(MyVoteHandler {}); -/// -/// // POST /webhook +/// let state = Arc::new(MyVoteListener {}); +/// +/// // POST /votes /// let webhook = topgg::warp::webhook( -/// "webhook", -/// env!("TOPGG_WEBHOOK_PASSWORD").to_string(), +/// "votes", +/// env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), /// Arc::clone(&state), /// ); -/// +/// /// let routes = warp::get().map(|| "Hello, World!").or(webhook); -/// +/// /// let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); -/// +/// /// warp::serve(routes).run(addr).await /// } /// ``` #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] -pub fn webhook( +pub fn webhook( endpoint: &'static str, password: String, state: Arc, ) -> impl Filter + Clone where - T: VoteHandler, + D: DeserializeOwned + Send, + T: Webhook, { let password = Arc::new(password); @@ -55,15 +57,15 @@ where .and(path(endpoint)) .and(header("Authorization")) .and(body::json()) - .then(move |auth: String, vote: Vote| { + .then(move |auth: String, data: D| { let current_state = Arc::clone(&state); let current_password = Arc::clone(&password); async move { if auth == *current_password { - current_state.voted(vote).await; + current_state.callback(data).await; - StatusCode::OK + StatusCode::NO_CONTENT } else { StatusCode::UNAUTHORIZED } From bc9f676ff27f98b64e68c49bbc618100cb27a680 Mon Sep 17 00:00:00 2001 From: null8626 Date: Tue, 24 Jun 2025 18:46:19 +0700 Subject: [PATCH 34/37] refactor: return 400 if json is not valid --- src/webhook/actix_web.rs | 8 ++++--- src/webhook/axum.rs | 45 ++++++++++++++++++++++------------------ src/webhook/mod.rs | 5 ++--- src/webhook/rocket.rs | 9 ++++---- src/webhook/vote.rs | 2 +- src/webhook/warp.rs | 16 +++++++------- 6 files changed, 46 insertions(+), 39 deletions(-) diff --git a/src/webhook/actix_web.rs b/src/webhook/actix_web.rs index 6851b9f..9393140 100644 --- a/src/webhook/actix_web.rs +++ b/src/webhook/actix_web.rs @@ -1,16 +1,16 @@ use crate::Incoming; use actix_web::{ dev::Payload, - error::{Error, ErrorUnauthorized}, + error::{Error, ErrorBadRequest, ErrorUnauthorized}, web::Json, FromRequest, HttpRequest, }; +use serde::de::DeserializeOwned; use std::{ future::Future, pin::Pin, task::{ready, Context, Poll}, }; -use serde::de::DeserializeOwned; #[doc(hidden)] pub struct IncomingFut { @@ -36,9 +36,11 @@ where })); } } + + return Poll::Ready(Err(ErrorUnauthorized("401"))); } - Poll::Ready(Err(ErrorUnauthorized("401"))) + Poll::Ready(Err(ErrorBadRequest("400"))) } } diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs index f436cf2..8b90449 100644 --- a/src/webhook/axum.rs +++ b/src/webhook/axum.rs @@ -35,31 +35,31 @@ impl Clone for WebhookState { /// use topgg::{Vote, Webhook}; /// use tokio::net::TcpListener; /// use std::sync::Arc; -/// +/// /// struct MyVoteListener {} -/// +/// /// #[async_trait::async_trait] /// impl Webhook for MyVoteListener { /// async fn callback(&self, vote: Vote) { /// 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(); /// } /// ``` @@ -71,21 +71,26 @@ where 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(); + .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() - })) + + (StatusCode::UNAUTHORIZED, ()).into_response() + }, + ), + ) .with_state(WebhookState { state, password: Arc::new(password), diff --git a/src/webhook/mod.rs b/src/webhook/mod.rs index 8a6f56d..b029c64 100644 --- a/src/webhook/mod.rs +++ b/src/webhook/mod.rs @@ -46,7 +46,7 @@ cfg_if::cfg_if! { } } } - + impl Clone for Incoming where T: Clone, @@ -62,7 +62,6 @@ cfg_if::cfg_if! { } } - cfg_if::cfg_if! { if #[cfg(any(feature = "axum", feature = "warp"))] { /// Webhook event handler. @@ -72,4 +71,4 @@ cfg_if::cfg_if! { async fn callback(&self, data: T); } } -} \ No newline at end of file +} diff --git a/src/webhook/rocket.rs b/src/webhook/rocket.rs index 4bc800e..0bfb988 100644 --- a/src/webhook/rocket.rs +++ b/src/webhook/rocket.rs @@ -19,12 +19,13 @@ where let headers = request.headers(); if let Some(authorization) = headers.get_one("Authorization") { - if let Outcome::Success(data) = as FromData>::from_data(request, data).await { - return Outcome::Success(Self { + return match as FromData>::from_data(request, data).await { + Outcome::Success(data) => Outcome::Success(Self { authorization: authorization.to_owned(), data: data.into_inner(), - }); - } + }), + _ => Outcome::Error((Status::BadRequest, ())), + }; } Outcome::Error((Status::Unauthorized, ())) diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs index 51ef6f6..412d242 100644 --- a/src/webhook/vote.rs +++ b/src/webhook/vote.rs @@ -64,4 +64,4 @@ pub struct Vote { /// Query strings found on the vote page. #[serde(default, deserialize_with = "deserialize_query_string")] pub query: HashMap, -} \ No newline at end of file +} diff --git a/src/webhook/warp.rs b/src/webhook/warp.rs index c7c96d9..6127086 100644 --- a/src/webhook/warp.rs +++ b/src/webhook/warp.rs @@ -1,6 +1,6 @@ use super::Webhook; -use std::sync::Arc; use serde::de::DeserializeOwned; +use std::sync::Arc; use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// Creates a new `warp` [`Filter`] for receiving webhook events. @@ -13,31 +13,31 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// use std::{net::SocketAddr, sync::Arc}; /// use topgg::{Vote, Webhook}; /// use warp::Filter; -/// +/// /// struct MyVoteListener {} -/// +/// /// #[async_trait::async_trait] /// impl Webhook for MyVoteListener { /// async fn callback(&self, vote: Vote) { /// println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); /// } /// } -/// +/// /// #[tokio::main] /// async fn main() { /// let state = Arc::new(MyVoteListener {}); -/// +/// /// // POST /votes /// let webhook = topgg::warp::webhook( /// "votes", /// env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), /// Arc::clone(&state), /// ); -/// +/// /// let routes = warp::get().map(|| "Hello, World!").or(webhook); -/// +/// /// let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); -/// +/// /// warp::serve(routes).run(addr).await /// } /// ``` From 48a69027c909b0f402b241c7c2978a966c5c95ac Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 26 Jun 2025 12:13:16 +0700 Subject: [PATCH 35/37] feat: rename webhook to webhooks --- Cargo.toml | 10 +++++----- src/lib.rs | 8 ++++---- src/{webhook => webhooks}/actix_web.rs | 0 src/{webhook => webhooks}/axum.rs | 0 src/{webhook => webhooks}/mod.rs | 2 +- src/{webhook => webhooks}/rocket.rs | 0 src/{webhook => webhooks}/vote.rs | 2 +- src/{webhook => webhooks}/warp.rs | 0 8 files changed, 11 insertions(+), 11 deletions(-) rename src/{webhook => webhooks}/actix_web.rs (100%) rename src/{webhook => webhooks}/axum.rs (100%) rename src/{webhook => webhooks}/mod.rs (97%) rename src/{webhook => webhooks}/rocket.rs (100%) rename src/{webhook => webhooks}/vote.rs (97%) rename src/{webhook => webhooks}/warp.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index ec80bf4..c406906 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,8 +70,8 @@ serenity-cached = ["serenity", "serenity/cache"] twilight = ["twilight-model"] twilight-cached = ["twilight", "twilight-cache-inmemory"] -webhook = [] -rocket = ["webhook", "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 +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 diff --git a/src/lib.rs b/src/lib.rs index 4bf77e5..6454fd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,6 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] -#![cfg_attr(feature = "webhook", allow(unreachable_patterns))] +#![cfg_attr(feature = "webhooks", allow(unreachable_patterns))] #![allow(clippy::needless_pass_by_value)] mod snowflake; @@ -47,9 +47,9 @@ cfg_if::cfg_if! { } cfg_if::cfg_if! { - if #[cfg(feature = "webhook")] { - mod webhook; + if #[cfg(feature = "webhooks")] { + mod webhooks; - pub use webhook::*; + pub use webhooks::*; } } diff --git a/src/webhook/actix_web.rs b/src/webhooks/actix_web.rs similarity index 100% rename from src/webhook/actix_web.rs rename to src/webhooks/actix_web.rs diff --git a/src/webhook/axum.rs b/src/webhooks/axum.rs similarity index 100% rename from src/webhook/axum.rs rename to src/webhooks/axum.rs diff --git a/src/webhook/mod.rs b/src/webhooks/mod.rs similarity index 97% rename from src/webhook/mod.rs rename to src/webhooks/mod.rs index b029c64..a5ec591 100644 --- a/src/webhook/mod.rs +++ b/src/webhooks/mod.rs @@ -1,5 +1,5 @@ mod vote; -#[cfg_attr(docsrs, doc(cfg(feature = "webhook")))] +#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] pub use vote::*; #[cfg(feature = "actix-web")] diff --git a/src/webhook/rocket.rs b/src/webhooks/rocket.rs similarity index 100% rename from src/webhook/rocket.rs rename to src/webhooks/rocket.rs diff --git a/src/webhook/vote.rs b/src/webhooks/vote.rs similarity index 97% rename from src/webhook/vote.rs rename to src/webhooks/vote.rs index 412d242..f30f3c1 100644 --- a/src/webhook/vote.rs +++ b/src/webhooks/vote.rs @@ -37,7 +37,7 @@ where ) } -/// A dispatched Top.gg vote event. +/// A dispatched Top.gg vote webhook event. #[must_use] #[derive(Clone, Debug, Deserialize)] pub struct Vote { diff --git a/src/webhook/warp.rs b/src/webhooks/warp.rs similarity index 100% rename from src/webhook/warp.rs rename to src/webhooks/warp.rs From 194581676e16fb4504523d4404042046921a171e Mon Sep 17 00:00:00 2001 From: null8626 Date: Wed, 9 Jul 2025 15:45:26 +0700 Subject: [PATCH 36/37] doc: documentation overhaul --- README.md | 37 +++-- src/autoposter/mod.rs | 222 +++++++++++++++++++++++---- src/autoposter/serenity_impl.rs | 8 +- src/autoposter/twilight_impl.rs | 8 +- src/bot.rs | 205 ++++++++++++------------- src/client.rs | 125 +++++++++++----- src/error.rs | 6 +- src/snowflake.rs | 257 ++++++++++++++++---------------- src/util.rs | 97 +----------- src/voter.rs | 40 ++--- src/webhooks/axum.rs | 4 +- src/webhooks/vote.rs | 2 +- src/webhooks/warp.rs | 6 +- src/widget.rs | 24 +++ 14 files changed, 586 insertions(+), 455 deletions(-) diff --git a/README.md b/README.md index d83f496..a5dae51 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,23 @@ The community-maintained Rust library for Top.gg. +## Chapters + +- [Installation](#installation) +- [Setting up](#setting-up) +- [Usage](#usage) + - [Getting a bot](#getting-a-bot) + - [Getting several bots](#getting-several-bots) + - [Getting your bot's voters](#getting-your-bots-voters) + - [Check if a user has voted for your bot](#check-if-a-user-has-voted-for-your-bot) + - [Getting your bot's server count](#getting-your-bots-server-count) + - [Posting your bot's server count](#posting-your-bots-server-count) + - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-every-few-minutes) + - [Checking if the weekend vote multiplier is active](#checking-if-the-weekend-vote-multiplier-is-active) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + - [Being notified whenever someone voted for your bot](#being-notified-whenever-someone-voted-for-your-bot) + ## Installation In your `Cargo.toml`: @@ -14,9 +31,7 @@ topgg = "2" ## Setting up ```rust,no_run -use topgg::Client; - -let client = Client::new(env!("TOPGG_TOKEN").to_string()); +let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); ``` ## Usage @@ -200,33 +215,25 @@ let is_weekend = client.is_weekend().await.unwrap(); #### Large ```rust,no_run -use topgg::{Widget, WidgetType}; - -let widget_url = Widget::large(WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::Widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); ``` #### Votes ```rust,no_run -use topgg::{Widget, WidgetType}; - -let widget_url = Widget::votes(WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::Widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); ``` #### Owner ```rust,no_run -use topgg::{Widget, WidgetType}; - -let widget_url = Widget::owner(WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::Widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); ``` #### Social ```rust,no_run -use topgg::{Widget, WidgetType}; - -let widget_url = Widget::social(WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::Widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); ``` ### Webhooks diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs index 5d1ca95..7554752 100644 --- a/src/autoposter/mod.rs +++ b/src/autoposter/mod.rs @@ -1,7 +1,7 @@ use crate::Result; use std::{ops::Deref, sync::Arc, time::Duration}; use tokio::{ - sync::{mpsc, RwLock}, + sync::{mpsc, RwLock, RwLockReadGuard}, task::{spawn, JoinHandle}, time::sleep, }; @@ -29,17 +29,108 @@ cfg_if::cfg_if! { } } -/// Handle events from third-party bot libraries. +/// 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. -pub trait Handler: Send + Sync + 'static { - /// Borrows the instance to the [`Autoposter`]. - fn server_count(&self) -> &RwLock; +pub trait Handler<'a>: Send + Sync + 'static { + /// Read-only `RwLock` guard containing the bot's latest server count. + fn server_count(&'a self) -> RwLockReadGuard<'a, usize>; } -/// Automate the process of posting your bot's server count to the API. +/// Automatically update the server count in your Discord bot's Top.gg page every few minutes. /// -/// **NOTE**: This struct owns the thread that does the autoposting. It will stop once it gets dropped. +/// **NOTE**: This struct owns the 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::Autoposter; +/// +/// struct Handler; +/// +/// #[serenity::async_trait] +/// impl EventHandler for Handler { +/// 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 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(Handler) +/// .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:?}"); +/// } +/// } +/// ``` +/// +/// Twilight: +/// +/// ```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!("DISCORD_TOKEN").to_string(), +/// Intents::GUILD_MEMBERS | 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!"); +/// }, +/// +/// _ => {} +/// } +/// } +/// } +/// ``` #[must_use] pub struct Autoposter { handler: Arc, @@ -47,15 +138,11 @@ pub struct Autoposter { receiver: Option>>, } -impl Autoposter +impl<'a, H> Autoposter where - H: Handler, + H: Handler<'a>, { - /// Creates an autoposter instance and immediately starts up the thread. - /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or an API token ([`&str`][std::str]). - /// - `handler` is any struct that gives out server count information. - /// - `interval` is the interval between posting. Defaults to 15 minutes. + /// Creates and starts an autoposter thread. pub fn new(client: &C, handler: H, mut interval: Duration) -> Self where C: AsClient, @@ -73,7 +160,7 @@ where thread: spawn(async move { loop { { - let server_count = handler.server_count().read().await; + let server_count = handler.server_count().await; if sender .send( @@ -95,19 +182,19 @@ where } } - /// Retrieves this autoposter's handler. + /// This autoposter's handler. #[inline(always)] pub fn handler(&self) -> Arc { Arc::clone(&self.handler) } - /// Returns a future that resolves every time the autoposter posts your bot's server count. The value contained inside is the server count that was just posted. + /// 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. /// - /// If you want to use the receiver directly, call [`receiver`][Autoposter::receiver]. + /// **NOTE**: If you want to use the receiver directly, call [`receiver`][Autoposter::receiver]. /// /// # Panics /// - /// Subsequent calls to this method after [`receiver`][Autoposter::receiver] is called. + /// Panics if this method gets called again after [`receiver`][Autoposter::receiver] is called. #[inline(always)] pub async fn recv(&mut self) -> Option> { self.receiver.as_mut().expect("The receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await @@ -117,7 +204,7 @@ where /// /// # Panics /// - /// Subsequent calls to this method. + /// Panics if this method gets called for the second time. #[inline(always)] pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { self @@ -139,10 +226,53 @@ impl Deref for Autoposter { #[cfg(feature = "serenity")] #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] impl Autoposter { - /// Creates a serenity-based autoposter instance and immediately starts up the thread. + /// Creates and starts a serenity-based autoposter thread. + /// + /// # Example + /// + /// ```rust,no_run + /// use std::time::Duration; + /// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; + /// use topgg::Autoposter; + /// + /// struct Handler; + /// + /// #[serenity::async_trait] + /// impl EventHandler for Handler { + /// 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 autoposter = Autoposter::serenity(&client, Duration::from_secs(1800)); + /// + /// let bot_token = env!("BOT_TOKEN").to_string(); + /// let intents = GatewayIntents::GUILDS; /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or an API token ([`&str`][std::str]). - /// - `interval` is the interval between posting. Defaults to 15 minutes. + /// let mut bot = Client::builder(&bot_token, intents) + /// .event_handler(Handler) + /// .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 @@ -155,10 +285,50 @@ impl Autoposter { #[cfg(feature = "twilight")] #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] impl Autoposter { - /// Creates a twilight-based autoposter instance and immediately starts up the thread. + /// Creates and starts a twilight-based autoposter thread. + /// + /// # Example + /// + /// ```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!("DISCORD_TOKEN").to_string(), + /// Intents::GUILD_MEMBERS | 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!"); + /// }, /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or an API token ([`&str`][std::str]). - /// - `interval` is the interval between posting. Defaults to 15 minutes. + /// _ => {} + /// } + /// } + /// } + /// ``` #[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 b2d1dad..25160f5 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/autoposter/serenity_impl.rs @@ -8,7 +8,7 @@ use serenity::{ id::GuildId, }, }; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, RwLockReadGuard}; cfg_if::cfg_if! { if #[cfg(not(feature = "serenity-cached"))] { @@ -192,9 +192,9 @@ serenity_handler! { } } -impl Handler for Serenity { +impl<'a> Handler<'a> for Serenity { #[inline(always)] - fn server_count(&self) -> &RwLock { - &self.server_count + fn server_count(&'a self) -> RwLockReadGuard<'a, usize> { + self.server_count.read() } } diff --git a/src/autoposter/twilight_impl.rs b/src/autoposter/twilight_impl.rs index 4370bff..cf559a1 100644 --- a/src/autoposter/twilight_impl.rs +++ b/src/autoposter/twilight_impl.rs @@ -1,6 +1,6 @@ use crate::autoposter::Handler; use std::collections::HashSet; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::{Mutex, RwLock, RwLockReadGuard}; use twilight_model::gateway::event::Event; /// Autoposter handler for working with the twilight. @@ -55,9 +55,9 @@ impl Twilight { } } -impl Handler for Twilight { +impl<'a> Handler<'a> for Twilight { #[inline(always)] - fn server_count(&self) -> &RwLock { - &self.server_count + fn server_count(&'a self) -> RwLockReadGuard<'a, usize> { + self.server_count.read() } } diff --git a/src/bot.rs b/src/bot.rs index 46ad912..9e33e36 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -9,116 +9,99 @@ use std::{ pin::Pin, }; -util::debug_struct! { - /// A Discord bot's reviews on Top.gg. - #[must_use] - #[derive(Clone, Deserialize)] - BotReviews { - public { - /// This bot's average review score out of 5. - #[serde(rename = "averageScore")] - score: f64, - - /// This bot's review count. - count: usize, - } - } +/// 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, } -util::debug_struct! { - /// A Discord bot listed on Top.gg. - #[must_use] - #[derive(Clone, Deserialize)] - Bot { - public { - /// This bot's Discord ID. - #[serde(rename = "clientid", deserialize_with = "snowflake::deserialize")] - id: u64, - - /// This bot's Top.gg ID. - #[serde(rename = "id", deserialize_with = "snowflake::deserialize")] - topgg_id: u64, - - /// This bot's username. - #[serde(rename = "username")] - name: String, - - /// This bot's prefix. - prefix: String, - - /// This bot's short description. - #[serde(rename = "shortdesc")] - short_description: String, - - /// This bot's long description. It can contain HTML and/or Markdown. - #[serde( - default, - deserialize_with = "util::deserialize_optional_string", - rename = "longdesc" - )] - long_description: Option, - - /// This bot's tags. - #[serde(deserialize_with = "util::deserialize_default")] - tags: Vec, - - /// This bot's website URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - website: Option, - - /// This bot's GitHub repository URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - github: Option, - - /// This bot's owner IDs. - #[serde(deserialize_with = "snowflake::deserialize_vec")] - owners: Vec, - - /// This bot's submission date. - #[serde(rename = "date")] - submitted_at: DateTime, - - /// The amount of votes this bot has. - #[serde(rename = "points")] - votes: usize, - - /// The amount of votes this bot has this month. - #[serde(rename = "monthlyPoints")] - monthly_votes: usize, - - /// This bot's support URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - support: Option, - - /// This bot's avatar URL. - avatar: String, - - /// This bot's invite URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - invite: Option, - - /// This bot's Top.gg vanity code. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - vanity: Option, - - /// This bot's posted server count. - #[serde(default)] - server_count: Option, - - /// This bot's reviews. - #[serde(rename = "reviews")] - review: BotReviews, - } - - getters(self) { - /// This bot's creation date. - #[must_use] - #[inline(always)] - created_at: DateTime => { - util::get_creation_date(self.id) - } - } - } +/// 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, } #[derive(Serialize, Deserialize)] @@ -137,7 +120,7 @@ pub(crate) struct IsWeekend { pub(crate) is_weekend: bool, } -/// Configure a Discord bot query before sending it to the API. +/// Query for [`Client::get_bots`]. #[must_use] pub struct BotQuery<'a> { client: &'a Client, @@ -193,10 +176,10 @@ impl<'a> BotQuery<'a> { } get_bots_method! { - /// Sets the maximum amount of bots to be queried. This cannot be more than 500. + /// Sets the maximum amount of bots to be returned. limit: u16 = query(limit, min(limit, 500).to_string()); - /// Sets the amount of bots to be skipped. This cannot be more than 499. + /// Sets the amount of bots to be skipped. skip: u16 = query(offset, min(skip, 499).to_string()); } } diff --git a/src/client.rs b/src/client.rs index 9ae77fe..49215f7 100644 --- a/src/client.rs +++ b/src/client.rs @@ -37,7 +37,6 @@ macro_rules! api { pub(crate) use api; -#[derive(Debug)] pub struct InnerClient { http: reqwest::Client, token: String, @@ -139,7 +138,6 @@ impl InnerClient { /// Interact with the API's endpoints. #[must_use] -#[derive(Debug)] pub struct Client { inner: SyncedClient, } @@ -147,11 +145,17 @@ pub struct Client { impl Client { /// Creates a new instance. /// - /// To retrieve your API token, [see this tutorial](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). + /// To retrieve your API token, [see this tutorial](https://github.com/top-gg-community/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). /// /// # Panics /// - /// - The client uses an invalid API token. + /// 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); @@ -166,15 +170,23 @@ impl Client { /// /// # Panics /// - /// - The provided ID is not numeric. + /// Panics if: + /// - The specified ID is invalid. /// - The client uses an invalid API token. /// /// # Errors /// + /// Returns [`Err`] if: /// - The specified bot does not exist. ([`NotFound`][crate::Error::NotFound]) - /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - 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, @@ -189,13 +201,20 @@ impl Client { /// /// # Panics /// - /// The client uses an invalid API token. + /// Panics if the client uses an invalid API token. /// /// # Errors /// - /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// 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_server_count().await.unwrap(); + /// ``` pub async fn get_server_count(&self) -> Result> { self .inner @@ -204,36 +223,55 @@ impl Client { .map(|stats: Stats| stats.server_count) } - /// Posts your Discord bot's server count to the API. This will update the server count in your bot's Top.gg page. + /// Updates the server count in your Discord bot's Top.gg page. /// /// # Panics /// - /// The client uses an invalid API token. + /// Panics if the client uses an invalid API token. /// /// # Errors /// + /// Returns [`Err`] if: /// - The bot is currently in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) - /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - 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_server_count(bot.server_count()).await.unwrap(); + /// ``` #[inline(always)] pub async fn post_server_count(&self, server_count: usize) -> Result<()> { self.inner.post_server_count(server_count).await } - /// Fetches your Discord bot's recent 100 unique voters. + /// Fetches your Discord bot'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 /// - /// The client uses an invalid API token. + /// Panics if the client uses an invalid API token. /// /// # Errors /// - /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// 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; @@ -257,36 +295,32 @@ impl Client { .map(|res| res.results) } - /// Returns a [`BotQuery`] instance that allows you to configure a bot query before sending it to the API. + /// Fetches Discord bots that matches the specified query. /// /// # Panics /// - /// The client uses an invalid API token. + /// Panics if the client uses an invalid API token. /// /// # Errors /// - /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// 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]) /// - /// # Examples - /// - /// Basic usage: + /// # Example /// /// ```rust,no_run - /// use topgg::{Client, BotQuery}; - /// - /// let client = 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); + /// println!("{}", bot.name); /// } /// ``` #[inline(always)] @@ -294,19 +328,27 @@ impl Client { BotQuery::new(self) } - /// Checks if the specified Discord user has voted your Discord bot. + /// Checks if a Discord user has voted for your Discord bot in the past 12 hours. /// /// # Panics /// - /// - The provided ID is not numeric. + /// Panics if: + /// - The specified ID is invalid. /// - The client uses an invalid API token. /// /// # Errors /// + /// Returns [`Err`] if: /// - The specified user has not logged in to Top.gg. ([`NotFound`][crate::Error::NotFound]) - /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - 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(661200758510977084).await.unwrap(); + /// ``` pub async fn has_voted(&self, user_id: I) -> Result where I: Snowflake, @@ -326,13 +368,20 @@ impl Client { /// /// # Panics /// - /// The client uses an invalid API token. + /// Panics if the client uses an invalid API token. /// /// # Errors /// - /// - An unexpected client-side error has occurred. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected server-side error has occurred. ([`InternalServerError`][crate::Error::InternalServerError]) + /// 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 0caba82..6e5fa4c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,10 +3,10 @@ use std::{error, fmt, result}; /// An error coming from this SDK. #[derive(Debug)] pub enum Error { - /// An unexpected client-side error has occurred. + /// HTTP request failure from the client-side. InternalClientError(reqwest::Error), - /// An unexpected server-side error has occurred. + /// HTTP request failure from the server-side. InternalServerError, /// Attempted to send an invalid request to the API. @@ -17,7 +17,7 @@ pub enum Error { /// Ratelimited from sending more requests. Ratelimit { - /// How long the client should wait (in seconds) before it can make a request to the API again. + /// How long the client should wait in seconds before it could send requests again without receiving a 429. retry_after: u16, }, } diff --git a/src/snowflake.rs b/src/snowflake.rs index ecc2796..af47d6f 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -8,151 +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()) -} - -/// Any datatype 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_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); - )+} - ); + #[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_topgg_idstruct!( - crate::bot::Bot, - crate::voter::Voter - ); - } -} + /// Any datatype that can be interpreted as a Discord ID. + pub trait Snowflake { + /// Converts this value to a [`u64`]. + fn as_snowflake(&self) -> u64; + } -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() + 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!( - #[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() - ); + impl_snowflake!(self, u64, *self); - macro_rules! impl_serenity_id( + macro_rules! impl_string( ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, self.get()); + impl_snowflake!(self, $t, self.parse().expect("Invalid snowflake as it's not numeric.")); )+} ); - impl_serenity_id!( - serenity::model::id::GenericId, - serenity::model::id::UserId - ); + impl_string!(&str, String); - macro_rules! impl_serenity_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, self.id.get()); - )+} - ); + cfg_if::cfg_if! { + if #[cfg(feature = "api")] { + macro_rules! impl_topgg_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(self, &$t, self.id); + )+} + ); - 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_serenity_cacheref!( - serenity::cache::UserRef<'_>, - serenity::cache::MemberRef<'_>, - serenity::cache::CurrentUserRef<'_> - ); - } -} - -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::voter::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::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() + ); + } + } } } diff --git a/src/util.rs b/src/util.rs index 2c45794..b45b274 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,82 +1,8 @@ use crate::{snowflake, Error}; use base64::Engine; -use chrono::{DateTime, TimeZone, Utc}; use reqwest::Response; use serde::{de::DeserializeOwned, Deserialize, Deserializer}; -const DISCORD_EPOCH: u64 = 1_420_070_400_000; - -macro_rules! debug_struct { - ( - $(#[$struct_attr:meta])* - $struct_name:ident { - $(public { - $( - $(#[$pub_prop_attr:meta])* - $pub_prop_name:ident: $pub_prop_type:ty, - )* - })? - $(protected { - $( - $(#[$protected_prop_attr:meta])* - $protected_prop_name:ident: $protected_prop_type:ty, - )* - })? - $(private { - $( - $(#[$priv_prop_attr:meta])* - $priv_prop_name:ident: $priv_prop_type:ty, - )* - })? - $(getters($self:ident) { - $( - $(#[$getter_attr:meta])* - $getter_name:ident: $getter_type:ty => $getter_code:tt - )* - })? - } - ) => { - $(#[$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, @@ -84,16 +10,11 @@ pub(crate) fn deserialize_optional_string<'de, D>( where D: Deserializer<'de>, { - Ok(match String::deserialize(deserializer) { - Ok(s) => { - if s.is_empty() { - None - } else { - Some(s) - } - } - _ => None, - }) + Ok( + String::deserialize(deserializer) + .ok() + .filter(|s| !s.is_empty()), + ) } #[inline(always)] @@ -105,14 +26,6 @@ where Option::deserialize(deserializer).map(Option::unwrap_or_default) } -#[inline(always)] -pub(crate) fn get_creation_date(id: u64) -> DateTime { - Utc - .timestamp_millis_opt(((id >> 22) + DISCORD_EPOCH) as _) - .single() - .unwrap() -} - #[inline(always)] pub(crate) async fn parse_json(response: Response) -> crate::Result where diff --git a/src/voter.rs b/src/voter.rs index e26ae1c..e8fffa9 100644 --- a/src/voter.rs +++ b/src/voter.rs @@ -1,5 +1,4 @@ -use crate::{snowflake, util}; -use chrono::{DateTime, Utc}; +use crate::snowflake; use serde::Deserialize; #[derive(Deserialize)] @@ -7,31 +6,18 @@ pub(crate) struct Voted { pub(crate) voted: u8, } -util::debug_struct! { - /// A Top.gg voter. - #[must_use] - #[derive(Clone, Deserialize)] - Voter { - public { - /// This voter's Discord ID. - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, +/// A Top.gg voter. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct Voter { + /// This voter's Discord ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub id: u64, - /// This voter's username. - #[serde(rename = "username")] - name: String, + /// This voter's username. + #[serde(rename = "username")] + pub name: String, - /// This voter's avatar URL. - avatar: String, - } - - getters(self) { - /// This voter's creation date. - #[must_use] - #[inline(always)] - created_at: DateTime => { - util::get_creation_date(self.id) - } - } - } + /// This voter's avatar URL. + pub avatar: String, } diff --git a/src/webhooks/axum.rs b/src/webhooks/axum.rs index 8b90449..cd0dd5e 100644 --- a/src/webhooks/axum.rs +++ b/src/webhooks/axum.rs @@ -26,9 +26,7 @@ impl Clone for WebhookState { /// Creates a new axum [`Router`] for receiving vote events. /// -/// # Examples -/// -/// Basic usage: +/// # Example /// /// ```rust,no_run /// use axum::{routing::get, Router}; diff --git a/src/webhooks/vote.rs b/src/webhooks/vote.rs index f30f3c1..412d242 100644 --- a/src/webhooks/vote.rs +++ b/src/webhooks/vote.rs @@ -37,7 +37,7 @@ where ) } -/// A dispatched Top.gg vote webhook event. +/// A dispatched Top.gg vote event. #[must_use] #[derive(Clone, Debug, Deserialize)] pub struct Vote { diff --git a/src/webhooks/warp.rs b/src/webhooks/warp.rs index 6127086..c8b5dcc 100644 --- a/src/webhooks/warp.rs +++ b/src/webhooks/warp.rs @@ -3,11 +3,9 @@ use serde::de::DeserializeOwned; use std::sync::Arc; use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; -/// Creates a new `warp` [`Filter`] for receiving webhook events. +/// Creates a new warp [`Filter`] for receiving webhook events. /// -/// # Examples -/// -/// Basic usage: +/// # Example /// /// ```rust,no_run /// use std::{net::SocketAddr, sync::Arc}; diff --git a/src/widget.rs b/src/widget.rs index a22b625..4bd1dc8 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -17,6 +17,12 @@ impl WidgetType { } /// Generates a large widget URL. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::Widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` #[inline(always)] pub fn large(ty: WidgetType, id: I) -> String where @@ -26,6 +32,12 @@ where } /// Generates a small widget URL for displaying votes. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::Widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` #[inline(always)] pub fn votes(ty: WidgetType, id: I) -> String where @@ -39,6 +51,12 @@ where } /// Generates a small widget URL for displaying an entity's owner. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::Widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` #[inline(always)] pub fn owner(ty: WidgetType, id: I) -> String where @@ -52,6 +70,12 @@ where } /// Generates a small widget URL for displaying social stats. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::Widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` #[inline(always)] pub fn social(ty: WidgetType, id: I) -> String where From f34f8ae28eb30233feff1c4a71b9c25119171958 Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 11 Sep 2025 22:43:13 +0700 Subject: [PATCH 37/37] feat: adapt to v1 --- Cargo.toml | 17 +- README.md | 116 ++++--- src/bot.rs | 18 +- src/{autoposter => bot_autoposter}/client.rs | 0 src/{autoposter => bot_autoposter}/mod.rs | 124 ++++---- .../serenity_impl.rs | 16 +- .../twilight_impl.rs | 18 +- src/client.rs | 198 ++++++++++-- src/error.rs | 58 +++- src/lib.rs | 41 ++- src/project.rs | 73 +++++ src/snowflake.rs | 34 ++- src/test.rs | 287 +++++++++++++++++- src/util.rs | 6 +- src/vote.rs | 46 +++ src/voter.rs | 23 -- src/webhooks/axum.rs | 6 +- src/webhooks/vote.rs | 4 +- src/webhooks/warp.rs | 6 +- src/widget.rs | 18 +- 20 files changed, 881 insertions(+), 228 deletions(-) rename src/{autoposter => bot_autoposter}/client.rs (100%) rename src/{autoposter => bot_autoposter}/mod.rs (71%) rename src/{autoposter => bot_autoposter}/serenity_impl.rs (93%) rename src/{autoposter => bot_autoposter}/twilight_impl.rs (77%) create mode 100644 src/project.rs create mode 100644 src/vote.rs delete mode 100644 src/voter.rs diff --git a/Cargo.toml b/Cargo.toml index c406906..3cfece0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,13 @@ serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } urlencoding = "2" -serenity = { version = ">=0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } +serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } -twilight-model = { version = ">=0.16", optional = true } -twilight-cache-inmemory = { version = ">=0.16", 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"] } +chrono = { version = "0.4", default-features = false, optional = true, features = ["serde", "now"] } serde_json = { version = "1", optional = true } rocket = { version = "0.5", default-features = false, features = ["json"], optional = true } @@ -36,6 +37,7 @@ 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 } @@ -61,13 +63,14 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["base64", "chrono", "reqwest", "serde_json"] -autoposter = ["api", "tokio"] +api = ["async-trait", "base64", "chrono", "reqwest", "serde_json"] +bot-autoposter = ["api", "tokio"] +autoposter = ["bot-autoposter"] serenity = ["dep:serenity", "paste"] serenity-cached = ["serenity", "serenity/cache"] -twilight = ["twilight-model"] +twilight = ["twilight-model", "twilight-http"] twilight-cached = ["twilight", "twilight-cache-inmemory"] webhooks = [] diff --git a/README.md b/README.md index a5dae51..3b45643 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,16 @@ The community-maintained Rust library for Top.gg. - [Usage](#usage) - [Getting a bot](#getting-a-bot) - [Getting several bots](#getting-several-bots) - - [Getting your bot's voters](#getting-your-bots-voters) - - [Check if a user has voted for your bot](#check-if-a-user-has-voted-for-your-bot) + - [Getting your project's voters](#getting-your-projects-voters) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) - [Getting your bot's server count](#getting-your-bots-server-count) - [Posting your bot's server count](#posting-your-bots-server-count) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-every-few-minutes) - [Checking if the weekend vote multiplier is active](#checking-if-the-weekend-vote-multiplier-is-active) - [Generating widget URLs](#generating-widget-urls) - [Webhooks](#webhooks) - - [Being notified whenever someone voted for your bot](#being-notified-whenever-someone-voted-for-your-bot) + - [Being notified whenever someone voted for your project](#being-notified-whenever-someone-voted-for-your-project) ## Installation @@ -58,7 +59,7 @@ for bot in bots { } ``` -### Getting your bot's voters +### Getting your project's voters ```rust,no_run // Page number @@ -69,22 +70,59 @@ for voter in voters { } ``` -### Check if a user has voted for your bot +### Getting your project's vote information of a user + +#### Discord ID + +```rust,no_run +use topgg::UserSource; + +let vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); +``` + +#### Top.gg ID ```rust,no_run -let has_voted = client.has_voted(661200758510977084).await.unwrap(); +use topgg::UserSource; + +let vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); ``` ### Getting your bot's server count ```rust,no_run -let server_count = client.get_server_count().await.unwrap(); +let server_count = client.get_bot_server_count().await.unwrap(); ``` ### Posting your bot's server count ```rust,no_run -client.post_server_count(bot.server_count()).await.unwrap(); +client.post_bot_server_count(bot.server_count()).await.unwrap(); +``` + +### Posting your bot's application commands list + +#### Serenity + +```rust,no_run +client.post_bot_commands(&ctx).await.unwrap(); +``` + +#### Twilight + +```rust,no_run +let application_id = bot.current_user_application().await.unwrap().model().await.unwrap().id; +let interaction = bot.interaction(application_id); + +client.post_bot_commands(interaction.global_commands()).await.unwrap(); +``` + +#### Others + +```rust,no_run +let commands = vec![...]; // Array of application commands that + // can be serialized to Discord API's raw JSON format. +client.post_bot_commands(commands).await.unwrap(); ``` ### Automatically posting your bot's server count every few minutes @@ -96,10 +134,10 @@ In your `Cargo.toml`: ```toml [dependencies] # using serenity with guild caching disabled -topgg = { version = "2", features = ["autoposter", "serenity"] } +topgg = { version = "2", features = ["bot-autoposter", "serenity"] } # using serenity with guild caching enabled -topgg = { version = "2", features = ["autoposter", "serenity-cached"] } +topgg = { version = "2", features = ["bot-autoposter", "serenity-cached"] } ``` In your code: @@ -107,12 +145,12 @@ In your code: ```rust,no_run use std::time::Duration; use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; -use topgg::Autoposter; +use topgg::BotAutoposter; -struct Handler; +struct BotAutoposterHandler; #[serenity::async_trait] -impl EventHandler for Handler { +impl EventHandler for BotAutoposterHandler { async fn ready(&self, _: Context, ready: Ready) { println!("{} is now ready!", ready.user.name); } @@ -123,18 +161,18 @@ 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 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(Handler) - .event_handler_arc(autoposter.handler()) + .event_handler(BotAutoposterHandler) + .event_handler_arc(bot_autoposter.handler()) .await .unwrap(); - let mut receiver = autoposter.receiver(); + let mut receiver = bot_autoposter.receiver(); tokio::spawn(async move { while let Some(result) = receiver.recv().await { @@ -155,28 +193,28 @@ In your `Cargo.toml`: ```toml [dependencies] # using twilight with guild caching disabled -topgg = { version = "2", features = ["autoposter", "twilight"] } +topgg = { version = "2", features = ["bot-autoposter", "twilight"] } # using twilight with guild caching enabled -topgg = { version = "2", features = ["autoposter", "twilight-cached"] } +topgg = { version = "2", features = ["bot-autoposter", "twilight-cached"] } ``` In your code: ```rust,no_run use std::time::Duration; -use topgg::{Autoposter, Client}; +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 autoposter = Autoposter::twilight(&client, Duration::from_secs(1800)); + let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); let mut shard = Shard::new( ShardId::ONE, - env!("DISCORD_TOKEN").to_string(), - Intents::GUILD_MEMBERS | Intents::GUILDS, + env!("BOT_TOKEN").to_string(), + Intents::GUILD_MESSAGES | Intents::GUILDS, ); loop { @@ -191,7 +229,7 @@ async fn main() { } }; - autoposter.handle(&event).await; + bot_autoposter.handle(&event).await; match event { Event::Ready(_) => { @@ -215,30 +253,30 @@ let is_weekend = client.is_weekend().await.unwrap(); #### Large ```rust,no_run -let widget_url = topgg::Widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); ``` #### Votes ```rust,no_run -let widget_url = topgg::Widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); ``` #### Owner ```rust,no_run -let widget_url = topgg::Widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); ``` #### Social ```rust,no_run -let widget_url = topgg::Widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); ``` ### Webhooks -#### Being notified whenever someone voted for your bot +#### Being notified whenever someone voted for your project ##### actix-web @@ -256,11 +294,11 @@ use actix_web::{ error::{Error, ErrorUnauthorized}, get, post, App, HttpServer, }; -use topgg::{Incoming, Vote}; +use topgg::{Incoming, VoteEvent}; use std::io; #[post("/votes")] -async fn voted(vote: Incoming) -> Result<&'static str, Error> { +async fn voted(vote: Incoming) -> Result<&'static str, Error> { match vote.authenticate(env!("MY_TOPGG_WEBHOOK_SECRET")) { Some(vote) => { println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); @@ -298,15 +336,15 @@ In your code: ```rust,no_run use axum::{routing::get, Router}; -use topgg::{Vote, Webhook}; +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: Vote) { +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); } } @@ -343,10 +381,10 @@ In your code: ```rust,no_run use rocket::{get, http::Status, launch, post, routes}; -use topgg::{Incoming, Vote}; +use topgg::{Incoming, VoteEvent}; #[post("/votes", data = "")] -fn voted(vote: Incoming) -> Status { +fn voted(vote: Incoming) -> Status { match vote.authenticate(env!("MY_TOPGG_WEBHOOK_SECRET")) { Some(vote) => { println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); @@ -381,14 +419,14 @@ In your code: ```rust,no_run use std::{net::SocketAddr, sync::Arc}; -use topgg::{Vote, Webhook}; +use topgg::{VoteEvent, Webhook}; use warp::Filter; struct MyVoteListener {} #[async_trait::async_trait] -impl Webhook for MyVoteListener { - async fn callback(&self, vote: Vote) { +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); } } diff --git a/src/bot.rs b/src/bot.rs index 9e33e36..e83c0ed 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,4 +1,4 @@ -use crate::{snowflake, util, Client}; +use crate::{snowflake, util, Client, Reviews}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{ @@ -9,18 +9,6 @@ use std::{ 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, -} - /// A Discord bot listed on Top.gg. #[must_use] #[derive(Clone, Debug, Deserialize)] @@ -101,11 +89,11 @@ pub struct Bot { /// This bot's reviews. #[serde(rename = "reviews")] - pub review: BotReviews, + pub review: Reviews, } #[derive(Serialize, Deserialize)] -pub(crate) struct Stats { +pub(crate) struct BotStats { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) server_count: Option, } diff --git a/src/autoposter/client.rs b/src/bot_autoposter/client.rs similarity index 100% rename from src/autoposter/client.rs rename to src/bot_autoposter/client.rs diff --git a/src/autoposter/mod.rs b/src/bot_autoposter/mod.rs similarity index 71% rename from src/autoposter/mod.rs rename to src/bot_autoposter/mod.rs index 7554752..a761313 100644 --- a/src/autoposter/mod.rs +++ b/src/bot_autoposter/mod.rs @@ -1,7 +1,7 @@ use crate::Result; use std::{ops::Deref, sync::Arc, time::Duration}; use tokio::{ - sync::{mpsc, RwLock, RwLockReadGuard}, + sync::mpsc, task::{spawn, JoinHandle}, time::sleep, }; @@ -32,14 +32,15 @@ cfg_if::cfg_if! { /// 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. -pub trait Handler<'a>: Send + Sync + 'static { - /// Read-only `RwLock` guard containing the bot's latest server count. - fn server_count(&'a self) -> RwLockReadGuard<'a, usize>; +#[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 autoposter thread which means that it will stop once it gets dropped. +/// **NOTE**: This struct owns the Discord bot autoposter thread which means that it will stop once it gets dropped. /// /// # Examples /// @@ -48,12 +49,12 @@ pub trait Handler<'a>: Send + Sync + 'static { /// ```rust,no_run /// use std::time::Duration; /// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; -/// use topgg::Autoposter; +/// use topgg::BotAutoposter; /// -/// struct Handler; +/// struct BotAutoposterHandler; /// /// #[serenity::async_trait] -/// impl EventHandler for Handler { +/// impl EventHandler for BotAutoposterHandler { /// async fn ready(&self, _: Context, ready: Ready) { /// println!("{} is now ready!", ready.user.name); /// } @@ -64,18 +65,18 @@ pub trait Handler<'a>: Send + Sync + 'static { /// 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 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(Handler) -/// .event_handler_arc(autoposter.handler()) +/// .event_handler(BotAutoposterHandler) +/// .event_handler_arc(bot_autoposter.handler()) /// .await /// .unwrap(); /// -/// let mut receiver = autoposter.receiver(); +/// let mut receiver = bot_autoposter.receiver(); /// /// tokio::spawn(async move { /// while let Some(result) = receiver.recv().await { @@ -93,18 +94,18 @@ pub trait Handler<'a>: Send + Sync + 'static { /// /// ```rust,no_run /// use std::time::Duration; -/// use topgg::{Autoposter, Client}; +/// 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 autoposter = Autoposter::twilight(&client, Duration::from_secs(1800)); +/// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); /// /// let mut shard = Shard::new( /// ShardId::ONE, -/// env!("DISCORD_TOKEN").to_string(), -/// Intents::GUILD_MEMBERS | Intents::GUILDS, +/// env!("BOT_TOKEN").to_string(), +/// Intents::GUILD_MESSAGES | Intents::GUILDS, /// ); /// /// loop { @@ -119,7 +120,7 @@ pub trait Handler<'a>: Send + Sync + 'static { /// } /// }; /// -/// autoposter.handle(&event).await; +/// bot_autoposter.handle(&event).await; /// /// match event { /// Event::Ready(_) => { @@ -132,48 +133,55 @@ pub trait Handler<'a>: Send + Sync + 'static { /// } /// ``` #[must_use] -pub struct Autoposter { +pub struct BotAutoposter { handler: Arc, thread: JoinHandle<()>, receiver: Option>>, } -impl<'a, H> Autoposter +impl BotAutoposter where - H: Handler<'a>, + H: BotAutoposterHandler, { - /// Creates and starts an autoposter thread. + /// 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: Arc::clone(&handler), + handler: local_handler, thread: spawn(async move { loop { - { - let server_count = handler.server_count().await; - - if sender - .send( - client - .post_server_count(*server_count) - .await - .map(|_| *server_count), - ) - .is_err() - { - break; + 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; } @@ -182,7 +190,7 @@ where } } - /// This autoposter's handler. + /// This Discord bot autoposter's handler. #[inline(always)] pub fn handler(&self) -> Arc { Arc::clone(&self.handler) @@ -190,17 +198,17 @@ where /// 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`][Autoposter::receiver]. + /// **NOTE**: If you want to use the receiver directly, call [`receiver`][BotAutoposter::receiver]. /// /// # Panics /// - /// Panics if this method gets called again after [`receiver`][Autoposter::receiver] is called. + /// 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`][Autoposter::recv]. + /// Takes the receiver responsible for [`recv`][BotAutoposter::recv]. /// /// # Panics /// @@ -214,7 +222,7 @@ where } } -impl Deref for Autoposter { +impl Deref for BotAutoposter { type Target = H; #[inline(always)] @@ -225,20 +233,20 @@ impl Deref for Autoposter { #[cfg(feature = "serenity")] #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] -impl Autoposter { - /// Creates and starts a serenity-based autoposter thread. +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::Autoposter; + /// use topgg::BotAutoposter; /// - /// struct Handler; + /// struct BotAutoposterHandler; /// /// #[serenity::async_trait] - /// impl EventHandler for Handler { + /// impl EventHandler for BotAutoposterHandler { /// async fn ready(&self, _: Context, ready: Ready) { /// println!("{} is now ready!", ready.user.name); /// } @@ -249,18 +257,18 @@ impl Autoposter { /// 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 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(Handler) - /// .event_handler_arc(autoposter.handler()) + /// .event_handler(BotAutoposterHandler) + /// .event_handler_arc(bot_autoposter.handler()) /// .await /// .unwrap(); /// - /// let mut receiver = autoposter.receiver(); + /// let mut receiver = bot_autoposter.receiver(); /// /// tokio::spawn(async move { /// while let Some(result) = receiver.recv().await { @@ -284,25 +292,25 @@ impl Autoposter { #[cfg(feature = "twilight")] #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] -impl Autoposter { - /// Creates and starts a twilight-based autoposter thread. +impl BotAutoposter { + /// Creates and starts a twilight-based Discord bot autoposter thread. /// /// # Example /// /// ```rust,no_run /// use std::time::Duration; - /// use topgg::{Autoposter, Client}; + /// 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 autoposter = Autoposter::twilight(&client, Duration::from_secs(1800)); + /// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); /// /// let mut shard = Shard::new( /// ShardId::ONE, - /// env!("DISCORD_TOKEN").to_string(), - /// Intents::GUILD_MEMBERS | Intents::GUILDS, + /// env!("BOT_TOKEN").to_string(), + /// Intents::GUILD_MESSAGES | Intents::GUILDS, /// ); /// /// loop { @@ -317,7 +325,7 @@ impl Autoposter { /// } /// }; /// - /// autoposter.handle(&event).await; + /// bot_autoposter.handle(&event).await; /// /// match event { /// Event::Ready(_) => { @@ -338,7 +346,7 @@ impl Autoposter { } } -impl Drop for Autoposter { +impl Drop for BotAutoposter { #[inline(always)] fn drop(&mut self) { self.thread.abort(); diff --git a/src/autoposter/serenity_impl.rs b/src/bot_autoposter/serenity_impl.rs similarity index 93% rename from src/autoposter/serenity_impl.rs rename to src/bot_autoposter/serenity_impl.rs index 25160f5..327344f 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/bot_autoposter/serenity_impl.rs @@ -1,4 +1,4 @@ -use crate::autoposter::Handler; +use crate::bot_autoposter::BotAutoposterHandler; use paste::paste; use serenity::{ client::{Context, EventHandler, FullEvent}, @@ -8,7 +8,7 @@ use serenity::{ id::GuildId, }, }; -use tokio::sync::{RwLock, RwLockReadGuard}; +use tokio::sync::RwLock; cfg_if::cfg_if! { if #[cfg(not(feature = "serenity-cached"))] { @@ -21,7 +21,7 @@ cfg_if::cfg_if! { } } -/// Autoposter handler for working with the serenity library. +/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the serenity library. #[must_use] pub struct Serenity { #[cfg(not(feature = "serenity-cached"))] @@ -192,9 +192,11 @@ serenity_handler! { } } -impl<'a> Handler<'a> for Serenity { - #[inline(always)] - fn server_count(&'a self) -> RwLockReadGuard<'a, usize> { - self.server_count.read() +#[async_trait::async_trait] +impl BotAutoposterHandler for Serenity { + async fn server_count(&self) -> usize { + let guard = self.server_count.read().await; + + *guard } } diff --git a/src/autoposter/twilight_impl.rs b/src/bot_autoposter/twilight_impl.rs similarity index 77% rename from src/autoposter/twilight_impl.rs rename to src/bot_autoposter/twilight_impl.rs index cf559a1..69886ad 100644 --- a/src/autoposter/twilight_impl.rs +++ b/src/bot_autoposter/twilight_impl.rs @@ -1,9 +1,9 @@ -use crate::autoposter::Handler; +use crate::bot_autoposter::BotAutoposterHandler; use std::collections::HashSet; -use tokio::sync::{Mutex, RwLock, RwLockReadGuard}; +use tokio::sync::{Mutex, RwLock}; use twilight_model::gateway::event::Event; -/// Autoposter handler for working with the twilight. +/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the twilight. pub struct Twilight { cache: Mutex>, server_count: RwLock, @@ -33,7 +33,7 @@ impl Twilight { Event::GuildCreate(guild_create) => { let mut cache = self.cache.lock().await; - if cache.insert(guild_create.id().get()) { + if cache.insert(guild_create.id.get()) { let mut server_count = self.server_count.write().await; *server_count = cache.len(); @@ -55,9 +55,11 @@ impl Twilight { } } -impl<'a> Handler<'a> for Twilight { - #[inline(always)] - fn server_count(&'a self) -> RwLockReadGuard<'a, usize> { - self.server_count.read() +#[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 49215f7..db55d95 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,15 +1,18 @@ use crate::{ - bot::{Bot, BotQuery, Bots, IsWeekend, Stats}, + bot::{Bot, BotQuery, BotStats, Bots, IsWeekend}, + error::PostBotCommandsResult, + project::GetBotCommands, + snowflake::UserSource, util, - voter::{Voted, Voter}, - Error, Result, Snowflake, + vote::{Vote, Voted, Voter}, + Error, PostBotCommandsError, Result, Snowflake, }; use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; -use serde::{de::DeserializeOwned, Deserialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; cfg_if::cfg_if! { - if #[cfg(feature = "autoposter")] { - use crate::autoposter; + if #[cfg(feature = "bot-autoposter")] { + use crate::bot_autoposter; use std::sync::Arc; type SyncedClient = Arc; @@ -27,7 +30,7 @@ struct Ratelimit { #[macro_export] macro_rules! api { ($e:literal) => { - concat!("https://top.gg/api/v1", $e) + concat!("https://top.gg/api", $e) }; ($e:literal, $($rest:tt)*) => { @@ -41,17 +44,25 @@ pub struct InnerClient { http: reqwest::Client, token: String, id: u64, + legacy: bool, } -// This is implemented here because autoposter needs to access this struct from a different thread. +#[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. impl InnerClient { pub(crate) fn new(token: String) -> Self { - let id = util::id_from_token(&token); + let (id, legacy) = util::parse_api_token(&token); Self { http: reqwest::Client::new(), token, id, + legacy, } } @@ -62,7 +73,7 @@ impl InnerClient { self .http .request(method, url) - .header(header::AUTHORIZATION, &self.token) + .header(header::AUTHORIZATION, &format!("Bearer {}", self.token)) .header(header::CONNECTION, "close") .header(header::CONTENT_LENGTH, body.len()) .header(header::CONTENT_TYPE, "application/json") @@ -85,7 +96,12 @@ impl InnerClient { } else { Err(match status { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => panic!("Invalid API token."), - StatusCode::NOT_FOUND => Error::NotFound, + StatusCode::NOT_FOUND => Error::NotFound( + util::parse_json::(response) + .await + .ok() + .and_then(|err| err.message), + ), StatusCode::TOO_MANY_REQUESTS => match util::parse_json::(response).await { Ok(ratelimit) => Error::Ratelimit { retry_after: ratelimit.retry_after, @@ -117,7 +133,7 @@ impl InnerClient { } } - pub(crate) async fn post_server_count(&self, server_count: usize) -> Result<()> { + pub(crate) async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { if server_count == 0 { return Err(Error::InvalidRequest); } @@ -126,7 +142,7 @@ impl InnerClient { .send_inner( Method::POST, api!("/bots/stats"), - serde_json::to_vec(&Stats { + serde_json::to_vec(&BotStats { server_count: Some(server_count), }) .unwrap(), @@ -160,7 +176,7 @@ impl Client { pub fn new(token: String) -> Self { let inner = InnerClient::new(token); - #[cfg(feature = "autoposter")] + #[cfg(feature = "bot-autoposter")] let inner = Arc::new(inner); Self { inner } @@ -213,14 +229,14 @@ impl Client { /// # Example /// /// ```rust,no_run - /// let server_count = client.get_server_count().await.unwrap(); + /// let server_count = client.get_bot_server_count().await.unwrap(); /// ``` - pub async fn get_server_count(&self) -> Result> { + pub async fn get_bot_server_count(&self) -> Result> { self .inner .send(Method::GET, api!("/bots/stats"), None) .await - .map(|stats: Stats| stats.server_count) + .map(|stats: BotStats| stats.server_count) } /// Updates the server count in your Discord bot's Top.gg page. @@ -240,14 +256,78 @@ impl Client { /// # Example /// /// ```rust,no_run - /// client.post_server_count(bot.server_count()).await.unwrap(); + /// client.post_bot_server_count(bot.server_count()).await.unwrap(); /// ``` #[inline(always)] - pub async fn post_server_count(&self, server_count: usize) -> Result<()> { - self.inner.post_server_count(server_count).await + pub async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { + self.inner.post_bot_server_count(server_count).await + } + + /// Updates the application commands list in your Discord bot's Top.gg page. + /// + /// # Panics + /// + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - A legacy API token is used. ([`Error::UnsupportedToken`](crate::Error::UnsupportedToken)) + /// - Unable to retrieve the list of bot commands. ([`PostBotCommandsError::Retrieval`][crate::PostBotCommandsError::Retrieval]) + /// - Unable to serialize the list of bot commands. ([`PostBotCommandsError::Serialization`][crate::PostBotCommandsError::Serialization]) + /// - The list of bot commands supplied do not match [Discord API's raw JSON format](https://discord.com/developers/docs/interactions/application-commands#application-command-object). ([`Error::InvalidRequest`][crate::Error::InvalidRequest]) + /// - HTTP request failure from the client-side. ([`Error::InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`Error::InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Error::Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// // Serenity: + /// client.post_bot_commands(&ctx).await.unwrap(); + /// + /// // Twilight: + /// let application_id = bot.current_user_application().await.unwrap().model().await.unwrap().id; + /// let interaction = bot.interaction(application_id); + /// + /// client.post_bot_commands(interaction.global_commands()).await.unwrap(); + /// + /// // Others: + /// let commands = vec![...]; // Array of application commands that + /// // can be serialized to Discord API's raw JSON format. + /// client.post_bot_commands(commands).await.unwrap(); + /// ``` + pub async fn post_bot_commands(&self, context: C) -> PostBotCommandsResult<(), E> + where + L: Serialize + DeserializeOwned, + C: GetBotCommands, + { + if self.inner.legacy { + return Err(PostBotCommandsError::Request(Error::UnsupportedToken)); + } + + let commands = context + .get_bot_commands() + .await + .map_err(PostBotCommandsError::Retrieval)?; + + match self + .inner + .send_inner( + Method::POST, + api!("/v1/projects/@me/commands"), + serde_json::to_vec(&commands).map_err(PostBotCommandsError::Serialization)?, + ) + .await + { + Ok(_) => Ok(()), + Err(err) => Err(PostBotCommandsError::Request(err)), + } } - /// Fetches your Discord bot's recent unique 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. /// @@ -328,7 +408,7 @@ impl Client { BotQuery::new(self) } - /// Checks if a Discord user has voted for your Discord bot in the past 12 hours. + /// Checks if a Top.gg user has voted for your Discord bot in the past 12 hours. /// /// # Panics /// @@ -347,8 +427,12 @@ impl Client { /// # Example /// /// ```rust,no_run - /// let has_voted = client.has_voted(661200758510977084).await.unwrap(); + /// let has_voted = client.has_voted(8226924471638491136).await.unwrap(); /// ``` + #[deprecated( + since = "2.0.0", + note = "Legacy API. Use a v1 API token with `get_vote()` instead." + )] pub async fn has_voted(&self, user_id: I) -> Result where I: Snowflake, @@ -364,6 +448,68 @@ impl Client { .map(|res| res.voted != 0) } + /// Fetches the latest vote information of a user on your project. Returns [`None`] if the user has not voted. + /// + /// # Panics + /// + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - A legacy API token is used. ([`UnsupportedToken`](crate::Error::UnsupportedToken)) + /// - 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 + /// use topgg::UserSource; + /// + /// // Discord ID: + /// let vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); + /// + /// // Top.gg ID: + /// let vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); + /// ``` + pub async fn get_vote(&self, user: UserSource) -> Result> + where + I: Snowflake, + { + if self.inner.legacy { + return Err(Error::UnsupportedToken); + } + + match self + .inner + .send::( + Method::GET, + api!( + "/v1/projects/@me/votes/{}?source={}", + user.as_snowflake(), + user.name() + ), + None, + ) + .await + { + Ok(vote) => Ok(Some(vote)), + Err(err) => { + if let Error::NotFound(Some(message)) = &err { + if message == "User has not voted in the last 12 hours." { + return Ok(None); + } + } + + Err(err) + } + } + } + /// Checks if the weekend multiplier is active, where a single vote counts as two. /// /// # Panics @@ -392,14 +538,14 @@ impl Client { } cfg_if::cfg_if! { - if #[cfg(feature = "autoposter")] { - impl autoposter::AsClientSealed for Client { + if #[cfg(feature = "bot-autoposter")] { + impl bot_autoposter::AsClientSealed for Client { #[inline(always)] fn as_client(&self) -> Arc { Arc::clone(&self.inner) } } - impl autoposter::AsClient for Client {} + impl bot_autoposter::AsClient for Client {} } } diff --git a/src/error.rs b/src/error.rs index 6e5fa4c..4aa759f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use serde_json::Error as SerdeJsonError; use std::{error, fmt, result}; /// An error coming from this SDK. @@ -12,14 +13,17 @@ pub enum Error { /// Attempted to send an invalid request to the API. InvalidRequest, - /// Such query does not exist. - NotFound, + /// Such query does not exist. Inside is the message from the API if available. + NotFound(Option), /// Ratelimited from sending more requests. Ratelimit { /// How long the client should wait in seconds before it could send requests again without receiving a 429. retry_after: u16, }, + + /// Endpoint is inaccessible with legacy API tokens. + UnsupportedToken, } impl fmt::Display for Error { @@ -28,11 +32,16 @@ impl fmt::Display for Error { Self::InternalClientError(err) => write!(f, "Internal Client Error: {err}"), Self::InternalServerError => write!(f, "Internal Server Error"), Self::InvalidRequest => write!(f, "Invalid Request"), - Self::NotFound => write!(f, "Not Found"), + Self::NotFound(message) => write!( + f, + "Not Found: {}", + message.as_deref().unwrap_or("") + ), Self::Ratelimit { retry_after } => write!( f, "Blocked by the API for an hour. Please try again in {retry_after} seconds", ), + Self::UnsupportedToken => write!(f, "Endpoint is inaccessible with legacy API tokens"), } } } @@ -49,3 +58,46 @@ impl error::Error for Error { /// The result type primarily used in this SDK. pub type Result = result::Result; + +/// An error coming from [`Client::post_bot_commands`][crate::Client::post_bot_commands]. +#[derive(Debug)] +pub enum PostBotCommandsError { + /// Error happened while retrieving the bot commands in [`GetBotCommands`][crate::GetBotCommands]. + Retrieval(E), + + /// Error happened while serializing the bot commands. + Serialization(SerdeJsonError), + + /// Error happened while sending the HTTP request. + Request(Error), +} + +impl fmt::Display for PostBotCommandsError +where + E: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Retrieval(err) => write!(f, "Error while retrieving bot commands: {err:?}"), + Self::Serialization(err) => write!(f, "Error while serializing bot commands: {err:?}"), + Self::Request(err) => write!(f, "Error while posting bot commands: {err:?}"), + } + } +} + +impl error::Error for PostBotCommandsError +where + E: error::Error + 'static, +{ + #[inline(always)] + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::Retrieval(err) => Some(err), + Self::Serialization(err) => Some(err), + Self::Request(err) => err.source(), + } + } +} + +/// The result type used in [`Client::post_bot_commands`][crate::Client::post_bot_commands]. +pub type PostBotCommandsResult = result::Result>; diff --git a/src/lib.rs b/src/lib.rs index 6454fd2..7ceca72 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,39 +10,48 @@ mod test; cfg_if::cfg_if! { if #[cfg(feature = "api")] { pub(crate) mod client; + mod bot; mod error; + mod project; mod util; + mod vote; - #[cfg(feature = "autoposter")] + #[cfg(feature = "bot-autoposter")] pub(crate) use client::InnerClient; - /// Bot-related traits and structs. - pub mod bot; - - /// Voter-related structs. - pub mod voter; - /// Widget generator functions. pub mod widget; #[doc(inline)] + pub use bot::{Bot, BotQuery}; pub use client::Client; - pub use error::{Error, Result}; - pub use snowflake::Snowflake; // for doc purposes - - #[doc(inline)] + pub use error::{Error, Result, PostBotCommandsError, PostBotCommandsResult}; + pub use project::{GetBotCommands, Reviews}; + pub use snowflake::{Snowflake, UserSource}; // for doc purposes + pub use vote::{Vote, Voter}; pub use widget::WidgetType; + + #[doc(hidden)] + #[cfg(any(feature = "twilight", feature = "twilight-cached"))] + pub use project::TwilightGetCommandsError; } } cfg_if::cfg_if! { - if #[cfg(feature = "autoposter")] { - /// Autoposter-related traits and structs. - #[cfg_attr(docsrs, doc(cfg(feature = "autoposter")))] - pub mod autoposter; + if #[cfg(feature = "bot-autoposter")] { + mod bot_autoposter; #[doc(inline)] - pub use autoposter::Autoposter; + #[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; } } diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..2c631aa --- /dev/null +++ b/src/project.rs @@ -0,0 +1,73 @@ +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +/// A project's reviews on Top.gg. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct Reviews { + /// This project's average review score out of 5. + #[serde(rename = "averageScore")] + pub score: f64, + + /// This project's review count. + pub count: usize, +} + +/// Retrieves an array of application commands in [Discord API's raw JSON format](https://discord.com/developers/docs/interactions/application-commands#application-command-object). For use in [`Client::post_bot_commands`][crate::Client::post_bot_commands]. +#[async_trait::async_trait] +pub trait GetBotCommands +where + C: Serialize + DeserializeOwned, +{ + async fn get_bot_commands(self) -> Result, E>; +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "serenity", feature = "serenity-cached"))] { + use serenity::{ + client::Context as SerenityContext, + http::{ + HttpError as SerenityHttpError, LightMethod as SerenityHttpMethod, + Request as SerenityHttpRequest, Route as SerenityHttpRoute, + }, + Error as SerenityError, + }; + + #[async_trait::async_trait] + impl GetBotCommands for &SerenityContext { + async fn get_bot_commands(self) -> Result, SerenityError> { + let Some(application_id) = self.http.application_id() else { + return Err(SerenityHttpError::ApplicationIdMissing.into()); + }; + + self + .http + .fire::<_>(SerenityHttpRequest::new( + SerenityHttpRoute::Commands { application_id }, + SerenityHttpMethod::Get, + )) + .await + } + } + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "twilight", feature = "twilight-cached"))] { + use twilight_http::{error::Error as TwilightHttpError, response::DeserializeBodyError as TwilightHttpDeserializeBodyError, request::application::command::GetGlobalCommands as TwilightGetGlobalCommands}; + use twilight_model::application::command::Command as TwilightCommand; + + #[doc(hidden)] + #[derive(Debug)] + pub enum TwilightGetCommandsError { + Http(TwilightHttpError), + Deserialize(TwilightHttpDeserializeBodyError), + } + + #[async_trait::async_trait] + impl GetBotCommands for TwilightGetGlobalCommands<'_> { + async fn get_bot_commands(self) -> Result, TwilightGetCommandsError> { + self.await.map_err(TwilightGetCommandsError::Http)?.models().await.map_err(TwilightGetCommandsError::Deserialize) + } + } + } +} diff --git a/src/snowflake.rs b/src/snowflake.rs index af47d6f..c149ea9 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -19,12 +19,40 @@ cfg_if::cfg_if! { .map(|s: Vec| s.into_iter().filter_map(|next| next.parse().ok()).collect()) } - /// Any datatype that can be interpreted as a Discord 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; } + /// A user account from an external platform that is linked to a Top.gg user account. + #[non_exhaustive] + pub enum UserSource { + Topgg(I), + Discord(I), + } + + impl UserSource { + pub(crate) const fn name(&self) -> &'static str { + match self { + Self::Topgg(_) => "topgg", + Self::Discord(_) => "discord", + } + } + } + + impl Snowflake for UserSource + where + I: Snowflake, + { + #[inline(always)] + fn as_snowflake(&self) -> u64 { + match self { + Self::Topgg(id) | Self::Discord(id) => id.as_snowflake(), + } + } + } + macro_rules! impl_snowflake( ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { $(#[$attr])? @@ -56,8 +84,8 @@ cfg_if::cfg_if! { ); impl_topgg_idstruct!( - crate::bot::Bot, - crate::voter::Voter + crate::Bot, + crate::Voter ); } } diff --git a/src/test.rs b/src/test.rs index 9f61f80..065cb54 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,6 +1,9 @@ -use crate::Client; +use crate::{Client, UserSource}; use tokio::time::{sleep, Duration}; +#[cfg(feature = "bot-autoposter")] +use crate::BotAutoposter; + macro_rules! delayed { ($($b:tt)*) => { $($b)* @@ -8,10 +11,189 @@ macro_rules! delayed { }; } +cfg_if::cfg_if! { + if #[cfg(any(feature = "serenity", feature = "serenity-cached", feature = "twilight", feature = "twilight-cached"))] { + use crate::PostBotCommandsError; + use std::sync::Arc; + use tokio::sync::{mpsc, OnceCell}; + + #[cfg(feature = "bot-autoposter")] + use tokio::sync::Mutex; + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "serenity", feature = "serenity-cached"))] { + use serenity::{ + Error as SerenityError, + builder::CreateCommand as SerenityCreateCommand, + client::{ + Client as SerenityClient, + Context as SerenityContext, + EventHandler as SerenityEventHandler + }, + model::{application::Command as SerenityCommand, gateway::{Ready as SerenityReadyEvent, GatewayIntents as SerenityGatewayIntents}} + }; + + #[cfg(feature = "bot-autoposter")] + use tokio::time::timeout; + + #[derive(Debug)] + #[allow(dead_code)] + enum SerenityTestError { + PostBotCommandsSerenity(SerenityError), + PostBotCommandsTopgg(PostBotCommandsError), + #[cfg(feature = "bot-autoposter")] + BotAutoposterThread(crate::Error), + #[cfg(feature = "bot-autoposter")] + BotAutoposterTimeout, + } + + struct SerenityTestEventHandler { + client: Arc, + result_sender: mpsc::Sender>, + #[cfg(feature = "bot-autoposter")] + bot_autoposter: Mutex>, + } + + impl SerenityTestEventHandler { + async fn test_post_bot_commands(&self, ctx: &SerenityContext) -> Result<(), SerenityTestError> { + SerenityCommand::set_global_commands(&ctx.http, vec![SerenityCreateCommand::new("test").description("command description")]).await.map_err(SerenityTestError::PostBotCommandsSerenity)?; + + self.client.post_bot_commands(ctx).await.map_err(SerenityTestError::PostBotCommandsTopgg) + } + } + + static SERENITY_TEST_EVENT_HANDLER_READY_ONCE: OnceCell<()> = OnceCell::const_new(); + + #[async_trait::async_trait] + impl SerenityEventHandler for SerenityTestEventHandler { + #[cfg_attr(not(feature = "bot-autoposter"), allow(unused_mut))] + async fn ready(&self, ctx: SerenityContext, _ready: SerenityReadyEvent) { + SERENITY_TEST_EVENT_HANDLER_READY_ONCE.get_or_init(|| async { + let mut test_result = self.test_post_bot_commands(&ctx).await; + + #[cfg(feature = "bot-autoposter")] + if test_result.is_ok() { + let mut bot_autoposter_guard = self.bot_autoposter.lock().await; + let mut bot_autoposter_receiver = bot_autoposter_guard.receiver(); + + match timeout(Duration::from_secs(10), async move { + let mut bot_autopost_counter = 0; + + while let Some(posted) = bot_autoposter_receiver.recv().await { + if let Err(err) = posted { + return Err(err); + } + + bot_autopost_counter += 1; + + if bot_autopost_counter == 3 { + break; + } + } + + Ok(()) + }).await { + Ok(Err(err)) => test_result = Err(SerenityTestError::BotAutoposterThread(err)), + Err(_) => test_result = Err(SerenityTestError::BotAutoposterTimeout), + _ => {}, + } + } + + self.result_sender.send(test_result).await.unwrap(); + }).await; + } + } + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "twilight", feature = "twilight-cached"))] { + use std::sync::atomic::{self, AtomicBool}; + use tokio::time::timeout; + use twilight_gateway::{Event as TwilightGatewayEvent, Intents as TwilightGatewayIntents, Shard as TwilightShard, ShardId as TwilightShardId}; + use twilight_http::{Error as TwilightHttpError, response::DeserializeBodyError as TwilightHttpDeserializeBodyError, Client as TwilightHttpClient}; + use twilight_model::{application::command::{Command as TwilightCommand, CommandType as TwilightCommandType}, id::Id as TwilightId}; + + static TWILIGHT_TEST_EVENT_HANDLER_READY_ONCE: OnceCell<()> = OnceCell::const_new(); + + #[derive(Debug)] + #[allow(dead_code)] + enum TwilightTestError { + PostBotCommandsTwilightApplicationIdHttp(TwilightHttpError), + PostBotCommandsTwilightApplicationIdDeserialization(TwilightHttpDeserializeBodyError), + PostBotCommandsTwilightSetBotCommands(TwilightHttpError), + PostBotCommandsTopgg(PostBotCommandsError), + #[cfg(feature = "bot-autoposter")] + BotAutoposterThread(crate::Error), + } + + struct TwilightTestContext { + bot: TwilightHttpClient, + running: AtomicBool, + result_sender: mpsc::Sender>, + #[cfg(feature = "bot-autoposter")] + autoposter_receiver: Mutex>>, + } + + async fn test_twilight<'a>(client: &'a Client, context: &'a TwilightTestContext) -> Result<(), TwilightTestError> { + let application_id = context.bot.current_user_application().await.map_err(TwilightTestError::PostBotCommandsTwilightApplicationIdHttp)?.model().await.map_err(TwilightTestError::PostBotCommandsTwilightApplicationIdDeserialization)?.id; + let interaction = context.bot.interaction(application_id); + + interaction.set_global_commands(&[TwilightCommand { + application_id: None, + default_member_permissions: None, + dm_permission: None, + description: String::from("command description"), + description_localizations: None, + guild_id: None, + id: None, + kind: TwilightCommandType::ChatInput, + name: String::from("test"), + name_localizations: None, + nsfw: Some(false), + options: vec![], + version: TwilightId::new(1) + }]).await.map_err(TwilightTestError::PostBotCommandsTwilightSetBotCommands)?; + + client.post_bot_commands(interaction.global_commands()).await.map_err(TwilightTestError::PostBotCommandsTopgg)?; + + cfg_if::cfg_if! { + if #[cfg(feature = "bot-autoposter")] { + let mut bot_autopost_counter = 0; + + while let Some(posted) = context.autoposter_receiver.lock().await.recv().await { + if let Err(err) = posted { + return Err(TwilightTestError::BotAutoposterThread(err)); + } + + bot_autopost_counter += 1; + + if bot_autopost_counter == 3 { + break; + } + } + } + } + + Ok(()) + } + } +} + #[tokio::test] async fn api() { let client = Client::new(env!("TOPGG_TOKEN").to_string()); + #[cfg(any( + feature = "serenity", + feature = "serenity-cached", + feature = "twilight", + feature = "twilight-cached" + ))] + let client = Arc::new(client); + delayed! { let bot = client.get_bot(264811613708746752).await.unwrap(); @@ -31,13 +213,13 @@ async fn api() { delayed! { client - .post_server_count(2) + .post_bot_server_count(2) .await .unwrap(); } delayed! { - assert_eq!(client.get_server_count().await.unwrap().unwrap(), 2); + assert_eq!(client.get_bot_server_count().await.unwrap().unwrap(), 2); } delayed! { @@ -45,10 +227,107 @@ async fn api() { } delayed! { - let _has_voted = client.has_voted(661200758510977084).await.unwrap(); + let _vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); + } + + delayed! { + let _vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); } delayed! { let _is_weekend = client.is_weekend().await.unwrap(); } + + #[cfg(any(feature = "serenity", feature = "serenity-cached"))] + delayed! { + let bot_token = env!("BOT_TOKEN").to_string(); + let (test_result_sender, mut test_result_receiver) = mpsc::channel(1); + + cfg_if::cfg_if! { + if #[cfg(feature = "bot-autoposter")] { + let bot_autoposter = BotAutoposter::serenity(client.as_ref(), Duration::from_secs(2)); + let bot_autoposter_handler = bot_autoposter.handler(); + } + } + + let bot = SerenityClient::builder(&bot_token, SerenityGatewayIntents::GUILD_MESSAGES | SerenityGatewayIntents::GUILDS) + .event_handler(SerenityTestEventHandler { + client: Arc::clone(&client), + result_sender: test_result_sender, + #[cfg(feature = "bot-autoposter")] + bot_autoposter: Mutex::const_new(bot_autoposter), + }); + + #[cfg(feature = "bot-autoposter")] + let bot = bot.event_handler_arc(bot_autoposter_handler); + + let mut bot = bot.await.unwrap(); + let shard_manager = Arc::clone(&bot.shard_manager); + + let test_serenity_thread = tokio::spawn(async move { + let test_result = test_result_receiver.recv().await; + + shard_manager.shutdown_all().await; + + test_result + }); + + bot.start().await.unwrap(); + test_serenity_thread.await.unwrap().unwrap().unwrap(); + } + + #[cfg(any(feature = "twilight", feature = "twilight-cached"))] + delayed! { + let bot_token = env!("BOT_TOKEN").to_string(); + let (test_result_sender, mut test_result_receiver) = mpsc::channel(1); + + #[cfg(feature = "bot-autoposter")] + let mut bot_autoposter = BotAutoposter::twilight(client.as_ref(), Duration::from_secs(2)); + + let context = Arc::new(TwilightTestContext { + bot: TwilightHttpClient::new(bot_token.clone()), + running: AtomicBool::new(true), + result_sender: test_result_sender, + #[cfg(feature = "bot-autoposter")] + autoposter_receiver: Mutex::const_new(bot_autoposter.receiver()), + }); + + let mut shard = TwilightShard::new( + TwilightShardId::ONE, + bot_token, + TwilightGatewayIntents::GUILD_MESSAGES | TwilightGatewayIntents::GUILDS, + ); + + while context.running.load(atomic::Ordering::Relaxed) { + let event = match shard.next_event().await { + Ok(event) => event, + Err(source) => { + if source.is_fatal() { + break; + } + + continue; + } + }; + + #[cfg(feature = "bot-autoposter")] + bot_autoposter.handle(&event).await; + + if matches!(event, TwilightGatewayEvent::Ready(_)) { + let thread_client = Arc::clone(&client); + let thread_context = Arc::clone(&context); + + TWILIGHT_TEST_EVENT_HANDLER_READY_ONCE.get_or_init(|| async move { + tokio::spawn(async move { + let test_result = test_twilight(&thread_client, &thread_context).await; + + thread_context.running.store(false, atomic::Ordering::Relaxed); + thread_context.result_sender.send(test_result).await.unwrap(); + }); + }).await; + } + } + + timeout(Duration::from_secs(10), test_result_receiver.recv()).await.unwrap().unwrap().unwrap(); + } } diff --git a/src/util.rs b/src/util.rs index b45b274..824acb3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -41,18 +41,20 @@ where } #[derive(Deserialize)] +#[allow(clippy::used_underscore_binding)] struct TokenStructure { #[serde(deserialize_with = "snowflake::deserialize")] id: u64, + _t: Option, } -pub(crate) fn id_from_token(token: &str) -> u64 { +pub(crate) fn parse_api_token(token: &str) -> (u64, bool) { 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; + return (token_structure.id, token_structure._t.is_none()); } } } diff --git a/src/vote.rs b/src/vote.rs new file mode 100644 index 0000000..6c7057c --- /dev/null +++ b/src/vote.rs @@ -0,0 +1,46 @@ +use crate::snowflake; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// A Top.gg vote. +#[derive(Deserialize)] +pub struct Vote { + /// When the vote was cast. + #[serde(rename = "created_at")] + pub voted_at: DateTime, + + /// When the vote expires and the user is required to vote again. + pub expires_at: DateTime, + + /// This vote's weight. + pub weight: usize, +} + +impl Vote { + /// Whether this vote is now expired. + #[inline(always)] + pub fn expired(&self) -> bool { + Utc::now() >= self.expires_at + } +} + +#[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/voter.rs b/src/voter.rs deleted file mode 100644 index e8fffa9..0000000 --- a/src/voter.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 Discord 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/axum.rs b/src/webhooks/axum.rs index cd0dd5e..4175b1b 100644 --- a/src/webhooks/axum.rs +++ b/src/webhooks/axum.rs @@ -30,15 +30,15 @@ impl Clone for WebhookState { /// /// ```rust,no_run /// use axum::{routing::get, Router}; -/// use topgg::{Vote, Webhook}; +/// 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: Vote) { +/// 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); /// } /// } diff --git a/src/webhooks/vote.rs b/src/webhooks/vote.rs index 412d242..0bbbb54 100644 --- a/src/webhooks/vote.rs +++ b/src/webhooks/vote.rs @@ -40,8 +40,8 @@ where /// A dispatched Top.gg vote event. #[must_use] #[derive(Clone, Debug, Deserialize)] -pub struct Vote { - /// The ID of the Discord bot/server that received a vote. +pub struct VoteEvent { + /// The ID of the project that received a vote. #[serde( deserialize_with = "snowflake::deserialize", alias = "bot", diff --git a/src/webhooks/warp.rs b/src/webhooks/warp.rs index c8b5dcc..51c108b 100644 --- a/src/webhooks/warp.rs +++ b/src/webhooks/warp.rs @@ -9,14 +9,14 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// /// ```rust,no_run /// use std::{net::SocketAddr, sync::Arc}; -/// use topgg::{Vote, Webhook}; +/// use topgg::{VoteEvent, Webhook}; /// use warp::Filter; /// /// struct MyVoteListener {} /// /// #[async_trait::async_trait] -/// impl Webhook for MyVoteListener { -/// async fn callback(&self, vote: Vote) { +/// 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); /// } /// } diff --git a/src/widget.rs b/src/widget.rs index 4bd1dc8..db15763 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -21,14 +21,14 @@ impl WidgetType { /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::Widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); /// ``` #[inline(always)] pub fn large(ty: WidgetType, id: I) -> String where I: Snowflake, { - crate::client::api!("/widgets/large/{}/{}", ty.as_path(), id.as_snowflake()) + crate::client::api!("/v1/widgets/large/{}/{}", ty.as_path(), id.as_snowflake()) } /// Generates a small widget URL for displaying votes. @@ -36,7 +36,7 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::Widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); /// ``` #[inline(always)] pub fn votes(ty: WidgetType, id: I) -> String @@ -44,18 +44,18 @@ where I: Snowflake, { crate::client::api!( - "/widgets/small/votes/{}/{}", + "/v1/widgets/small/votes/{}/{}", ty.as_path(), id.as_snowflake() ) } -/// Generates a small widget URL for displaying an entity's owner. +/// Generates a small widget URL for displaying a project's owner. /// /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::Widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); /// ``` #[inline(always)] pub fn owner(ty: WidgetType, id: I) -> String @@ -63,7 +63,7 @@ where I: Snowflake, { crate::client::api!( - "/widgets/small/owner/{}/{}", + "/v1/widgets/small/owner/{}/{}", ty.as_path(), id.as_snowflake() ) @@ -74,7 +74,7 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::Widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); /// ``` #[inline(always)] pub fn social(ty: WidgetType, id: I) -> String @@ -82,7 +82,7 @@ where I: Snowflake, { crate::client::api!( - "/widgets/small/social/{}/{}", + "/v1/widgets/small/social/{}/{}", ty.as_path(), id.as_snowflake() )