diff --git a/Cargo.toml b/Cargo.toml index 5eccf05..3cfece0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "topgg" -version = "1.4.2" +version = "2.0.0" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] -description = "The official Rust wrapper for the Top.gg API" +description = "A simple API wrapper for Top.gg written in Rust." readme = "README.md" repository = "https://github.com/Top-gg-Community/rust-sdk" license = "MIT" @@ -12,27 +12,50 @@ categories = ["api-bindings", "web-programming::http-client"] exclude = [".gitattributes", ".github/", ".gitignore", "rustfmt.toml"] [dependencies] +base64 = { version = "0.22", optional = true } cfg-if = "1" paste = { version = "1", optional = true } reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } -urlencoding = { version = "2", optional = true } +urlencoding = "2" serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } +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 } -axum = { version = "0.7", default-features = false, optional = true, features = ["http1", "tokio"] } +axum = { version = "0.8", default-features = false, optional = true, features = ["http1", "tokio"] } async-trait = { version = "0.1", optional = true } warp = { version = "0.3", default-features = false, optional = true } actix-web = { version = "4", default-features = false, optional = true } +[dev-dependencies] +tokio = { version = "1", features = ["rt", "macros"] } +twilight-gateway = "0.15" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +cast-lossless = "allow" +cast-possible-truncation = "allow" +cast-possible-wrap = "allow" +cast-sign-loss = "allow" +inline-always = "allow" +module-name-repetitions = "allow" +must-use-candidate = "allow" +return-self-not-must-use = "allow" +similar-names = "allow" +single-match-else = "allow" +too-many-lines = "allow" +unnecessary-wraps = "allow" +unreadable-literal = "allow" + [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] @@ -40,17 +63,18 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["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"] -webhook = ["urlencoding"] -rocket = ["webhook", "dep:rocket"] -axum = ["webhook", "async-trait", "serde_json", "dep:axum"] -warp = ["webhook", "async-trait", "dep:warp"] -actix-web = ["webhook", "dep:actix-web"] +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/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 diff --git a/src/autoposter/mod.rs b/src/autoposter/mod.rs deleted file mode 100644 index 4308260..0000000 --- a/src/autoposter/mod.rs +++ /dev/null @@ -1,254 +0,0 @@ -use crate::{Result, Stats}; -use core::{ - ops::{Deref, DerefMut}, - time::Duration, -}; -use std::sync::Arc; -use tokio::{ - sync::{mpsc, RwLock, RwLockWriteGuard, Semaphore}, - task::{spawn, JoinHandle}, - time::sleep, -}; - -mod client; - -pub use client::AsClient; -pub(crate) use client::AsClientSealed; - -cfg_if::cfg_if! { - if #[cfg(feature = "serenity")] { - mod serenity_impl; - - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] - pub use serenity_impl::Serenity; - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "twilight")] { - mod twilight_impl; - - #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] - pub use twilight_impl::Twilight; - } -} - -/// A struct representing a thread-safe form of the [`Stats`] struct to be used in autoposter [`Handler`]s. -pub struct SharedStats { - sem: Semaphore, - stats: RwLock, -} - -/// A guard wrapping over tokio's [`RwLockWriteGuard`] that lets you freely feed new [`Stats`] data before being sent to the [`Autoposter`]. -pub struct SharedStatsGuard<'a> { - sem: &'a Semaphore, - guard: RwLockWriteGuard<'a, Stats>, -} - -impl SharedStatsGuard<'_> { - /// Directly replaces the current [`Stats`] inside with the other. - #[inline(always)] - pub fn replace(&mut self, other: Stats) { - let ref_mut = self.guard.deref_mut(); - *ref_mut = other; - } - - /// Sets the current [`Stats`] server count. - #[inline(always)] - pub fn set_server_count(&mut self, server_count: usize) { - self.guard.server_count = Some(server_count); - } - - /// Sets the current [`Stats`] shard count. - #[inline(always)] - pub fn set_shard_count(&mut self, shard_count: usize) { - self.guard.shard_count = Some(shard_count); - } -} - -impl Deref for SharedStatsGuard<'_> { - type Target = Stats; - - #[inline(always)] - fn deref(&self) -> &Self::Target { - self.guard.deref() - } -} - -impl DerefMut for SharedStatsGuard<'_> { - #[inline(always)] - fn deref_mut(&mut self) -> &mut Self::Target { - self.guard.deref_mut() - } -} - -impl Drop for SharedStatsGuard<'_> { - #[inline(always)] - fn drop(&mut self) { - if self.sem.available_permits() < 1 { - self.sem.add_permits(1); - } - } -} - -impl SharedStats { - /// Creates a new [`SharedStats`] struct. Before any modifications, the [`Stats`] struct inside defaults to zero server count. - #[inline(always)] - pub fn new() -> Self { - Self { - sem: Semaphore::const_new(0), - stats: RwLock::new(Stats::from(0)), - } - } - - /// Locks this [`SharedStats`] with exclusive write access, causing the current task to yield until the lock has been acquired. This is akin to [`RwLock::write`]. - #[inline(always)] - pub async fn write<'a>(&'a self) -> SharedStatsGuard<'a> { - SharedStatsGuard { - sem: &self.sem, - guard: self.stats.write().await, - } - } - - #[inline(always)] - async fn wait(&self) { - self.sem.acquire().await.unwrap().forget(); - } -} - -/// A trait for handling events from third-party Discord Bot libraries. -/// -/// The struct implementing this trait should own an [`SharedStats`] struct and update it accordingly whenever Discord updates them with new data regarding guild/shard count. -pub trait Handler: Send + Sync + 'static { - /// The method that borrows [`SharedStats`] to the [`Autoposter`]. - fn stats(&self) -> &SharedStats; -} - -/// A struct that lets you automate the process of posting bot statistics to [Top.gg](https://top.gg) in intervals. -/// -/// **NOTE:** This struct owns the thread handle that executes the automatic posting. The autoposter thread will stop once this struct is dropped. -#[must_use] -pub struct Autoposter { - handler: Arc, - thread: JoinHandle<()>, - receiver: Option>>, -} - -impl Autoposter -where - H: Handler, -{ - /// Creates an [`Autoposter`] struct as well as immediately starting the thread. The thread will never stop until this struct gets dropped. - /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. - /// - `handler` is a struct that handles the *retrieving stats* part before being sent to the [`Autoposter`]. This datatype is essentially the bridge between an external third-party Discord Bot library between this library. - /// - /// # Panics - /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). - pub fn new(client: &C, handler: H, interval: Duration) -> Self - where - C: AsClient, - { - assert!( - interval.as_secs() >= 900, - "The interval mustn't be shorter than 15 minutes." - ); - - let client = client.as_client(); - let handler = Arc::new(handler); - let (sender, receiver) = mpsc::unbounded_channel(); - - Self { - handler: Arc::clone(&handler), - thread: spawn(async move { - loop { - handler.stats().wait().await; - - { - let stats = handler.stats().stats.read().await; - - if sender.send(client.post_stats(&stats).await).is_err() { - break; - } - }; - - sleep(interval).await; - } - }), - receiver: Some(receiver), - } - } - - /// Retrieves the [`Handler`] inside in the form of a [cloned][Arc::clone] [`Arc`][Arc]. - #[inline(always)] - pub fn handler(&self) -> Arc { - Arc::clone(&self.handler) - } - - /// Returns a future that resolves every time the [`Autoposter`] has attempted to post the bot's stats. If you want to use the receiver directly, call [`receiver`]. - #[inline(always)] - pub async fn recv(&mut self) -> Option> { - self.receiver.as_mut().expect("receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await - } - - /// Takes the receiver responsible for [`recv`]. Subsequent calls to this function and [`recv`] after this call will panic. - #[inline(always)] - pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { - self.receiver.take().expect("receiver() can only be called once.") - } -} - -impl Deref for Autoposter { - type Target = H; - - #[inline(always)] - fn deref(&self) -> &Self::Target { - self.handler.deref() - } -} - -#[cfg(feature = "serenity")] -#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] -impl Autoposter { - /// Creates an [`Autoposter`] struct from an existing built-in [serenity] [`Handler`] as well as immediately starting the thread. The thread will never stop until this struct gets dropped. - /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. - /// - /// # Panics - /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). - #[inline(always)] - pub fn serenity(client: &C, interval: Duration) -> Self - where - C: AsClient, - { - Self::new(client, Serenity::new(), interval) - } -} - -#[cfg(feature = "twilight")] -#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] -impl Autoposter { - /// Creates an [`Autoposter`] struct from an existing built-in [twilight](https://twilight.rs) [`Handler`] as well as immediately starting the thread. The thread will never stop until this struct gets dropped. - /// - /// - `client` can either be a reference to an existing [`Client`][crate::Client] or a [`&str`][std::str] representing a [Top.gg API](https://docs.top.gg) token. - /// - /// # Panics - /// - /// Panics if the interval argument is shorter than 15 minutes (900 seconds). - #[inline(always)] - pub fn twilight(client: &C, interval: Duration) -> Self - where - C: AsClient, - { - Self::new(client, Twilight::new(), interval) - } -} - -impl Drop for Autoposter { - #[inline(always)] - fn drop(&mut self) { - self.thread.abort(); - } -} diff --git a/src/autoposter/twilight_impl.rs b/src/autoposter/twilight_impl.rs deleted file mode 100644 index df225ff..0000000 --- a/src/autoposter/twilight_impl.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::autoposter::{Handler, SharedStats}; -use std::{collections::HashSet, ops::DerefMut}; -use tokio::sync::Mutex; -use twilight_model::gateway::event::Event; - -/// A built-in [`Handler`] for the [twilight](https://twilight.rs) library. -pub struct Twilight { - cache: Mutex>, - stats: SharedStats, -} - -impl Twilight { - #[inline(always)] - pub(super) fn new() -> Self { - Self { - cache: Mutex::const_new(HashSet::new()), - stats: SharedStats::new(), - } - } - - /// Handles an entire [twilight](https://twilight.rs) [`Event`] enum. - pub async fn handle(&self, event: &Event) { - match event { - Event::Ready(ready) => { - let mut cache = self.cache.lock().await; - let mut stats = self.stats.write().await; - let cache_ref = cache.deref_mut(); - - *cache_ref = ready.guilds.iter().map(|guild| guild.id.get()).collect(); - stats.set_server_count(cache.len()); - } - - Event::GuildCreate(guild_create) => { - let mut cache = self.cache.lock().await; - - if cache.insert(guild_create.0.id.get()) { - let mut stats = self.stats.write().await; - - stats.set_server_count(cache.len()); - } - } - - Event::GuildDelete(guild_delete) => { - let mut cache = self.cache.lock().await; - - if cache.remove(&guild_delete.id.get()) { - let mut stats = self.stats.write().await; - - stats.set_server_count(cache.len()); - } - } - - _ => {} - } - } -} - -impl Handler for Twilight { - #[inline(always)] - fn stats(&self) -> &SharedStats { - &self.stats - } -} diff --git a/src/bot.rs b/src/bot.rs index 81670d2..b11436b 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,323 +1,206 @@ -use crate::{snowflake, util}; +use crate::{snowflake, util, Client}; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Deserializer, Serialize}; - -#[inline(always)] -pub(crate) fn deserialize_support_server<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - util::deserialize_optional_string(deserializer) - .map(|inner| inner.map(|support| format!("https://discord.com/invite/{support}"))) +use serde::{Deserialize, Serialize}; +use std::{ + cmp::min, + collections::HashMap, + fmt::Write, + future::{Future, IntoFuture}, + pin::Pin, +}; + +/// A Discord bot's reviews on Top.gg. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct BotReviews { + /// This bot's average review score out of 5. + #[serde(rename = "averageScore")] + pub score: f64, + + /// This bot's review count. + pub count: usize, } -util::debug_struct! { - /// A struct representing a Discord Bot listed on [Top.gg](https://top.gg). - #[must_use] - #[derive(Clone, Deserialize)] - Bot { - public { - /// The ID of this Discord bot. - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, - - /// The username of this Discord bot. - username: String, - - /// The discriminator of this Discord bot. - discriminator: String, - - /// The prefix of this Discord bot. - prefix: String, - - /// The short description of this Discord bot. - #[serde(rename = "shortdesc")] - short_description: String, - - /// The long description of this Discord bot. It can contain HTML and/or Markdown. - #[serde( - default, - deserialize_with = "util::deserialize_optional_string", - rename = "longdesc" - )] - long_description: Option, - - /// The tags of this Discord bot. - #[serde(default, deserialize_with = "util::deserialize_default")] - tags: Vec, - - /// The website URL of this Discord bot. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - website: Option, - - /// The link to this Discord bot's GitHub repository. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - github: Option, - - /// A list of IDs of this Discord bot's owners. The main owner is the first ID in the array. - #[serde(deserialize_with = "snowflake::deserialize_vec")] - owners: Vec, - - /// A list of IDs of the guilds featured on this Discord bot's page. - #[serde(default, deserialize_with = "snowflake::deserialize_vec")] - guilds: Vec, - - /// The URL for this Discord bot's banner image. - #[serde( - default, - deserialize_with = "util::deserialize_optional_string", - rename = "bannerUrl" - )] - banner_url: Option, - - /// The date when this Discord bot was approved on [Top.gg](https://top.gg). - #[serde(rename = "date")] - approved_at: DateTime, - - /// Whether this Discord bot is [Top.gg](https://top.gg) certified or not. - #[serde(rename = "certifiedBot")] - is_certified: bool, - - /// A list of this Discord bot's shards. - #[serde(default, deserialize_with = "util::deserialize_default")] - shards: Vec, - - /// The amount of upvotes this Discord bot has. - #[serde(rename = "points")] - votes: usize, - - /// The amount of upvotes this Discord bot has this month. - #[serde(rename = "monthlyPoints")] - monthly_votes: usize, - - /// The support server invite URL of this Discord bot. - #[serde(default, deserialize_with = "deserialize_support_server")] - support: Option, - } - - private { - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - avatar: Option, - - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - invite: Option, +/// 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, +} - shard_count: Option, +#[derive(Serialize, Deserialize)] +pub(crate) struct BotStats { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) server_count: Option, +} - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - vanity: Option, - } +#[derive(Deserialize)] +pub(crate) struct Bots { + pub(crate) results: Vec, +} - getters(self) { - /// Retrieves the creation date of this bot. - #[must_use] - #[inline(always)] - created_at: DateTime => { - util::get_creation_date(self.id) - } - - /// Retrieves the avatar URL of this bot. - /// - /// Its format will either be PNG or GIF if animated. - #[must_use] - #[inline(always)] - avatar: String => { - util::get_avatar(&self.avatar, self.id) - } - - /// The invite URL of this Discord bot. - #[must_use] - invite: String => { - match &self.invite { - Some(inv) => inv.to_owned(), - _ => format!( - "https://discord.com/oauth2/authorize?scope=bot&client_id={}", - self.id - ), - } - } - - /// The amount of shards this Discord bot has according to posted stats. - #[must_use] - #[inline(always)] - shard_count: usize => { - self.shard_count.unwrap_or(self.shards.len()) - } - - /// Retrieves the URL of this Discord bot's [Top.gg](https://top.gg) page. - #[must_use] - #[inline(always)] - url: String => { - format!( - "https://top.gg/bot/{}", - self.vanity.as_deref().unwrap_or(&self.id.to_string()) - ) - } - } - } +#[derive(Deserialize)] +pub(crate) struct IsWeekend { + pub(crate) is_weekend: bool, } -util::debug_struct! { - /// A struct representing a Discord bot's statistics. - /// - /// # Examples - /// - /// Solely from a server count: - /// - /// ```rust,no_run - /// use topgg::Stats; - /// - /// let _stats = Stats::from(12345); - /// ``` - /// - /// Server count with a shard count: - /// - /// ```rust,no_run - /// use topgg::Stats; - /// - /// let server_count = 12345; - /// let shard_count = 10; - /// let _stats = Stats::from_count(server_count, Some(shard_count)); - /// ``` - /// - /// Solely from shards information: - /// - /// ```rust,no_run - /// use topgg::Stats; - /// - /// // the shard posting this data has 456 servers. - /// let _stats = Stats::from_shards([123, 456, 789], Some(1)); - /// ``` - #[must_use] - #[derive(Clone, Serialize, Deserialize)] - Stats { - protected { - #[serde(skip_serializing_if = "Option::is_none")] - shard_count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - server_count: Option, - } +/// Query for [`Client::get_bots`]. +#[must_use] +pub struct BotQuery<'a> { + client: &'a Client, + query: HashMap<&'static str, String>, + sort: Option<&'static str>, +} - 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, +macro_rules! get_bots_method { + ($( + $(#[doc = $doc:literal])* + $lib_name:ident: $lib_type:ty = $property:ident($api_name:ident, $lib_value:expr); + )*) => {$( + $(#[doc = $doc])* + pub fn $lib_name(mut self, $lib_name: $lib_type) -> Self { + self.$property.insert(stringify!($api_name), $lib_value); + self } + )*}; +} - getters(self) { - /// An array of this Discord bot's server count for each shard. - #[must_use] - #[inline(always)] - shards: &[usize] => { - match self.shards { - Some(ref shards) => shards, - None => &[], - } - } - - /// The amount of shards this Discord bot has. - #[must_use] - #[inline(always)] - shard_count: usize => { - self.shard_count.unwrap_or(match self.shards { - Some(ref shards) => shards.len(), - None => 0, - }) - } - - /// The amount of servers this bot is in. `None` if such information is publy unavailable. - #[must_use] - server_count: Option => { - self.server_count.or_else(|| { - self.shards.as_ref().and_then(|shards| { - if shards.is_empty() { - None - } else { - Some(shards.iter().copied().sum()) - } - }) - }) - } +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 Stats { - /// Creates a [`Stats`] struct from the cache of a serenity [`Context`][serenity::client::Context]. +impl<'a> BotQuery<'a> { #[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 _), - ) - } - - /// 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 { + pub(crate) fn new(client: &'a Client) -> Self { Self { - server_count: Some(server_count), - shard_count, - shards: None, - shard_id: None, + client, + query: HashMap::new(), + sort: 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 - 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); - } + get_bots_sort! { + /// Sorts results based on each bot's ID. + sort_by_id: id, - if let Some(index) = shard_index { - assert!(index < shards_list.len(), "Shard index out of range."); - } + /// Sorts results based on each bot's submission date. + sort_by_submission_date: date, - Self { - server_count: Some(total_server_count), - shard_count: Some(shards_list.len()), - shards: Some(shards_list), - shard_id: shard_index, - } + /// Sorts results based on each bot's monthly vote count. + sort_by_monthly_votes: monthlyPoints, } -} -/// Creates a [`Stats`] struct solely from a server count. -impl From for Stats { - #[inline(always)] - fn from(server_count: usize) -> Self { - Self::from_count(server_count, None) + get_bots_method! { + /// 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. + skip: u16 = query(offset, min(skip, 499).to_string()); } } -#[derive(Deserialize)] -pub(crate) struct IsWeekend { - pub(crate) is_weekend: bool, +impl<'a> IntoFuture for BotQuery<'a> { + type Output = crate::Result>; + type IntoFuture = Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + let mut path = String::from("/bots?"); + + if let Some(sort) = self.sort { + write!(&mut path, "sort={sort}&").unwrap(); + } + + for (key, value) in self.query { + write!(&mut path, "{key}={value}&").unwrap(); + } + + path.pop(); + + Box::pin(self.client.get_bots_inner(path)) + } } diff --git a/src/autoposter/client.rs b/src/bot_autoposter/client.rs similarity index 54% rename from src/autoposter/client.rs rename to src/bot_autoposter/client.rs index be95464..35118a6 100644 --- a/src/autoposter/client.rs +++ b/src/bot_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/bot_autoposter/mod.rs b/src/bot_autoposter/mod.rs new file mode 100644 index 0000000..a761313 --- /dev/null +++ b/src/bot_autoposter/mod.rs @@ -0,0 +1,354 @@ +use crate::Result; +use std::{ops::Deref, sync::Arc, time::Duration}; +use tokio::{ + sync::mpsc, + task::{spawn, JoinHandle}, + time::sleep, +}; + +mod client; + +pub use client::AsClient; +pub(crate) use client::AsClientSealed; + +cfg_if::cfg_if! { + if #[cfg(feature = "serenity")] { + mod serenity_impl; + + #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] + pub use serenity_impl::Serenity; + } +} + +cfg_if::cfg_if! { + if #[cfg(feature = "twilight")] { + mod twilight_impl; + + #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] + pub use twilight_impl::Twilight; + } +} + +/// Handle events from third-party Discord bot libraries. +/// +/// Structs that implement this ideally should own a `RwLock` instance and update it accordingly whenever Discord sends them new data regarding their server count. +#[async_trait::async_trait] +pub trait BotAutoposterHandler: Send + Sync + 'static { + /// The bot's latest server count. + async fn server_count(&self) -> usize; +} + +/// Automatically update the server count in your Discord bot's Top.gg page every few minutes. +/// +/// **NOTE**: This struct owns the Discord bot autoposter thread which means that it will stop once it gets dropped. +/// +/// # Examples +/// +/// Serenity: +/// +/// ```rust,no_run +/// use std::time::Duration; +/// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; +/// use topgg::BotAutoposter; +/// +/// struct BotAutoposterHandler; +/// +/// #[serenity::async_trait] +/// impl EventHandler for BotAutoposterHandler { +/// async fn ready(&self, _: Context, ready: Ready) { +/// println!("{} is now ready!", ready.user.name); +/// } +/// } +/// +/// #[tokio::main] +/// async fn main() { +/// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); +/// +/// // Posts once every 30 minutes +/// let mut bot_autoposter = BotAutoposter::serenity(&client, Duration::from_secs(1800)); +/// +/// let bot_token = env!("BOT_TOKEN").to_string(); +/// let intents = GatewayIntents::GUILDS; +/// +/// let mut bot = Client::builder(&bot_token, intents) +/// .event_handler(BotAutoposterHandler) +/// .event_handler_arc(bot_autoposter.handler()) +/// .await +/// .unwrap(); +/// +/// let mut receiver = bot_autoposter.receiver(); +/// +/// tokio::spawn(async move { +/// while let Some(result) = receiver.recv().await { +/// println!("Just posted: {result:?}"); +/// } +/// }); +/// +/// if let Err(why) = bot.start().await { +/// println!("Client error: {why:?}"); +/// } +/// } +/// ``` +/// +/// Twilight: +/// +/// ```rust,no_run +/// use std::time::Duration; +/// use topgg::{BotAutoposter, Client}; +/// use twilight_gateway::{Event, Intents, Shard, ShardId}; +/// +/// #[tokio::main] +/// async fn main() { +/// let client = Client::new(env!("TOPGG_TOKEN").to_string()); +/// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); +/// +/// let mut shard = Shard::new( +/// ShardId::ONE, +/// env!("BOT_TOKEN").to_string(), +/// Intents::GUILD_MESSAGES | Intents::GUILDS, +/// ); +/// +/// loop { +/// let event = match shard.next_event().await { +/// Ok(event) => event, +/// Err(source) => { +/// if source.is_fatal() { +/// break; +/// } +/// +/// continue; +/// } +/// }; +/// +/// bot_autoposter.handle(&event).await; +/// +/// match event { +/// Event::Ready(_) => { +/// println!("Bot is now ready!"); +/// }, +/// +/// _ => {} +/// } +/// } +/// } +/// ``` +#[must_use] +pub struct BotAutoposter { + handler: Arc, + thread: JoinHandle<()>, + receiver: Option>>, +} + +impl BotAutoposter +where + H: BotAutoposterHandler, +{ + /// Creates and starts a Discord bot autoposter thread. + #[allow(unused_mut)] + pub fn new(client: &C, handler: H, mut interval: Duration) -> Self + where + C: AsClient, + { + #[cfg(not(test))] + if interval.as_secs() < 900 { + interval = Duration::from_secs(900); + } + + let client = client.as_client(); + let handler = Arc::new(handler); + let local_handler = Arc::clone(&handler); + let (sender, receiver) = mpsc::unbounded_channel(); + + Self { + handler: local_handler, + thread: spawn(async move { + loop { + cfg_if::cfg_if! { + if #[cfg(test)] { + let server_count = 3; + } else { + let server_count = handler.server_count().await; + } + } + + if sender + .send( + client + .post_bot_server_count(server_count) + .await + .map(|()| server_count), + ) + .is_err() + { + break; + } + + sleep(interval).await; + } + }), + receiver: Some(receiver), + } + } + + /// This Discord bot autoposter's handler. + #[inline(always)] + pub fn handler(&self) -> Arc { + Arc::clone(&self.handler) + } + + /// Returns a future that resolves whenever an attempt to update the server count in your bot's Top.gg page has been made. The `usize` in this case is the server count that was just posted. + /// + /// **NOTE**: If you want to use the receiver directly, call [`receiver`][BotAutoposter::receiver]. + /// + /// # Panics + /// + /// Panics if this method gets called again after [`receiver`][BotAutoposter::receiver] is called. + #[inline(always)] + pub async fn recv(&mut self) -> Option> { + self.receiver.as_mut().expect("The receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await + } + + /// Takes the receiver responsible for [`recv`][BotAutoposter::recv]. + /// + /// # Panics + /// + /// Panics if this method gets called for the second time. + #[inline(always)] + pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { + self + .receiver + .take() + .expect("receiver() can only be called once.") + } +} + +impl Deref for BotAutoposter { + type Target = H; + + #[inline(always)] + fn deref(&self) -> &Self::Target { + &self.handler + } +} + +#[cfg(feature = "serenity")] +#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] +impl BotAutoposter { + /// Creates and starts a serenity-based Discord bot autoposter thread. + /// + /// # Example + /// + /// ```rust,no_run + /// use std::time::Duration; + /// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; + /// use topgg::BotAutoposter; + /// + /// struct BotAutoposterHandler; + /// + /// #[serenity::async_trait] + /// impl EventHandler for BotAutoposterHandler { + /// async fn ready(&self, _: Context, ready: Ready) { + /// println!("{} is now ready!", ready.user.name); + /// } + /// } + /// + /// #[tokio::main] + /// async fn main() { + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// + /// // Posts once every 30 minutes + /// let mut bot_autoposter = BotAutoposter::serenity(&client, Duration::from_secs(1800)); + /// + /// let bot_token = env!("BOT_TOKEN").to_string(); + /// let intents = GatewayIntents::GUILDS; + /// + /// let mut bot = Client::builder(&bot_token, intents) + /// .event_handler(BotAutoposterHandler) + /// .event_handler_arc(bot_autoposter.handler()) + /// .await + /// .unwrap(); + /// + /// let mut receiver = bot_autoposter.receiver(); + /// + /// tokio::spawn(async move { + /// while let Some(result) = receiver.recv().await { + /// println!("Just posted: {result:?}"); + /// } + /// }); + /// + /// if let Err(why) = bot.start().await { + /// println!("Client error: {why:?}"); + /// } + /// } + /// ``` + #[inline(always)] + pub fn serenity(client: &C, interval: Duration) -> Self + where + C: AsClient, + { + Self::new(client, Serenity::new(), interval) + } +} + +#[cfg(feature = "twilight")] +#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] +impl BotAutoposter { + /// Creates and starts a twilight-based Discord bot autoposter thread. + /// + /// # Example + /// + /// ```rust,no_run + /// use std::time::Duration; + /// use topgg::{BotAutoposter, Client}; + /// use twilight_gateway::{Event, Intents, Shard, ShardId}; + /// + /// #[tokio::main] + /// async fn main() { + /// let client = Client::new(env!("TOPGG_TOKEN").to_string()); + /// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); + /// + /// let mut shard = Shard::new( + /// ShardId::ONE, + /// env!("BOT_TOKEN").to_string(), + /// Intents::GUILD_MESSAGES | Intents::GUILDS, + /// ); + /// + /// loop { + /// let event = match shard.next_event().await { + /// Ok(event) => event, + /// Err(source) => { + /// if source.is_fatal() { + /// break; + /// } + /// + /// continue; + /// } + /// }; + /// + /// bot_autoposter.handle(&event).await; + /// + /// match event { + /// Event::Ready(_) => { + /// println!("Bot is now ready!"); + /// }, + /// + /// _ => {} + /// } + /// } + /// } + /// ``` + #[inline(always)] + pub fn twilight(client: &C, interval: Duration) -> Self + where + C: AsClient, + { + Self::new(client, Twilight::new(), interval) + } +} + +impl Drop for BotAutoposter { + #[inline(always)] + fn drop(&mut self) { + self.thread.abort(); + } +} diff --git a/src/autoposter/serenity_impl.rs b/src/bot_autoposter/serenity_impl.rs similarity index 70% rename from src/autoposter/serenity_impl.rs rename to src/bot_autoposter/serenity_impl.rs index 22c19d5..327344f 100644 --- a/src/autoposter/serenity_impl.rs +++ b/src/bot_autoposter/serenity_impl.rs @@ -1,4 +1,4 @@ -use crate::autoposter::{Handler, SharedStats}; +use crate::bot_autoposter::BotAutoposterHandler; 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,17 +18,15 @@ cfg_if::cfg_if! { struct Cache { guilds: HashSet, } - } else { - use std::ops::Add; } } -/// A built-in [`Handler`] for the [serenity] library. +/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the serenity library. #[must_use] pub struct Serenity { #[cfg(not(feature = "serenity-cached"))] cache: Mutex, - stats: SharedStats, + server_count: RwLock, } macro_rules! serenity_handler { @@ -50,11 +49,15 @@ macro_rules! serenity_handler { cache: Mutex::const_new(Cache { guilds: HashSet::new(), }), - stats: SharedStats::new(), + server_count: RwLock::new(0), } } - /// Handles an entire [serenity] [`FullEvent`] enum. This can be used in [serenity] frameworks. + /// Handles an entire serenity [`FullEvent`] enum. This can be used in serenity frameworks. + /// + /// # Panics + /// + /// The `serenity-cached` feature is enabled but the bot doesn't cache guilds. pub async fn handle(&$self, $context: &Context, event: &FullEvent) { match event { $( @@ -94,19 +97,19 @@ serenity_handler! { (self, context) => { ready { map(data_about_bot: Ready) { - self.handle_ready(&data_about_bot.guilds).await + self.handle_ready(&data_about_bot.guilds).await; } handle(guilds: &[UnavailableGuild]) { - let mut 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"))] { let mut cache = self.cache.lock().await; - cache.guilds = guilds.into_iter().map(|x| x.id).collect(); + cache.guilds = guilds.iter().map(|x| x.id).collect(); } } } @@ -115,27 +118,13 @@ serenity_handler! { #[cfg(feature = "serenity-cached")] cache_ready { map(guilds: Vec) { - self.handle_cache_ready(guilds.len()).await + self.handle_cache_ready(guilds.len()).await; } handle(guild_count: usize) { - let mut stats = self.stats.write().await; + let mut server_count = self.server_count.write().await; - stats.set_server_count(guild_count); - } - } - - #[cfg(feature = "serenity-cached")] - shards_ready { - map(total_shards: u32) { - // turns either &u32 or u32 to a u32 :) - self.handle_shards_ready(total_shards.add(0)).await - } - - handle(shard_count: u32) { - let mut stats = self.stats.write().await; - - stats.set_shard_count(shard_count as _); + *server_count = guild_count; } } @@ -144,8 +133,8 @@ serenity_handler! { self.handle_guild_create( #[cfg(not(feature = "serenity-cached"))] guild.id, #[cfg(feature = "serenity-cached")] context.cache.guilds().len(), - #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the discord bot doesn't cache guilds"), - ).await + #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the bot doesn't cache guilds."), + ).await; } handle( @@ -155,17 +144,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(); } } } @@ -177,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( @@ -185,16 +174,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(); } } } @@ -203,9 +192,11 @@ serenity_handler! { } } -impl Handler for Serenity { - #[inline(always)] - fn stats(&self) -> &SharedStats { - &self.stats +#[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/bot_autoposter/twilight_impl.rs b/src/bot_autoposter/twilight_impl.rs new file mode 100644 index 0000000..69886ad --- /dev/null +++ b/src/bot_autoposter/twilight_impl.rs @@ -0,0 +1,65 @@ +use crate::bot_autoposter::BotAutoposterHandler; +use std::collections::HashSet; +use tokio::sync::{Mutex, RwLock}; +use twilight_model::gateway::event::Event; + +/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the twilight. +pub struct Twilight { + cache: Mutex>, + server_count: RwLock, +} + +impl Twilight { + #[inline(always)] + pub(super) fn new() -> Self { + Self { + cache: Mutex::const_new(HashSet::new()), + server_count: RwLock::new(0), + } + } + + /// Handles an entire twilight [`Event`] enum. + pub async fn handle(&self, event: &Event) { + match event { + Event::Ready(ready) => { + let mut cache: tokio::sync::MutexGuard<'_, HashSet> = self.cache.lock().await; + let mut server_count = self.server_count.write().await; + let cache_ref = &mut *cache; + + *cache_ref = ready.guilds.iter().map(|guild| guild.id.get()).collect(); + *server_count = cache.len(); + } + + Event::GuildCreate(guild_create) => { + let mut cache = self.cache.lock().await; + + if cache.insert(guild_create.id.get()) { + let mut server_count = self.server_count.write().await; + + *server_count = cache.len(); + } + } + + Event::GuildDelete(guild_delete) => { + let mut cache = self.cache.lock().await; + + if cache.remove(&guild_delete.id.get()) { + let mut server_count = self.server_count.write().await; + + *server_count = cache.len(); + } + } + + _ => {} + } + } +} + +#[async_trait::async_trait] +impl BotAutoposterHandler for Twilight { + async fn server_count(&self) -> usize { + let guard = self.server_count.read().await; + + *guard + } +} diff --git a/src/client.rs b/src/client.rs index 5c7fc44..ea85b09 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,14 +1,15 @@ use crate::{ - bot::{Bot, IsWeekend}, - user::{User, Voted, Voter}, - util, Error, Result, Snowflake, Stats, + bot::{Bot, BotQuery, BotStats, Bots, IsWeekend}, + util, + vote::{Voted, Voter}, + Error, Result, Snowflake, }; use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; use serde::{de::DeserializeOwned, Deserialize}; 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; @@ -23,30 +24,40 @@ struct Ratelimit { retry_after: u16, } +#[macro_export] macro_rules! api { ($e:literal) => { concat!("https://top.gg/api", $e) }; ($e:literal, $($rest:tt)*) => { - format!(api!($e), $($rest)*) + format!($crate::client::api!($e), $($rest)*) }; } -#[derive(Debug)] +pub(crate) use api; + pub struct InnerClient { http: reqwest::Client, token: String, + id: u64, +} + +#[derive(Deserialize)] +pub(crate) struct ErrorJson { + #[serde(default, alias = "message", alias = "detail")] + message: Option, } -// this is implemented here because autoposter needs to access this struct from a different thread. +// This is implemented here because the Discord bot 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 { + let id = util::parse_api_token(&token); Self { http: reqwest::Client::new(), token, + id, } } @@ -79,8 +90,13 @@ impl InnerClient { Ok(response) } else { Err(match status { - StatusCode::UNAUTHORIZED => panic!("Invalid Top.gg API token."), - StatusCode::NOT_FOUND => Error::NotFound, + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => panic!("Invalid API token."), + StatusCode::NOT_FOUND => Error::NotFound( + util::parse_json::(response) + .await + .ok() + .and_then(|err| err.message), + ), StatusCode::TOO_MANY_REQUESTS => match util::parse_json::(response).await { Ok(ratelimit) => Error::Ratelimit { retry_after: ratelimit.retry_after, @@ -112,79 +128,76 @@ impl InnerClient { } } - pub(crate) async fn post_stats(&self, new_stats: &Stats) -> Result<()> { + pub(crate) async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { + if server_count == 0 { + return Err(Error::InvalidRequest); + } + self .send_inner( Method::POST, api!("/bots/stats"), - serde_json::to_vec(new_stats).unwrap(), + serde_json::to_vec(&BotStats { + server_count: Some(server_count), + }) + .unwrap(), ) .await .map(|_| ()) } } -/// A struct representing a [Top.gg API](https://docs.top.gg) client instance. +/// Interact with the API's endpoints. #[must_use] -#[derive(Debug)] pub struct Client { inner: SyncedClient, } impl Client { - /// Creates a brand new client instance from a [Top.gg](https://top.gg) token. + /// Creates a new instance. /// - /// 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-community/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Example + /// + /// ```rust,no_run + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// ``` #[inline(always)] 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 } } - /// Fetches a user from a Discord ID. + /// Fetches a Discord bot from its ID. /// /// # Panics /// - /// Panics if any of the following conditions are met: - /// - The ID argument is a string but not numeric - /// - The client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: - /// - An internal error from the client itself preventing it from sending a HTTP request to [Top.gg](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The requested user does not exist ([`NotFound`][crate::Error::NotFound]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) - pub async fn get_user(&self, id: I) -> Result - where - I: Snowflake, - { - self - .inner - .send(Method::GET, api!("/users/{}", id.as_snowflake()), None) - .await - } - - /// Fetches a listed Discord bot from a Discord ID. - /// - /// # Panics - /// - /// Panics if any of the following conditions are met: - /// - The ID argument is a string but not numeric - /// - The client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Returns [`Err`] if: + /// - The specified bot does not exist. ([`NotFound`][crate::Error::NotFound]) + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) /// - /// # Errors + /// # Example /// - /// Errors if any of the following conditions are met: - /// - An internal error from the client itself preventing it from sending a HTTP request to [Top.gg](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The requested Discord bot is not listed on [Top.gg](https://top.gg) ([`NotFound`][crate::Error::NotFound]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// ```rust,no_run + /// let bot = client.get_bot(264811613708746752).await.unwrap(); + /// ``` pub async fn get_bot(&self, id: I) -> Result where I: Snowflake, @@ -195,75 +208,158 @@ impl Client { .await } - /// Fetches your Discord bot's statistics. + /// Fetches your Discord bot's posted server count. /// /// # Panics /// - /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if the client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: - /// - An internal error from the client itself preventing it from sending a HTTP request to [Top.gg](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) - pub async fn get_stats(&self) -> Result { + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// let server_count = client.get_bot_server_count().await.unwrap(); + /// ``` + pub async fn get_bot_server_count(&self) -> Result> { self .inner .send(Method::GET, api!("/bots/stats"), None) .await + .map(|stats: BotStats| stats.server_count) } - /// Posts your Discord bot's statistics. + /// Updates the server count in your Discord 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]) + /// Returns [`Err`] if: + /// - The bot is currently in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// client.post_bot_server_count(bot.server_count()).await.unwrap(); + /// ``` #[inline(always)] - pub async fn post_stats(&self, new_stats: Stats) -> Result<()> { - self.inner.post_stats(&new_stats).await + pub async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { + self.inner.post_bot_server_count(server_count).await } - /// Fetches your Discord bot's last 1000 voters. + /// Fetches your project's recent unique voters. + /// + /// The amount of voters returned can't exceed 100, so you would need to use the `page` argument for this. /// /// # Panics /// - /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if the client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: - /// - An internal error from the client itself preventing it from sending a HTTP request to [Top.gg](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) - pub async fn get_voters(&self) -> Result> { + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// // Page number + /// let voters = client.get_voters(1).await.unwrap(); + /// + /// for voter in voters { + /// println!("{}", voter.username); + /// } + /// ``` + pub async fn get_voters(&self, mut page: usize) -> Result> { + if page < 1 { + page = 1; + } + + self + .inner + .send( + Method::GET, + api!("/bots/{}/votes?page={}", self.inner.id, page), + None, + ) + .await + } + + pub(crate) async fn get_bots_inner(&self, path: String) -> Result> { self .inner - .send(Method::GET, api!("/bots/votes"), None) + .send::(Method::GET, api!("{}", path), None) .await + .map(|res| res.results) + } + + /// Fetches Discord bots that matches the specified query. + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// let bots = client + /// .get_bots() + /// .limit(250) + /// .skip(50) + /// .sort_by_monthly_votes() + /// .await + /// .unwrap(); + /// + /// for bot in bots { + /// println!("{}", bot.name); + /// } + /// ``` + #[inline(always)] + pub fn get_bots(&self) -> BotQuery<'_> { + BotQuery::new(self) } - /// Checks if the specified user has voted your Discord bot. + /// Checks if a Top.gg user has voted for your Discord bot in the past 12 hours. /// /// # Panics /// - /// Panics if any of the following conditions are met: - /// - The user ID argument is a string and it's not a valid ID (expected things like `"123456789"`) - /// - The client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: - /// - An internal error from the client itself preventing it from sending a HTTP request to [Top.gg](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// Returns [`Err`] if: + /// - The specified user has not logged in to Top.gg. ([`NotFound`][crate::Error::NotFound]) + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// let has_voted = client.has_voted(8226924471638491136).await.unwrap(); + /// ``` pub async fn has_voted(&self, user_id: I) -> Result where I: Snowflake, @@ -279,18 +375,24 @@ impl Client { .map(|res| res.voted != 0) } - /// Checks if the weekend multiplier is active. + /// Checks if the weekend multiplier is active, where a single vote counts as two. /// /// # Panics /// - /// Panics if the client uses an invalid [Top.gg API](https://docs.top.gg) token (unauthorized) + /// Panics if the client uses an invalid API token. /// /// # Errors /// - /// Errors if any of the following conditions are met: - /// - An internal error from the client itself preventing it from sending a HTTP request to [Top.gg](https://top.gg) ([`InternalClientError`][crate::Error::InternalClientError]) - /// - An unexpected response from the [Top.gg](https://top.gg) servers ([`InternalServerError`][crate::Error::InternalServerError]) - /// - The client is being ratelimited from sending more HTTP requests ([`Ratelimit`][crate::Error::Ratelimit]) + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// let is_weekend = client.is_weekend().await.unwrap(); + /// ``` pub async fn is_weekend(&self) -> Result { self .inner @@ -301,14 +403,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 17fe2e4..3139798 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,21 +1,23 @@ -use core::{fmt, result}; -use std::error; +use std::{error, fmt, result}; -/// A struct representing an error coming from this SDK - unexpected or not. +/// An error coming from this SDK. #[derive(Debug)] pub enum Error { - /// An unexpected internal error coming from the client itself, preventing it from sending a request to [Top.gg](https://top.gg). + /// HTTP request failure from the client-side. InternalClientError(reqwest::Error), - /// An unexpected error coming from [Top.gg](https://top.gg)'s servers themselves. + /// HTTP request failure from the server-side. InternalServerError, - /// The requested resource does not exist. (404) - NotFound, + /// Attempted to send an invalid request to the API. + InvalidRequest, - /// The client is being ratelimited from sending more HTTP requests. + /// Such query does not exist. Inside is the message from the API if available. + NotFound(Option), + + /// Ratelimited from sending more requests. Ratelimit { - /// The amount of seconds before the ratelimit is lifted. + /// How long the client should wait in seconds before it could send requests again without receiving a 429. retry_after: u16, }, } @@ -23,13 +25,17 @@ 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(message) => write!( + f, + "Not Found: {}", + message.as_deref().unwrap_or("") + ), Self::Ratelimit { retry_after } => write!( f, - "this client is ratelimited, try again in {} seconds", - retry_after / 60 + "Blocked by the API for an hour. Please try again in {retry_after} seconds", ), } } @@ -45,5 +51,5 @@ impl error::Error for Error { } } -/// The [`Result`][std::result::Result] type primarily used in this SDK. -pub type Result = result::Result; +/// The result type primarily used in this SDK. +pub type Result = result::Result; \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 0749924..1f1ac99 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,46 +1,58 @@ #![doc = include_str!("../README.md")] #![cfg_attr(docsrs, feature(doc_cfg))] +#![cfg_attr(feature = "webhooks", allow(unreachable_patterns))] +#![allow(clippy::needless_pass_by_value)] mod snowflake; +#[cfg(test)] +mod test; cfg_if::cfg_if! { if #[cfg(feature = "api")] { - mod client; + pub(crate) mod client; + mod bot; mod error; mod util; + mod vote; - #[cfg(feature = "autoposter")] + #[cfg(feature = "bot-autoposter")] pub(crate) use client::InnerClient; - /// Bot-related traits and structs. - pub mod bot; - - /// User-related structs. - pub mod user; - #[doc(inline)] - pub use bot::Stats; + pub use bot::{Bot, BotQuery}; pub use client::Client; pub use error::{Error, Result}; pub use snowflake::Snowflake; // for doc purposes + pub use vote::Voter; + + #[doc(hidden)] + #[cfg(any(feature = "twilight", feature = "twilight-cached"))] + pub use project::TwilightGetCommandsError; } } cfg_if::cfg_if! { - if #[cfg(feature = "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, SharedStats}; + #[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; } } 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/snowflake.rs b/src/snowflake.rs index ab5dae6..02eaf0b 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -8,153 +8,154 @@ where String::deserialize(deserializer).and_then(|s| s.parse().map_err(D::Error::custom)) } -#[inline(always)] -#[cfg(feature = "api")] -pub(crate) fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Deserialize::deserialize(deserializer) - .map(|s: Vec| s.into_iter().filter_map(|next| next.parse().ok()).collect()) -} - -/// A trait that represents any datatype that can be interpreted as a Discord snowflake/ID. -pub trait Snowflake { - /// The method that converts this value to a [`u64`]. - fn as_snowflake(&self) -> u64; -} - -macro_rules! impl_snowflake( - ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { - $(#[$attr])? - impl Snowflake for $t { - #[inline(always)] - fn as_snowflake(&$self) -> u64 { - $body - } - } - } -); - -impl_snowflake!(self, u64, *self); - -macro_rules! impl_string( - ($($t:ty),+) => {$( - impl_snowflake!(self, $t, (*self).parse().expect("invalid snowflake as it's not numeric")); - )+} -); - -impl_string!(&str, String); - cfg_if::cfg_if! { if #[cfg(feature = "api")] { - macro_rules! impl_topgg_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(self, &$t, (*self).id); - )+} - ); + #[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::user::User, - crate::user::Voter - ); - } -} + /// 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; + } -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, + crate::Voter + ); } } - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, twilight_model::gateway::presence::UserOrId, match self { - twilight_model::gateway::presence::UserOrId::User(user) => user.id.get(), - twilight_model::gateway::presence::UserOrId::UserId { id } => id.get(), - }); + cfg_if::cfg_if! { + if #[cfg(feature = "serenity")] { + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, + &serenity::model::guild::Member, + self.user.id.get() + ); + + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, + &serenity::model::guild::PartialMember, + self.user.as_ref().expect("User property in PartialMember is None.").id.get() + ); + + macro_rules! impl_serenity_id( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, self.get()); + )+} + ); + + impl_serenity_id!( + serenity::model::id::GenericId, + serenity::model::id::UserId + ); + + macro_rules! impl_serenity_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, self.id.get()); + )+} + ); + + impl_serenity_idstruct!( + serenity::model::gateway::PresenceUser, + serenity::model::user::CurrentUser, + serenity::model::user::User + ); + } + } - macro_rules! impl_twilight_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, (*self).id.get()); - )+} - ); + cfg_if::cfg_if! { + if #[cfg(feature = "serenity-cached")] { + use std::ops::Deref; + + macro_rules! impl_serenity_cacheref( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity-cached")))] self, $t, Snowflake::as_snowflake(&self.deref())); + )+} + ); + + impl_serenity_cacheref!( + serenity::cache::UserRef<'_>, + serenity::cache::MemberRef<'_>, + serenity::cache::CurrentUserRef<'_> + ); + } + } - impl_twilight_idstruct!( - twilight_model::user::CurrentUser, - twilight_model::user::User, - twilight_model::user::UserProfile, - twilight_model::gateway::payload::incoming::invite_create::PartialUser - ); - } -} + cfg_if::cfg_if! { + if #[cfg(feature = "twilight")] { + #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] + impl Snowflake for twilight_model::id::Id { + #[inline(always)] + fn as_snowflake(&self) -> u64 { + self.get() + } + } + + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, twilight_model::gateway::presence::UserOrId, match self { + twilight_model::gateway::presence::UserOrId::User(user) => user.id.get(), + twilight_model::gateway::presence::UserOrId::UserId { id } => id.get(), + }); + + macro_rules! impl_twilight_idstruct( + ($($t:ty),+) => {$( + impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] self, &$t, self.id.get()); + )+} + ); + + impl_twilight_idstruct!( + twilight_model::user::CurrentUser, + twilight_model::user::User, + twilight_model::gateway::payload::incoming::invite_create::PartialUser + ); + } + } -cfg_if::cfg_if! { - if #[cfg(feature = "twilight-cached")] { - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "twilight-cached")))] self, - &twilight_cache_inmemory::model::CachedMember, - (*self).user_id().get() - ); + cfg_if::cfg_if! { + if #[cfg(feature = "twilight-cached")] { + impl_snowflake!( + #[cfg_attr(docsrs, doc(cfg(feature = "twilight-cached")))] self, + &twilight_cache_inmemory::model::CachedMember, + self.user_id().get() + ); + } + } } } diff --git a/src/test.rs b/src/test.rs new file mode 100644 index 0000000..d7ed055 --- /dev/null +++ b/src/test.rs @@ -0,0 +1,54 @@ +use crate::Client; +use tokio::time::{sleep, Duration}; + +macro_rules! delayed { + ($($b:tt)*) => { + $($b)* + sleep(Duration::from_secs(1)).await + }; +} + +#[tokio::test] +async fn api() { + let client = Client::new(env!("TOPGG_TOKEN").to_string()); + + delayed! { + let bot = client.get_bot(264811613708746752).await.unwrap(); + + assert_eq!(bot.name, "Luca"); + assert_eq!(bot.id, 264811613708746752); + } + + delayed! { + let _bots = client + .get_bots() + .limit(250) + .skip(50) + .sort_by_monthly_votes() + .await + .unwrap(); + } + + delayed! { + client + .post_bot_server_count(2) + .await + .unwrap(); + } + + delayed! { + assert_eq!(client.get_bot_server_count().await.unwrap().unwrap(), 2); + } + + delayed! { + let _voters = client.get_voters(1).await.unwrap(); + } + + delayed! { + let _has_voted = client.has_voted(661200758510977084).await.unwrap(); + } + + delayed! { + let _is_weekend = client.is_weekend().await.unwrap(); + } +} \ No newline at end of file diff --git a/src/user.rs b/src/user.rs deleted file mode 100644 index 42d3dfc..0000000 --- a/src/user.rs +++ /dev/null @@ -1,141 +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) certified developer or not. - #[serde(rename = "certifiedDev")] - is_certified_dev: bool, - - /// Whether this user is a [Top.gg](https://top.gg) moderator or not. - #[serde(rename = "mod")] - is_moderator: bool, - - /// Whether this user is a [Top.gg](https://top.gg) website moderator or not. - #[serde(rename = "webMod")] - is_web_moderator: bool, - - /// Whether this user is a [Top.gg](https://top.gg) website administrator or not. - #[serde(rename = "admin")] - is_admin: bool, - } - - private { - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - avatar: Option, - } - - getters(self) { - /// Retrieves the creation date of this user. - #[must_use] - #[inline(always)] - created_at: DateTime => { - util::get_creation_date(self.id) - } - - /// Retrieves the Discord avatar URL of this user. - /// - /// Its format will either be PNG or GIF if animated. - #[must_use] - #[inline(always)] - avatar: String => { - util::get_avatar(&self.avatar, self.id) - } - } - } -} - -#[derive(Deserialize)] -pub(crate) struct Voted { - pub(crate) voted: u8, -} - -util::debug_struct! { - /// A struct representing a user who has voted on a Discord bot listed on [Top.gg](https://top.gg). (See [`Client::get_voters`][crate::Client::get_voters]) - #[must_use] - #[derive(Clone, Deserialize)] - Voter { - public { - /// The Discord ID of this user. - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, - - /// The username of this user. - username: String, - } - - private { - avatar: Option, - } - - getters(self) { - /// Retrieves the creation date of this user. - #[must_use] - #[inline(always)] - created_at: DateTime => { - util::get_creation_date(self.id) - } - - /// Retrieves the Discord avatar URL of this user. - /// - /// Its format will either be PNG or GIF if animated. - #[must_use] - #[inline(always)] - avatar: String => { - util::get_avatar(&self.avatar, self.id) - } - } - } -} diff --git a/src/util.rs b/src/util.rs index edaa15c..33ca6ad 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,81 +1,8 @@ -use crate::Error; -use chrono::{DateTime, TimeZone, Utc}; +use crate::{snowflake, Error}; +use base64::Engine; 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, @@ -83,16 +10,11 @@ pub(crate) fn deserialize_optional_string<'de, D>( where D: Deserializer<'de>, { - Ok(match ::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)] @@ -101,15 +23,7 @@ where T: Default + Deserialize<'de>, D: Deserializer<'de>, { - Option::deserialize(deserializer).map(|res| res.unwrap_or_default()) -} - -#[inline(always)] -pub(crate) fn get_creation_date(id: u64) -> DateTime { - Utc - .timestamp_millis_opt(((id >> 22) + DISCORD_EPOCH) as _) - .single() - .unwrap() + Option::deserialize(deserializer).map(Option::unwrap_or_default) } #[inline(always)] @@ -126,16 +40,23 @@ 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" }; +#[derive(Deserialize)] +#[allow(clippy::used_underscore_binding)] +struct TokenStructure { + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, +} - format!("https://cdn.discordapp.com/avatars/{id}/{hash}.{ext}?size=1024") +pub(crate) fn parse_api_token(token: &str) -> u64 { + if let Some(base64_section) = token.split('.').nth(1) { + if let Ok(decoded_base64) = + base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) + { + if let Ok(token_structure) = serde_json::from_slice::(&decoded_base64) { + return token_structure.id; + } } - _ => format!( - "https://cdn.discordapp.com/embed/avatars/{}.png", - (id >> 22) % 5 - ), } + + panic!("Got a malformed API token."); } diff --git a/src/vote.rs b/src/vote.rs new file mode 100644 index 0000000..627bf5c --- /dev/null +++ b/src/vote.rs @@ -0,0 +1,23 @@ +use crate::snowflake; +use serde::Deserialize; + +#[derive(Deserialize)] +pub(crate) struct Voted { + pub(crate) voted: u8, +} + +/// A Top.gg voter. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct Voter { + /// This voter's ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub id: u64, + + /// This voter's username. + #[serde(rename = "username")] + pub name: String, + + /// This voter's avatar URL. + pub avatar: String, +} diff --git a/src/webhook/axum.rs b/src/webhook/axum.rs deleted file mode 100644 index 76d804f..0000000 --- a/src/webhook/axum.rs +++ /dev/null @@ -1,102 +0,0 @@ -use crate::VoteHandler; -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - response::{IntoResponse, Response}, - routing::post, - Router, -}; -use std::sync::Arc; - -struct WebhookState { - state: Arc, - password: Arc, -} - -impl Clone for WebhookState { - #[inline(always)] - fn clone(&self) -> Self { - Self { - state: Arc::clone(&self.state), - password: Arc::clone(&self.password), - } - } -} - -async fn handler( - headers: HeaderMap, - State(webhook): State>, - body: String, -) -> Response -where - T: VoteHandler, -{ - if let Some(authorization) = headers.get("Authorization") { - if let Ok(authorization) = authorization.to_str() { - if authorization == *(webhook.password) { - if let Ok(vote) = serde_json::from_str(&body) { - webhook.state.voted(vote).await; - - return (StatusCode::OK, ()).into_response(); - } - } - } - } - - (StatusCode::UNAUTHORIZED, ()).into_response() -} - -/// Creates a new [`axum`] [`Router`] for adding an on-vote event handler to your application logic. -/// -/// # Examples -/// -/// Basic usage: -/// -/// ```rust,no_run -/// use axum::{routing::get, Router, Server}; -/// use std::{net::SocketAddr, sync::Arc}; -/// use topgg::{Vote, VoteHandler}; -/// -/// struct MyVoteHandler {} -/// -/// #[axum::async_trait] -/// impl VoteHandler for MyVoteHandler { -/// async fn voted(&self, vote: Vote) { -/// println!("{:?}", vote); -/// } -/// } -/// -/// async fn index() -> &'static str { -/// "Hello, World!" -/// } -/// -/// #[tokio::main] -/// async fn main() { -/// let state = Arc::new(MyVoteHandler {}); -/// -/// let app = Router::new().route("/", get(index)).nest( -/// "/webhook", -/// topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), -/// ); -/// -/// let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); -/// -/// Server::bind(&addr) -/// .serve(app.into_make_service()) -/// .await -/// .unwrap(); -/// } -/// ``` -#[inline(always)] -#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] -pub fn webhook(password: String, state: Arc) -> Router -where - T: VoteHandler, -{ - Router::new() - .route("/", post(handler::)) - .with_state(WebhookState { - state, - password: Arc::new(password), - }) -} diff --git a/src/webhook/mod.rs b/src/webhook/mod.rs deleted file mode 100644 index d7012d9..0000000 --- a/src/webhook/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -mod vote; -#[cfg_attr(docsrs, doc(cfg(feature = "webhook")))] -pub use vote::*; - -#[cfg(feature = "actix-web")] -mod actix_web; - -#[cfg(feature = "rocket")] -mod rocket; - -cfg_if::cfg_if! { - if #[cfg(feature = "axum")] { - /// Wrapper for working with the [`axum`](https://crates.io/crates/axum) web framework. - #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] - pub mod axum; - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "warp")] { - /// Wrapper for working with the [`warp`](https://crates.io/crates/warp) web framework. - #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] - pub mod warp; - } -} diff --git a/src/webhook/vote.rs b/src/webhook/vote.rs deleted file mode 100644 index 2360767..0000000 --- a/src/webhook/vote.rs +++ /dev/null @@ -1,151 +0,0 @@ -use crate::snowflake; -use serde::{Deserialize, Deserializer}; -use std::collections::HashMap; - -#[inline(always)] -fn deserialize_is_test<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - String::deserialize(deserializer).map(|s| s == "test") -} - -const fn _true() -> bool { - true -} - -#[inline(always)] -fn deserialize_is_server<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - Ok(String::deserialize(deserializer).is_err()) -} - -fn deserialize_query_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok( - String::deserialize(deserializer) - .map(|s| { - let mut output = HashMap::new(); - - for mut it in s.split('&').map(|pair| pair.split('=')) { - if let (Some(k), Some(v)) = (it.next(), it.next()) { - if let Ok(v) = urlencoding::decode(v) { - output.insert(k.to_owned(), v.into_owned()); - } - } - } - - output - }) - .unwrap_or_default(), - ) -} - -/// A struct representing a dispatched [Top.gg](https://top.gg) bot/server vote event. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct Vote { - /// The ID of the bot/server that received a vote. - #[serde( - deserialize_with = "snowflake::deserialize", - alias = "bot", - alias = "guild" - )] - pub receiver_id: u64, - - /// The ID of the user who voted. - #[serde(deserialize_with = "snowflake::deserialize", rename = "user")] - pub voter_id: u64, - - /// Whether this vote's receiver is a server or not (bot otherwise). - #[serde( - default = "_true", - deserialize_with = "deserialize_is_server", - rename = "bot" - )] - pub is_server: bool, - - /// Whether this vote is just a test coming from the bot/server owner or not. Most of the time this would be `false`. - #[serde(deserialize_with = "deserialize_is_test", rename = "type")] - pub is_test: bool, - - /// Whether the weekend multiplier is active or not, meaning a single vote counts as two. - /// If the dispatched event came from a server being voted, this will always be `false`. - #[serde(default, rename = "isWeekend")] - pub is_weekend: bool, - - /// query strings found on the vote page. - #[serde(default, deserialize_with = "deserialize_query_string")] - pub query: HashMap, -} - -cfg_if::cfg_if! { - if #[cfg(any(feature = "actix-web", feature = "rocket"))] { - /// A struct that represents an **unauthenticated** request containing a [`Vote`] data. - /// - /// To authenticate this structure with a valid password and consume the [`Vote`] data inside of it, see the [`authenticate`][IncomingVote::authenticate] method. - #[must_use] - #[cfg_attr(docsrs, doc(cfg(any(feature = "actix-web", feature = "rocket"))))] - #[derive(Clone)] - pub struct IncomingVote { - pub(crate) authorization: String, - pub(crate) vote: Vote, - } - - impl IncomingVote { - /// Authenticates a valid password with this request. Returns a [`Some(Vote)`][`Vote`] if succeeds, otherwise `None`. - /// - /// # Examples - /// - /// Basic usage: - /// - /// ```rust,no_run - /// match incoming_vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { - /// Some(vote) => { - /// println!("{:?}", vote); - /// - /// // respond with 200 OK... - /// }, - /// _ => { - /// println!("found an unauthorized attacker."); - /// - /// // respond with 401 UNAUTHORIZED... - /// } - /// } - /// ``` - #[must_use] - #[inline(always)] - pub fn authenticate(self, password: &str) -> Option { - if self.authorization == password { - Some(self.vote) - } else { - None - } - } - } - } -} - -cfg_if::cfg_if! { - if #[cfg(any(feature = "axum", feature = "warp"))] { - /// An async trait for adding an on-vote event handler to your application logic. - /// - /// It's described as follows (without [`async_trait`]'s macro expansion): - /// ```rust,no_run - /// #[async_trait::async_trait] - /// pub trait VoteHandler: Send + Sync + 'static { - /// async fn voted(&self, vote: Vote); - /// } - /// ``` - #[cfg_attr(docsrs, doc(cfg(any(feature = "axum", feature = "warp"))))] - #[async_trait::async_trait] - pub trait VoteHandler: Send + Sync + 'static { - /// Your vote handler's on-vote async callback. The endpoint will always return a 200 (OK) HTTP status code after running this method. - async fn voted(&self, vote: Vote); - } - } -} diff --git a/src/webhook/actix_web.rs b/src/webhooks/actix_web.rs similarity index 58% rename from src/webhook/actix_web.rs rename to src/webhooks/actix_web.rs index 43bff60..9393140 100644 --- a/src/webhook/actix_web.rs +++ b/src/webhooks/actix_web.rs @@ -1,24 +1,28 @@ -use crate::{IncomingVote, Vote}; +use crate::Incoming; use actix_web::{ dev::Payload, - error::{Error, ErrorUnauthorized}, + error::{Error, ErrorBadRequest, ErrorUnauthorized}, web::Json, FromRequest, HttpRequest, }; -use core::{ +use serde::de::DeserializeOwned; +use std::{ future::Future, pin::Pin, task::{ready, Context, Poll}, }; #[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,26 +30,31 @@ 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(), })); } } + + return Poll::Ready(Err(ErrorUnauthorized("401"))); } - Poll::Ready(Err(ErrorUnauthorized("401"))) + Poll::Ready(Err(ErrorBadRequest("400"))) } } #[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/webhooks/axum.rs b/src/webhooks/axum.rs new file mode 100644 index 0000000..4175b1b --- /dev/null +++ b/src/webhooks/axum.rs @@ -0,0 +1,96 @@ +use super::Webhook; +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, + Router, +}; +use serde::de::DeserializeOwned; +use std::sync::Arc; + +struct WebhookState { + state: Arc, + password: Arc, +} + +impl Clone for WebhookState { + #[inline(always)] + fn clone(&self) -> Self { + Self { + state: Arc::clone(&self.state), + password: Arc::clone(&self.password), + } + } +} + +/// Creates a new axum [`Router`] for receiving vote events. +/// +/// # Example +/// +/// ```rust,no_run +/// use axum::{routing::get, Router}; +/// use topgg::{VoteEvent, Webhook}; +/// use tokio::net::TcpListener; +/// use std::sync::Arc; +/// +/// struct MyVoteListener {} +/// +/// #[async_trait::async_trait] +/// impl Webhook for MyVoteListener { +/// async fn callback(&self, vote: VoteEvent) { +/// println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); +/// } +/// } +/// +/// async fn index() -> &'static str { +/// "Hello, World!" +/// } +/// +/// #[tokio::main] +/// async fn main() { +/// let state = Arc::new(MyVoteListener {}); +/// +/// let router = Router::new().route("/", get(index)).nest( +/// "/votes", +/// topgg::axum::webhook(env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), Arc::clone(&state)), +/// ); +/// +/// let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); +/// +/// axum::serve(listener, router).await.unwrap(); +/// } +/// ``` +#[inline(always)] +#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] +pub fn webhook(password: String, state: Arc) -> Router +where + D: DeserializeOwned + Send, + T: Webhook, +{ + Router::new() + .route( + "/", + post( + async |headers: HeaderMap, State(webhook): State>, body: String| { + if let Some(authorization) = headers.get("Authorization") { + if let Ok(authorization) = authorization.to_str() { + if authorization == *(webhook.password) { + if let Ok(data) = serde_json::from_str(&body) { + webhook.state.callback(data).await; + + return (StatusCode::NO_CONTENT, ()).into_response(); + } + } + } + } + + (StatusCode::UNAUTHORIZED, ()).into_response() + }, + ), + ) + .with_state(WebhookState { + state, + password: Arc::new(password), + }) +} diff --git a/src/webhooks/mod.rs b/src/webhooks/mod.rs new file mode 100644 index 0000000..a5ec591 --- /dev/null +++ b/src/webhooks/mod.rs @@ -0,0 +1,74 @@ +mod vote; +#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] +pub use vote::*; + +#[cfg(feature = "actix-web")] +mod actix_web; + +#[cfg(feature = "rocket")] +mod rocket; + +cfg_if::cfg_if! { + if #[cfg(feature = "axum")] { + /// Extra helpers for working with axum. + #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] + pub mod axum; + } +} + +cfg_if::cfg_if! { + if #[cfg(feature = "warp")] { + /// Extra helpers for working with warp. + #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] + pub mod warp; + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "actix-web", feature = "rocket"))] { + /// An unauthenticated incoming Top.gg webhook request. + #[must_use] + #[cfg_attr(docsrs, doc(cfg(any(feature = "actix-web", feature = "rocket"))))] + pub struct Incoming { + pub(crate) authorization: String, + pub(crate) data: T, + } + + impl Incoming { + /// Authenticates a valid password with this request. + #[must_use] + #[inline(always)] + pub fn authenticate(self, password: &str) -> Option { + if self.authorization == password { + Some(self.data) + } else { + None + } + } + } + + impl Clone for Incoming + where + T: Clone, + { + #[inline(always)] + fn clone(&self) -> Self { + Self { + authorization: self.authorization.clone(), + data: self.data.clone(), + } + } + } + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "axum", feature = "warp"))] { + /// Webhook event handler. + #[cfg_attr(docsrs, doc(cfg(any(feature = "axum", feature = "warp"))))] + #[async_trait::async_trait] + pub trait Webhook: Send + Sync + 'static { + async fn callback(&self, data: T); + } + } +} diff --git a/src/webhook/rocket.rs b/src/webhooks/rocket.rs similarity index 57% rename from src/webhook/rocket.rs rename to src/webhooks/rocket.rs index 91f337b..0bfb988 100644 --- a/src/webhook/rocket.rs +++ b/src/webhooks/rocket.rs @@ -1,26 +1,31 @@ -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 { - return Outcome::Success(Self { + return match as FromData>::from_data(request, data).await { + Outcome::Success(data) => Outcome::Success(Self { authorization: authorization.to_owned(), - vote: vote.into_inner(), - }); - } + data: data.into_inner(), + }), + _ => Outcome::Error((Status::BadRequest, ())), + }; } Outcome::Error((Status::Unauthorized, ())) diff --git a/src/webhooks/vote.rs b/src/webhooks/vote.rs new file mode 100644 index 0000000..0bbbb54 --- /dev/null +++ b/src/webhooks/vote.rs @@ -0,0 +1,67 @@ +use crate::snowflake; +use serde::{Deserialize, Deserializer}; +use std::collections::HashMap; + +#[inline(always)] +fn deserialize_is_test<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + String::deserialize(deserializer).map(|s| s == "test") +} + +fn deserialize_query_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok( + String::deserialize(deserializer) + .map(|s| { + let mut output = HashMap::new(); + + for mut it in s + .trim_start_matches('?') + .split('&') + .map(|pair| pair.split('=')) + { + if let (Some(k), Some(v)) = (it.next(), it.next()) { + if let Ok(v) = urlencoding::decode(v) { + output.insert(k.to_owned(), v.into_owned()); + } + } + } + + output + }) + .unwrap_or_default(), + ) +} + +/// A dispatched Top.gg vote event. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct VoteEvent { + /// The ID of the project that received a vote. + #[serde( + deserialize_with = "snowflake::deserialize", + alias = "bot", + alias = "guild" + )] + pub receiver_id: u64, + + /// The ID of the Top.gg user who voted. + #[serde(deserialize_with = "snowflake::deserialize", rename = "user")] + pub voter_id: u64, + + /// Whether this vote is just a test done from the page settings. + #[serde(deserialize_with = "deserialize_is_test", rename = "type")] + pub is_test: bool, + + /// Whether the weekend multiplier is active, where a single vote counts as two. + #[serde(default, rename = "isWeekend")] + pub is_weekend: bool, + + /// Query strings found on the vote page. + #[serde(default, deserialize_with = "deserialize_query_string")] + pub query: HashMap, +} diff --git a/src/webhook/warp.rs b/src/webhooks/warp.rs similarity index 61% rename from src/webhook/warp.rs rename to src/webhooks/warp.rs index 13e1238..51c108b 100644 --- a/src/webhook/warp.rs +++ b/src/webhooks/warp.rs @@ -1,35 +1,34 @@ -use crate::{Vote, VoteHandler}; +use super::Webhook; +use serde::de::DeserializeOwned; use std::sync::Arc; use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; -/// Creates a new `warp` [`Filter`] for adding an on-vote event handler to your application logic. +/// Creates a new warp [`Filter`] for receiving webhook events. /// -/// # Examples -/// -/// Basic usage: +/// # Example /// /// ```rust,no_run /// use std::{net::SocketAddr, sync::Arc}; -/// use topgg::{Vote, VoteHandler}; +/// use topgg::{VoteEvent, 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: VoteEvent) { +/// println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); /// } /// } /// /// #[tokio::main] /// async fn main() { -/// let state = Arc::new(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), /// ); /// @@ -41,13 +40,14 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// } /// ``` #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] -pub fn webhook( +pub fn webhook( endpoint: &'static str, password: String, state: Arc, ) -> impl Filter + Clone where - T: VoteHandler, + D: DeserializeOwned + Send, + T: Webhook, { let password = Arc::new(password); @@ -55,15 +55,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 }