diff --git a/Cargo.toml b/Cargo.toml index 3cfece0..908e9e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "topgg" version = "2.0.0" -edition = "2021" +edition = "2024" +rust-version = "1.87" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] -description = "A simple API wrapper for Top.gg written in Rust." +description = "The community-maintained Rust library for Top.gg." readme = "README.md" repository = "https://github.com/Top-gg-Community/rust-sdk" license = "MIT" @@ -12,21 +13,23 @@ categories = ["api-bindings", "web-programming::http-client"] exclude = [".gitattributes", ".github/", ".gitignore", "rustfmt.toml"] [dependencies] -base64 = { version = "0.22", optional = true } cfg-if = "1" -paste = { version = "1", optional = true } reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } -urlencoding = "2" +urlencoding = { version = "2", optional = true } +bytes = { version = "1.11", optional = true } +hex = { version = "0.4", optional = true } +sha2 = { version = "0.10", optional = true } +hmac = { version = "0.12", optional = true } +futures-core = { version = "0.3", optional = true } serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } twilight-http = { version = "0.15", optional = true } twilight-model = { version = "0.15", optional = true } -twilight-cache-inmemory = { version = "0.15", optional = true } -chrono = { version = "0.4", default-features = false, optional = true, features = ["serde", "now"] } +chrono = { version = "0.4", default-features = false, features = ["serde", "now"] } serde_json = { version = "1", optional = true } rocket = { version = "0.5", default-features = false, features = ["json"], optional = true } @@ -39,23 +42,6 @@ actix-web = { version = "4", default-features = false, optional = true } tokio = { version = "1", features = ["rt", "macros"] } twilight-gateway = "0.15" -[lints.clippy] -all = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -cast-lossless = "allow" -cast-possible-truncation = "allow" -cast-possible-wrap = "allow" -cast-sign-loss = "allow" -inline-always = "allow" -module-name-repetitions = "allow" -must-use-candidate = "allow" -return-self-not-must-use = "allow" -similar-names = "allow" -single-match-else = "allow" -too-many-lines = "allow" -unnecessary-wraps = "allow" -unreadable-literal = "allow" - [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] @@ -63,18 +49,24 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["async-trait", "base64", "chrono", "reqwest", "serde_json"] -bot-autoposter = ["api", "tokio"] -autoposter = ["bot-autoposter"] - -serenity = ["dep:serenity", "paste"] -serenity-cached = ["serenity", "serenity/cache"] +api = ["async-trait", "reqwest", "serde_json", "urlencoding"] -twilight = ["twilight-model", "twilight-http"] -twilight-cached = ["twilight", "twilight-cache-inmemory"] +serenity = ["dep:serenity"] +twilight = ["twilight-http", "twilight-model"] 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 +rocket = ["webhooks", "hex", "hmac", "serde_json", "sha2", "dep:rocket"] +axum = ["webhooks", "async-trait", "hex", "hmac", "serde_json", "sha2", "dep:axum"] +warp = ["webhooks", "bytes", "hex", "hmac", "serde_json", "sha2", "dep:warp"] +actix-web = ["webhooks", "futures-core", "hex", "hmac", "serde_json", "sha2", "dep:actix-web"] + +[lints.rust] +unsafe_code = "forbid" + +[lints.rustdoc] +broken_intra_doc_links = "deny" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index a258444..b857e43 100644 --- a/README.md +++ b/README.md @@ -1,219 +1,175 @@ -# [topgg](https://crates.io/crates/topgg) [![crates.io][crates-io-image]][crates-io-url] [![crates.io downloads][crates-io-downloads-image]][crates-io-url] +# [Top.gg Rust SDK](https://crates.io/crates/topgg) [![crates.io][crates-io-image]][crates-io-url] [![crates.io downloads][crates-io-downloads-image]][crates-io-url] [crates-io-image]: https://img.shields.io/crates/v/topgg?style=flat-square [crates-io-downloads-image]: https://img.shields.io/crates/d/topgg?style=flat-square [crates-io-url]: https://crates.io/crates/topgg -The official Rust SDK for the [Top.gg API](https://docs.top.gg). +> For more information, see the documentation here: https://docs.rs/topgg. -## Getting Started +The community-maintained Rust library for Top.gg. -Make sure to have a [Top.gg API](https://docs.top.gg) token handy. If not, then [view this tutorial on how to retrieve yours](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). After that, add the following line to the `dependencies` section of your `Cargo.toml`: +## Chapters + +- [Installation](#installation) +- [Features](#features) +- [Setting up](#setting-up) +- [Usage](#usage) + - [Getting your project's information](#getting-your-projects-information) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) + - [Getting a cursor-based paginated list of votes for your project](#getting-a-cursor-based-paginated-list-of-votes-for-your-project) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + +## Installation + +Add the following line to the `dependencies` section of your `Cargo.toml`: ```toml -topgg = "1.4" +topgg = "2" ``` -For more information, please read [the documentation](https://docs.rs/topgg)! - ## Features -This library provides several feature flags that can be enabled/disabled in `Cargo.toml`. Such as: +This library provides several feature flags that can be enabled/disabled in `Cargo.toml`, such as: - **`api`**: Interacting with the [Top.gg API](https://docs.top.gg) and accessing the `top.gg/api/*` endpoints. (enabled by default) - - **`autoposter`**: Automating the process of periodically posting bot statistics to the [Top.gg API](https://docs.top.gg). -- **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Vote` struct. +- **`webhooks`**: Accessing [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) webhook payload structs. - **`actix-web`**: Wrapper for working with the [actix-web](https://actix.rs/) web framework. - **`axum`**: Wrapper for working with the [axum](https://crates.io/crates/axum) web framework. - **`rocket`**: Wrapper for working with the [rocket](https://rocket.rs/) web framework. - **`warp`**: Wrapper for working with the [warp](https://crates.io/crates/warp) web framework. -- **`serenity`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching disabled). - - **`serenity-cached`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching enabled). -- **`twilight`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching disabled). - - **`twilight-cached`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching enabled). - -## Examples +- **`serenity`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity). +- **`twilight`**: Extra helpers for working with [twilight](https://twilight.rs). -### Fetching a user from its Discord ID +## Setting up ```rust,no_run -use topgg::Client; +let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); +``` -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); - let user = client.get_user(661200758510977084).await.unwrap(); - - assert_eq!(user.username, "null"); - assert_eq!(user.id, 661200758510977084); - - println!("{:?}", user); -} +## Usage + +### Getting your project's information + +```rust,no_run +let project = client.get_self().await.unwrap(); ``` -### Posting your bot's statistics +### Getting your project's vote information of a user + +#### Discord ID ```rust,no_run -use topgg::{Client, Stats}; +let vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); +``` -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); +#### Top.gg ID - let server_count = 12345; - client - .post_stats(Stats::from(server_count)) - .await - .unwrap(); -} +```rust,no_run +let vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); ``` -### Checking if a user has voted your bot +### Getting a cursor-based paginated list of votes for your project ```rust,no_run -use topgg::Client; +use chrono::{TimeZone, Utc}; -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); +let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); +let first_page = client.get_votes(since).await.unwrap(); - if client.has_voted(661200758510977084).await.unwrap() { - println!("checks out"); - } +for vote in first_page.iter() { + println!("{vote:?}"); } -``` -### Autoposting with [serenity](https://crates.io/crates/serenity) +let second_page = first_page.next().await.unwrap(); -In your `Cargo.toml`: +for vote in second_page.iter() { + println!("{vote:?}"); +} +``` -```toml -[dependencies] -# using serenity with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "serenity"] } +### Posting your bot's application commands list -# using serenity with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "serenity-cached"] } +#### Serenity + +```rust,no_run +client.post_commands(&ctx).await.unwrap(); ``` -In your code: +#### Twilight ```rust,no_run -use core::time::Duration; -use serenity::{client::{Client, Context, EventHandler}, model::{channel::Message, gateway::Ready}}; -use topgg::Autoposter; - -struct Handler; - -#[serenity::async_trait] -impl EventHandler for Handler { - async fn message(&self, ctx: Context, msg: Message) { - if msg.content == "!ping" { - if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await { - println!("Error sending message: {why:?}"); - } - } - } +let application_id = bot.current_user_application().await.unwrap().model().await.unwrap().id; +let interaction = bot.interaction(application_id); - async fn ready(&self, _: Context, ready: Ready) { - println!("{} is connected!", ready.user.name); - } -} +client.post_commands(interaction.global_commands()).await.unwrap(); +``` -#[tokio::main] -async fn main() { - let topgg_client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - let autoposter = Autoposter::serenity(&topgg_client, Duration::from_secs(1800)); - - let bot_token = env!("DISCORD_TOKEN").to_string(); - let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::GUILDS | GatewayIntents::MESSAGE_CONTENT; - - let mut client = Client::builder(&bot_token, intents) - .event_handler(Handler) - .event_handler_arc(autoposter.handler()) - .await - .unwrap(); +#### Raw - if let Err(why) = client.start().await { - println!("Client error: {why:?}"); - } -} +```rust,no_run +let commands = json!([{ + "id": "1", + "type": 1, + "application_id": "1", + "name": "test", + "description": "command description", + "default_member_permissions": "", + "version": "1" +}]); // Array of application commands that + // can be serialized to Discord API's raw JSON format. + +client.post_commands(commands).await.unwrap(); ``` -### Autoposting with [twilight](https://twilight.rs) +### Generating widget URLs -In your `Cargo.toml`: - -```toml -[dependencies] -# using twilight with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "twilight"] } +#### Large -# using twilight with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "twilight-cached"] } +```rust,no_run +let widget_url = topgg::widget::large(topgg::ProjectType::DiscordBot, 574652751745777665); ``` -In your code: +#### Votes ```rust,no_run -use core::time::Duration; -use topgg::Autoposter; -use twilight_gateway::{Event, Intents, Shard, ShardId}; +let widget_url = topgg::widget::votes(topgg::ProjectType::DiscordBot, 574652751745777665); +``` -#[tokio::main] -async fn main() { - let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - let autoposter = Autoposter::twilight(&client, Duration::from_secs(1800)); +#### Owner - let mut shard = Shard::new( - ShardId::ONE, - env!("DISCORD_TOKEN").to_string(), - Intents::GUILD_MEMBERS | Intents::GUILDS, - ); +```rust,no_run +let widget_url = topgg::widget::owner(topgg::ProjectType::DiscordBot, 574652751745777665); +``` - loop { - let event = match shard.next_event().await { - Ok(event) => event, - Err(source) => { - if source.is_fatal() { - break; - } - - continue; - } - }; - - autoposter.handle(&event).await; - - match event { - Event::Ready(_) => { - println!("Bot is ready!"); - }, +#### Social - _ => {} - } - } -} +```rust,no_run +let widget_url = topgg::widget::social(topgg::ProjectType::DiscordBot, 574652751745777665); ``` -### Writing an [actix-web](https://actix.rs) webhook for listening to votes +### Webhooks + +#### Actix-web In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["actix-web"] } +topgg = { version = "2", default-features = false, features = ["actix-web"] } ``` In your code: ```rust,no_run +use topgg::IncomingPayload; +use std::io; + use actix_web::{ error::{Error, ErrorUnauthorized}, get, post, App, HttpServer, }; -use std::io; -use topgg::IncomingVote; #[get("/")] async fn index() -> &'static str { @@ -221,13 +177,14 @@ async fn index() -> &'static str { } #[post("/webhook")] -async fn webhook(vote: IncomingVote) -> Result<&'static str, Error> { - match vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { - Some(vote) => { - println!("{:?}", vote); +async fn webhook(payload: IncomingPayload) -> Result<&'static str, Error> { + match payload.authenticate(env!("TOPGG_WEBHOOK_SECRET")) { + Some(payload) => { + println!("{payload:?}"); Ok("ok") } + _ => Err(ErrorUnauthorized("401")), } } @@ -241,28 +198,32 @@ async fn main() -> io::Result<()> { } ``` -### Writing an [axum](https://crates.io/crates/axum) webhook for listening to votes +#### Axum In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["axum"] } +topgg = { version = "2", default-features = false, features = ["axum"] } ``` In your code: ```rust,no_run -use axum::{routing::get, Router, Server}; -use std::{net::SocketAddr, sync::Arc}; -use topgg::{Vote, VoteHandler}; +use topgg::Payload; +use std::sync::Arc; + +use axum::{http::status::StatusCode, response::{IntoResponse, Response}, routing::get, Router}; +use tokio::net::TcpListener; -struct MyVoteHandler {} +struct MyTopggListener {} -#[axum::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); +#[async_trait::async_trait] +impl topgg::axum::Listener for MyTopggListener { + async fn callback(self: Arc, payload: Payload, _trace: &str) -> Response { + println!("{payload:?}"); + + (StatusCode::NO_CONTENT, ()).into_response() } } @@ -272,49 +233,45 @@ async fn index() -> &'static str { #[tokio::main] async fn main() { - let state = Arc::new(MyVoteHandler {}); + let state = Arc::new(MyTopggListener {}); - let app = Router::new().route("/", get(index)).nest( + let router = Router::new().route("/", get(index)).nest( "/webhook", - topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), + topgg::axum::webhook(Arc::clone(&state), env!("TOPGG_WEBHOOK_SECRET").to_string()), ); - let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); - Server::bind(&addr) - .serve(app.into_make_service()) - .await - .unwrap(); + axum::serve(listener, router).await.unwrap(); } ``` -### Writing a [rocket](https://rocket.rs) webhook for listening to votes +#### Rocket In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["rocket"] } +topgg = { version = "2", default-features = false, features = ["rocket"] } ``` In your code: ```rust,no_run -#![feature(decl_macro)] +use topgg::IncomingPayload; -use rocket::{get, http::Status, post, routes}; -use topgg::IncomingVote; +use rocket::{get, http::Status, launch, post, routes, Build, Rocket}; #[get("/")] fn index() -> &'static str { "Hello, World!" } -#[post("/webhook", data = "")] -fn webhook(vote: IncomingVote) -> Status { - match vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { - Some(vote) => { - println!("{:?}", vote); +#[post("/webhook", data = "")] +fn webhook(payload: IncomingPayload) -> Status { + match payload.authenticate(env!("TOPGG_WEBHOOK_SECRET")) { + Some(payload) => { + println!("{payload:?}"); Status::Ok }, @@ -326,48 +283,45 @@ fn webhook(vote: IncomingVote) -> Status { } } -fn main() { - rocket::ignite() - .mount("/", routes![index, webhook]) - .launch(); +#[launch] +fn rocket() -> Rocket { + rocket::build().mount("/", routes![index, webhook]) } ``` -### Writing a [warp](https://crates.io/crates/warp) webhook for listening to votes +#### Warp In your `Cargo.toml`: ```toml [dependencies] -topgg = { version = "1.4", default-features = false, features = ["warp"] } +topgg = { version = "2", default-features = false, features = ["warp"] } ``` In your code: ```rust,no_run -use std::{net::SocketAddr, sync::Arc}; -use topgg::{Vote, VoteHandler}; -use warp::Filter; +use std::net::SocketAddr; -struct MyVoteHandler {} - -#[async_trait::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); - } -} +use warp::{http::StatusCode, reply, Filter}; #[tokio::main] async fn main() { - let state = Arc::new(MyVoteHandler {}); - // POST /webhook let webhook = topgg::warp::webhook( "webhook", - env!("TOPGG_WEBHOOK_PASSWORD").to_string(), - Arc::clone(&state), - ); + env!("TOPGG_WEBHOOK_SECRET").to_string() + ).then(|payload, _trace| async move { + match payload { + Some(payload) => { + println!("{payload:?}"); + + reply::with_status("", StatusCode::NO_CONTENT) + }, + + None => reply::with_status("Unauthorized", StatusCode::UNAUTHORIZED) + } + }); let routes = warp::get().map(|| "Hello, World!").or(webhook); diff --git a/src/bot.rs b/src/bot.rs deleted file mode 100644 index b11436b..0000000 --- a/src/bot.rs +++ /dev/null @@ -1,206 +0,0 @@ -use crate::{snowflake, util, Client}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::{ - cmp::min, - collections::HashMap, - fmt::Write, - future::{Future, IntoFuture}, - pin::Pin, -}; - -/// A Discord bot's reviews on Top.gg. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct BotReviews { - /// This bot's average review score out of 5. - #[serde(rename = "averageScore")] - pub score: f64, - - /// This bot's review count. - pub count: usize, -} - -/// A Discord bot listed on Top.gg. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct Bot { - /// This bot's Discord ID. - #[serde(rename = "clientid", deserialize_with = "snowflake::deserialize")] - pub id: u64, - - /// This bot's Top.gg ID. - #[serde(rename = "id", deserialize_with = "snowflake::deserialize")] - pub topgg_id: u64, - - /// This bot's username. - #[serde(rename = "username")] - pub name: String, - - /// This bot's prefix. - pub prefix: String, - - /// This bot's short description. - #[serde(rename = "shortdesc")] - pub short_description: String, - - /// This bot's HTML/Markdown long description. - #[serde( - default, - deserialize_with = "util::deserialize_optional_string", - rename = "longdesc" - )] - pub long_description: Option, - - /// This bot's tags. - #[serde(deserialize_with = "util::deserialize_default")] - pub tags: Vec, - - /// This bot's website URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub website: Option, - - /// This bot's GitHub repository URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub github: Option, - - /// This bot's owner IDs. - #[serde(deserialize_with = "snowflake::deserialize_vec")] - pub owners: Vec, - - /// This bot's submission date. - #[serde(rename = "date")] - pub submitted_at: DateTime, - - /// The amount of votes this bot has. - #[serde(rename = "points")] - pub votes: usize, - - /// The amount of votes this bot has this month. - #[serde(rename = "monthlyPoints")] - pub monthly_votes: usize, - - /// This bot's support URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub support: Option, - - /// This bot's avatar URL. - pub avatar: String, - - /// This bot's invite URL. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub invite: Option, - - /// This bot's Top.gg vanity code. - #[serde(default, deserialize_with = "util::deserialize_optional_string")] - pub vanity: Option, - - /// This bot's posted server count. - #[serde(default)] - pub server_count: Option, - - /// This bot's reviews. - #[serde(rename = "reviews")] - pub review: BotReviews, -} - -#[derive(Serialize, Deserialize)] -pub(crate) struct BotStats { - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) server_count: Option, -} - -#[derive(Deserialize)] -pub(crate) struct Bots { - pub(crate) results: Vec, -} - -#[derive(Deserialize)] -pub(crate) struct IsWeekend { - pub(crate) is_weekend: bool, -} - -/// Query for [`Client::get_bots`]. -#[must_use] -pub struct BotQuery<'a> { - client: &'a Client, - query: HashMap<&'static str, String>, - sort: Option<&'static str>, -} - -macro_rules! get_bots_method { - ($( - $(#[doc = $doc:literal])* - $lib_name:ident: $lib_type:ty = $property:ident($api_name:ident, $lib_value:expr); - )*) => {$( - $(#[doc = $doc])* - pub fn $lib_name(mut self, $lib_name: $lib_type) -> Self { - self.$property.insert(stringify!($api_name), $lib_value); - self - } - )*}; -} - -macro_rules! get_bots_sort { - ($( - $(#[doc = $doc:literal])* - $func_name:ident: $api_name:ident, - )*) => {$( - $(#[doc = $doc])* - pub fn $func_name(mut self) -> Self { - self.sort.replace(stringify!($api_name)); - self - } - )*}; -} - -impl<'a> BotQuery<'a> { - #[inline(always)] - pub(crate) fn new(client: &'a Client) -> Self { - Self { - client, - query: HashMap::new(), - sort: None, - } - } - - get_bots_sort! { - /// Sorts results based on each bot's ID. - sort_by_id: id, - - /// Sorts results based on each bot's submission date. - sort_by_submission_date: date, - - /// Sorts results based on each bot's monthly vote count. - sort_by_monthly_votes: monthlyPoints, - } - - get_bots_method! { - /// Sets the maximum amount of bots to be returned. - limit: u16 = query(limit, min(limit, 500).to_string()); - - /// Sets the amount of bots to be skipped. - skip: u16 = query(offset, min(skip, 499).to_string()); - } -} - -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/bot_autoposter/client.rs b/src/bot_autoposter/client.rs deleted file mode 100644 index 35118a6..0000000 --- a/src/bot_autoposter/client.rs +++ /dev/null @@ -1,18 +0,0 @@ -use crate::InnerClient; -use std::sync::Arc; - -pub trait AsClientSealed { - fn as_client(&self) -> Arc; -} - -/// Any datatype that can be interpreted as a [`Client`][crate::Client]. -pub trait AsClient: AsClientSealed {} - -impl AsClientSealed for str { - #[inline(always)] - fn as_client(&self) -> Arc { - Arc::new(InnerClient::new(String::from(self))) - } -} - -impl AsClient for str {} diff --git a/src/bot_autoposter/mod.rs b/src/bot_autoposter/mod.rs deleted file mode 100644 index a761313..0000000 --- a/src/bot_autoposter/mod.rs +++ /dev/null @@ -1,354 +0,0 @@ -use crate::Result; -use std::{ops::Deref, sync::Arc, time::Duration}; -use tokio::{ - sync::mpsc, - task::{spawn, JoinHandle}, - time::sleep, -}; - -mod client; - -pub use client::AsClient; -pub(crate) use client::AsClientSealed; - -cfg_if::cfg_if! { - if #[cfg(feature = "serenity")] { - mod serenity_impl; - - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] - pub use serenity_impl::Serenity; - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "twilight")] { - mod twilight_impl; - - #[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] - pub use twilight_impl::Twilight; - } -} - -/// Handle events from third-party Discord bot libraries. -/// -/// Structs that implement this ideally should own a `RwLock` instance and update it accordingly whenever Discord sends them new data regarding their server count. -#[async_trait::async_trait] -pub trait BotAutoposterHandler: Send + Sync + 'static { - /// The bot's latest server count. - async fn server_count(&self) -> usize; -} - -/// Automatically update the server count in your Discord bot's Top.gg page every few minutes. -/// -/// **NOTE**: This struct owns the Discord bot autoposter thread which means that it will stop once it gets dropped. -/// -/// # Examples -/// -/// Serenity: -/// -/// ```rust,no_run -/// use std::time::Duration; -/// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; -/// use topgg::BotAutoposter; -/// -/// struct BotAutoposterHandler; -/// -/// #[serenity::async_trait] -/// impl EventHandler for BotAutoposterHandler { -/// async fn ready(&self, _: Context, ready: Ready) { -/// println!("{} is now ready!", ready.user.name); -/// } -/// } -/// -/// #[tokio::main] -/// async fn main() { -/// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); -/// -/// // Posts once every 30 minutes -/// let mut bot_autoposter = BotAutoposter::serenity(&client, Duration::from_secs(1800)); -/// -/// let bot_token = env!("BOT_TOKEN").to_string(); -/// let intents = GatewayIntents::GUILDS; -/// -/// let mut bot = Client::builder(&bot_token, intents) -/// .event_handler(BotAutoposterHandler) -/// .event_handler_arc(bot_autoposter.handler()) -/// .await -/// .unwrap(); -/// -/// let mut receiver = bot_autoposter.receiver(); -/// -/// tokio::spawn(async move { -/// while let Some(result) = receiver.recv().await { -/// println!("Just posted: {result:?}"); -/// } -/// }); -/// -/// if let Err(why) = bot.start().await { -/// println!("Client error: {why:?}"); -/// } -/// } -/// ``` -/// -/// Twilight: -/// -/// ```rust,no_run -/// use std::time::Duration; -/// use topgg::{BotAutoposter, Client}; -/// use twilight_gateway::{Event, Intents, Shard, ShardId}; -/// -/// #[tokio::main] -/// async fn main() { -/// let client = Client::new(env!("TOPGG_TOKEN").to_string()); -/// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); -/// -/// let mut shard = Shard::new( -/// ShardId::ONE, -/// env!("BOT_TOKEN").to_string(), -/// Intents::GUILD_MESSAGES | Intents::GUILDS, -/// ); -/// -/// loop { -/// let event = match shard.next_event().await { -/// Ok(event) => event, -/// Err(source) => { -/// if source.is_fatal() { -/// break; -/// } -/// -/// continue; -/// } -/// }; -/// -/// bot_autoposter.handle(&event).await; -/// -/// match event { -/// Event::Ready(_) => { -/// println!("Bot is now ready!"); -/// }, -/// -/// _ => {} -/// } -/// } -/// } -/// ``` -#[must_use] -pub struct BotAutoposter { - handler: Arc, - thread: JoinHandle<()>, - receiver: Option>>, -} - -impl BotAutoposter -where - H: BotAutoposterHandler, -{ - /// Creates and starts a Discord bot autoposter thread. - #[allow(unused_mut)] - pub fn new(client: &C, handler: H, mut interval: Duration) -> Self - where - C: AsClient, - { - #[cfg(not(test))] - if interval.as_secs() < 900 { - interval = Duration::from_secs(900); - } - - let client = client.as_client(); - let handler = Arc::new(handler); - let local_handler = Arc::clone(&handler); - let (sender, receiver) = mpsc::unbounded_channel(); - - Self { - handler: local_handler, - thread: spawn(async move { - loop { - cfg_if::cfg_if! { - if #[cfg(test)] { - let server_count = 3; - } else { - let server_count = handler.server_count().await; - } - } - - if sender - .send( - client - .post_bot_server_count(server_count) - .await - .map(|()| server_count), - ) - .is_err() - { - break; - } - - sleep(interval).await; - } - }), - receiver: Some(receiver), - } - } - - /// This Discord bot autoposter's handler. - #[inline(always)] - pub fn handler(&self) -> Arc { - Arc::clone(&self.handler) - } - - /// Returns a future that resolves whenever an attempt to update the server count in your bot's Top.gg page has been made. The `usize` in this case is the server count that was just posted. - /// - /// **NOTE**: If you want to use the receiver directly, call [`receiver`][BotAutoposter::receiver]. - /// - /// # Panics - /// - /// Panics if this method gets called again after [`receiver`][BotAutoposter::receiver] is called. - #[inline(always)] - pub async fn recv(&mut self) -> Option> { - self.receiver.as_mut().expect("The receiver is already taken from the receiver() method. please call recv() directly from the receiver.").recv().await - } - - /// Takes the receiver responsible for [`recv`][BotAutoposter::recv]. - /// - /// # Panics - /// - /// Panics if this method gets called for the second time. - #[inline(always)] - pub fn receiver(&mut self) -> mpsc::UnboundedReceiver> { - self - .receiver - .take() - .expect("receiver() can only be called once.") - } -} - -impl Deref for BotAutoposter { - type Target = H; - - #[inline(always)] - fn deref(&self) -> &Self::Target { - &self.handler - } -} - -#[cfg(feature = "serenity")] -#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] -impl BotAutoposter { - /// Creates and starts a serenity-based Discord bot autoposter thread. - /// - /// # Example - /// - /// ```rust,no_run - /// use std::time::Duration; - /// use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; - /// use topgg::BotAutoposter; - /// - /// struct BotAutoposterHandler; - /// - /// #[serenity::async_trait] - /// impl EventHandler for BotAutoposterHandler { - /// async fn ready(&self, _: Context, ready: Ready) { - /// println!("{} is now ready!", ready.user.name); - /// } - /// } - /// - /// #[tokio::main] - /// async fn main() { - /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - /// - /// // Posts once every 30 minutes - /// let mut bot_autoposter = BotAutoposter::serenity(&client, Duration::from_secs(1800)); - /// - /// let bot_token = env!("BOT_TOKEN").to_string(); - /// let intents = GatewayIntents::GUILDS; - /// - /// let mut bot = Client::builder(&bot_token, intents) - /// .event_handler(BotAutoposterHandler) - /// .event_handler_arc(bot_autoposter.handler()) - /// .await - /// .unwrap(); - /// - /// let mut receiver = bot_autoposter.receiver(); - /// - /// tokio::spawn(async move { - /// while let Some(result) = receiver.recv().await { - /// println!("Just posted: {result:?}"); - /// } - /// }); - /// - /// if let Err(why) = bot.start().await { - /// println!("Client error: {why:?}"); - /// } - /// } - /// ``` - #[inline(always)] - pub fn serenity(client: &C, interval: Duration) -> Self - where - C: AsClient, - { - Self::new(client, Serenity::new(), interval) - } -} - -#[cfg(feature = "twilight")] -#[cfg_attr(docsrs, doc(cfg(feature = "twilight")))] -impl BotAutoposter { - /// Creates and starts a twilight-based Discord bot autoposter thread. - /// - /// # Example - /// - /// ```rust,no_run - /// use std::time::Duration; - /// use topgg::{BotAutoposter, Client}; - /// use twilight_gateway::{Event, Intents, Shard, ShardId}; - /// - /// #[tokio::main] - /// async fn main() { - /// let client = Client::new(env!("TOPGG_TOKEN").to_string()); - /// let bot_autoposter = BotAutoposter::twilight(&client, Duration::from_secs(1800)); - /// - /// let mut shard = Shard::new( - /// ShardId::ONE, - /// env!("BOT_TOKEN").to_string(), - /// Intents::GUILD_MESSAGES | Intents::GUILDS, - /// ); - /// - /// loop { - /// let event = match shard.next_event().await { - /// Ok(event) => event, - /// Err(source) => { - /// if source.is_fatal() { - /// break; - /// } - /// - /// continue; - /// } - /// }; - /// - /// bot_autoposter.handle(&event).await; - /// - /// match event { - /// Event::Ready(_) => { - /// println!("Bot is now ready!"); - /// }, - /// - /// _ => {} - /// } - /// } - /// } - /// ``` - #[inline(always)] - pub fn twilight(client: &C, interval: Duration) -> Self - where - C: AsClient, - { - Self::new(client, Twilight::new(), interval) - } -} - -impl Drop for BotAutoposter { - #[inline(always)] - fn drop(&mut self) { - self.thread.abort(); - } -} diff --git a/src/bot_autoposter/serenity_impl.rs b/src/bot_autoposter/serenity_impl.rs deleted file mode 100644 index 327344f..0000000 --- a/src/bot_autoposter/serenity_impl.rs +++ /dev/null @@ -1,202 +0,0 @@ -use crate::bot_autoposter::BotAutoposterHandler; -use paste::paste; -use serenity::{ - client::{Context, EventHandler, FullEvent}, - model::{ - gateway::Ready, - guild::{Guild, UnavailableGuild}, - id::GuildId, - }, -}; -use tokio::sync::RwLock; - -cfg_if::cfg_if! { - if #[cfg(not(feature = "serenity-cached"))] { - use std::collections::HashSet; - use tokio::sync::Mutex; - - struct Cache { - guilds: HashSet, - } - } -} - -/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the serenity library. -#[must_use] -pub struct Serenity { - #[cfg(not(feature = "serenity-cached"))] - cache: Mutex, - server_count: RwLock, -} - -macro_rules! serenity_handler { - ( - ($self:ident, $context: ident) => {$( - $(#[$attr:meta])? - $handler_name:ident { - map($($map_arg_name:ident: $map_arg_type:ty),*) $map_expr:tt - handle($($(#[$handle_arg_attr:meta])?$handle_arg_name:ident: $handle_arg_type:ty),*) $handle_expr:tt - } - )*} - ) => { - paste! { - #[allow(unused_variables)] - impl Serenity { - #[inline(always)] - pub(super) fn new() -> Self { - Self { - #[cfg(not(feature = "serenity-cached"))] - cache: Mutex::const_new(Cache { - guilds: HashSet::new(), - }), - server_count: RwLock::new(0), - } - } - - /// 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 { - $( - $(#[$attr])? - FullEvent::[<$handler_name:camel>] { $($map_arg_name),* } => $map_expr, - )* - - _ => {} - } - } - - $( - $(#[$attr])? - async fn []( - &$self, - $( - $(#[$handle_arg_attr])? $handle_arg_name: $handle_arg_type, - )* - ) $handle_expr - )* - } - - #[serenity::async_trait] - #[allow(unused_variables)] - impl EventHandler for Serenity { - $( - #[inline(always)] - $(#[$attr])? - async fn $handler_name(&$self, $context: Context, $($map_arg_name: $map_arg_type),*) $map_expr - )* - } - } - }; -} - -serenity_handler! { - (self, context) => { - ready { - map(data_about_bot: Ready) { - self.handle_ready(&data_about_bot.guilds).await; - } - - handle(guilds: &[UnavailableGuild]) { - let mut server_count = self.server_count.write().await; - - *server_count = guilds.len(); - - cfg_if::cfg_if! { - if #[cfg(not(feature = "serenity-cached"))] { - let mut cache = self.cache.lock().await; - - cache.guilds = guilds.iter().map(|x| x.id).collect(); - } - } - } - } - - #[cfg(feature = "serenity-cached")] - cache_ready { - map(guilds: Vec) { - self.handle_cache_ready(guilds.len()).await; - } - - handle(guild_count: usize) { - let mut server_count = self.server_count.write().await; - - *server_count = guild_count; - } - } - - guild_create { - map(guild: Guild, is_new: Option) { - self.handle_guild_create( - #[cfg(not(feature = "serenity-cached"))] guild.id, - #[cfg(feature = "serenity-cached")] context.cache.guilds().len(), - #[cfg(feature = "serenity-cached")] is_new.expect("serenity-cached feature is enabled but the bot doesn't cache guilds."), - ).await; - } - - handle( - #[cfg(not(feature = "serenity-cached"))] guild_id: GuildId, - #[cfg(feature = "serenity-cached")] guild_count: usize, - #[cfg(feature = "serenity-cached")] is_new: bool) { - cfg_if::cfg_if! { - if #[cfg(feature = "serenity-cached")] { - if is_new { - let mut server_count = self.server_count.write().await; - - *server_count = guild_count; - } - } else { - let mut cache = self.cache.lock().await; - - if cache.guilds.insert(guild_id) { - let mut server_count = self.server_count.write().await; - - *server_count = cache.guilds.len(); - } - } - } - } - } - - guild_delete { - map(incomplete: UnavailableGuild, full: Option) { - self.handle_guild_delete( - #[cfg(feature = "serenity-cached")] context.cache.guilds().len(), - #[cfg(not(feature = "serenity-cached"))] incomplete.id - ).await; - } - - handle( - #[cfg(feature = "serenity-cached")] guild_count: usize, - #[cfg(not(feature = "serenity-cached"))] guild_id: GuildId) { - cfg_if::cfg_if! { - if #[cfg(feature = "serenity-cached")] { - let mut server_count = self.server_count.write().await; - - *server_count = guild_count; - } else { - let mut cache = self.cache.lock().await; - - if cache.guilds.remove(&guild_id) { - let mut server_count = self.server_count.write().await; - - *server_count = cache.guilds.len(); - } - } - } - } - } - } -} - -#[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 deleted file mode 100644 index 69886ad..0000000 --- a/src/bot_autoposter/twilight_impl.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::bot_autoposter::BotAutoposterHandler; -use std::collections::HashSet; -use tokio::sync::{Mutex, RwLock}; -use twilight_model::gateway::event::Event; - -/// [`BotAutoposter`][crate::BotAutoposter] handler for working with the twilight. -pub struct Twilight { - cache: Mutex>, - server_count: RwLock, -} - -impl Twilight { - #[inline(always)] - pub(super) fn new() -> Self { - Self { - cache: Mutex::const_new(HashSet::new()), - server_count: RwLock::new(0), - } - } - - /// Handles an entire twilight [`Event`] enum. - pub async fn handle(&self, event: &Event) { - match event { - Event::Ready(ready) => { - let mut cache: tokio::sync::MutexGuard<'_, HashSet> = self.cache.lock().await; - let mut server_count = self.server_count.write().await; - let cache_ref = &mut *cache; - - *cache_ref = ready.guilds.iter().map(|guild| guild.id.get()).collect(); - *server_count = cache.len(); - } - - Event::GuildCreate(guild_create) => { - let mut cache = self.cache.lock().await; - - if cache.insert(guild_create.id.get()) { - let mut server_count = self.server_count.write().await; - - *server_count = cache.len(); - } - } - - Event::GuildDelete(guild_delete) => { - let mut cache = self.cache.lock().await; - - if cache.remove(&guild_delete.id.get()) { - let mut server_count = self.server_count.write().await; - - *server_count = cache.len(); - } - } - - _ => {} - } - } -} - -#[async_trait::async_trait] -impl BotAutoposterHandler for Twilight { - async fn server_count(&self) -> usize { - let guard = self.server_count.read().await; - - *guard - } -} diff --git a/src/client.rs b/src/client.rs index ea85b09..a615004 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,33 +1,16 @@ -use crate::{ - bot::{Bot, BotQuery, BotStats, Bots, IsWeekend}, - util, - vote::{Voted, Voter}, - Error, Result, Snowflake, +use super::{ + Error, GetCommands, PaginatedVotes, PaginatedVotesOwned, PartialVote, PostCommandsError, + PostCommandsResult, Project, Result, Snowflake, UserSource, util, }; -use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; -use serde::{de::DeserializeOwned, Deserialize}; -cfg_if::cfg_if! { - if #[cfg(feature = "bot-autoposter")] { - use crate::bot_autoposter; - use std::sync::Arc; - - type SyncedClient = Arc; - } else { - type SyncedClient = InnerClient; - } -} - -#[derive(Deserialize)] -#[serde(rename = "kebab-case")] -struct Ratelimit { - retry_after: u16, -} +use chrono::{DateTime, SecondsFormat, TimeZone}; +use reqwest::{IntoUrl, Method, Response, StatusCode, Version, header}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; #[macro_export] macro_rules! api { ($e:literal) => { - concat!("https://top.gg/api", $e) + concat!("https://top.gg/api/v1", $e) }; ($e:literal, $($rest:tt)*) => { @@ -35,29 +18,33 @@ macro_rules! api { }; } -pub(crate) use api; - -pub struct InnerClient { - http: reqwest::Client, - token: String, - id: u64, -} +pub(super) use api; #[derive(Deserialize)] -pub(crate) struct ErrorJson { - #[serde(default, alias = "message", alias = "detail")] - message: Option, +#[serde(rename = "kebab-case")] +struct Ratelimit { + retry_after: u16, } -// This is implemented here because the Discord bot autoposter needs to access this struct from a different thread. -impl InnerClient { - pub(crate) fn new(token: String) -> Self { - let id = util::parse_api_token(&token); +/// Interact with API v1's endpoints. +#[must_use] +pub struct Client { + http: reqwest::Client, + token: String, +} +impl Client { + /// Creates a new instance. + /// + /// # Example + /// + /// ```rust,no_run + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// ``` + pub fn new(token: String) -> Self { Self { http: reqwest::Client::new(), - token, - id, + token: format!("Bearer {token}"), } } @@ -91,18 +78,16 @@ impl InnerClient { } else { Err(match status { StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => panic!("Invalid API token."), - StatusCode::NOT_FOUND => Error::NotFound( - util::parse_json::(response) - .await - .ok() - .and_then(|err| err.message), - ), - StatusCode::TOO_MANY_REQUESTS => match util::parse_json::(response).await { - Ok(ratelimit) => Error::Ratelimit { + + StatusCode::NOT_FOUND => Error::NotFound, + + StatusCode::TOO_MANY_REQUESTS => util::parse_json::(response).await.map_or( + Error::InternalServerError, + |ratelimit| Error::Ratelimit { retry_after: ratelimit.retry_after, }, - _ => Error::InternalServerError, - }, + ), + _ => Error::InternalServerError, }) } @@ -112,103 +97,40 @@ impl InnerClient { } } - #[inline(always)] - pub(crate) async fn send( - &self, - method: Method, - url: impl IntoUrl, - body: Option>, - ) -> Result + async fn send(&self, method: Method, url: impl IntoUrl, body: Option>) -> Result where T: DeserializeOwned, { match self.send_inner(method, url, body.unwrap_or_default()).await { Ok(response) => util::parse_json(response).await, - Err(err) => Err(err), - } - } - pub(crate) async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { - if server_count == 0 { - return Err(Error::InvalidRequest); + Err(err) => Err(err), } - - self - .send_inner( - Method::POST, - api!("/bots/stats"), - serde_json::to_vec(&BotStats { - server_count: Some(server_count), - }) - .unwrap(), - ) - .await - .map(|_| ()) } -} - -/// Interact with the API's endpoints. -#[must_use] -pub struct Client { - inner: SyncedClient, -} -impl Client { - /// Creates a new instance. - /// - /// To retrieve your API token, [see this tutorial](https://github.com/top-gg-community/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). + /// Tries to get your project's information. /// /// # 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 = "bot-autoposter")] - let inner = Arc::new(inner); - - Self { inner } - } - - /// Fetches a Discord bot from its ID. - /// - /// # Panics - /// - /// Panics if: - /// - The specified ID is invalid. - /// - The client uses an invalid API token. - /// /// # Errors /// /// Returns [`Err`] if: - /// - The specified bot does not exist. ([`NotFound`][crate::Error::NotFound]) - /// - 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]) + /// - HTTP request failure from the client-side. ([`InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][super::Error::Ratelimit]) /// /// # Example /// /// ```rust,no_run - /// let bot = client.get_bot(264811613708746752).await.unwrap(); + /// let project = client.get_self().await.unwrap(); /// ``` - pub async fn get_bot(&self, id: I) -> Result - where - I: Snowflake, - { - self - .inner - .send(Method::GET, api!("/bots/{}", id.as_snowflake()), None) - .await + pub async fn get_self(&self) -> Result { + self.send(Method::GET, api!("/projects/@me"), None).await } - /// Fetches your Discord bot's posted server count. + /// Tries to update the application commands list in your Discord bot's Top.gg page. /// /// # Panics /// @@ -217,96 +139,119 @@ impl Client { /// # 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]) + /// - Unable to retrieve the list of bot commands. ([`PostCommandsError::Retrieval`][super::PostCommandsError::Retrieval]) + /// - Unable to serialize the list of bot commands. ([`PostCommandsError::Serialization`][super::PostCommandsError::Serialization]) + /// - The list of bot commands supplied do not match [Discord API's raw JSON format](https://discord.com/developers/docs/interactions/application-commands#application-command-object). ([`Error::InvalidRequest`][super::Error::InvalidRequest]) + /// - HTTP request failure from the client-side. ([`Error::InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`Error::InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Error::Ratelimit`][super::Error::Ratelimit]) /// /// # Example /// /// ```rust,no_run - /// let server_count = client.get_bot_server_count().await.unwrap(); + /// // Serenity: + /// client.post_commands(&ctx).await.unwrap(); + /// + /// // Twilight: + /// let application_id = bot.current_user_application().await.unwrap().model().await.unwrap().id; + /// let interaction = bot.interaction(application_id); + /// + /// client.post_commands(interaction.global_commands()).await.unwrap(); + /// + /// // Others: + /// let commands = json!([{ + /// "id": "1", + /// "type": 1, + /// "application_id": "1", + /// "name": "test", + /// "description": "command description", + /// "default_member_permissions": "", + /// "version": "1" + /// }]); // Array of application commands that + /// // can be serialized to Discord API's raw JSON format. + /// + /// client.post_commands(commands).await.unwrap(); /// ``` - pub async fn get_bot_server_count(&self) -> Result> { - self - .inner - .send(Method::GET, api!("/bots/stats"), None) + pub async fn post_commands(&self, context: C) -> PostCommandsResult<(), E> + where + L: Serialize + DeserializeOwned, + C: GetCommands, + { + let commands = context + .get_commands() .await - .map(|stats: BotStats| stats.server_count) - } + .map_err(PostCommandsError::Retrieval)?; - /// Updates the server count in your Discord bot's Top.gg page. - /// - /// # Panics - /// - /// Panics if the client uses an invalid API token. - /// - /// # Errors - /// - /// Returns [`Err`] if: - /// - The bot is currently in zero servers. ([`InvalidRequest`][crate::Error::InvalidRequest]) - /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) - /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) - /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) - /// - /// # Example - /// - /// ```rust,no_run - /// client.post_bot_server_count(bot.server_count()).await.unwrap(); - /// ``` - #[inline(always)] - pub async fn post_bot_server_count(&self, server_count: usize) -> Result<()> { - self.inner.post_bot_server_count(server_count).await + match self + .send_inner( + Method::POST, + api!("/projects/@me/commands"), + serde_json::to_vec(&commands).map_err(PostCommandsError::Serialization)?, + ) + .await + { + Ok(_) => Ok(()), + + Err(err) => Err(PostCommandsError::Request(err)), + } } - /// 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. + /// Tries to get the latest vote information of a user on your project. Returns [`None`] if the user has not voted. /// /// # Panics /// - /// Panics if the client uses an invalid API token. + /// Panics if: + /// - The specified ID is invalid. + /// - 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]) + /// - The specified user has not logged in to Top.gg. ([`NotFound`][super::Error::NotFound]) + /// - HTTP request failure from the client-side. ([`InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][super::Error::Ratelimit]) /// /// # Example /// /// ```rust,no_run - /// // Page number - /// let voters = client.get_voters(1).await.unwrap(); + /// use topgg::UserSource; /// - /// for voter in voters { - /// println!("{}", voter.username); - /// } + /// // Discord ID: + /// let vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); + /// + /// // Top.gg ID: + /// let vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); /// ``` - pub async fn get_voters(&self, mut page: usize) -> Result> { - if page < 1 { - page = 1; - } - - self - .inner + pub async fn get_vote(&self, user: UserSource) -> Result> + where + S: Snowflake, + { + match self .send( Method::GET, - api!("/bots/{}/votes?page={}", self.inner.id, page), + api!( + "/projects/@me/votes/{}?source={}", + user.as_snowflake(), + user.name() + ), None, ) .await - } + { + Ok(vote) => Ok(Some(vote)), - pub(crate) async fn get_bots_inner(&self, path: String) -> Result> { - self - .inner - .send::(Method::GET, api!("{}", path), None) - .await - .map(|res| res.results) + Err(err) => { + if matches!(err, Error::NotFound) { + return Ok(None); + } + + Err(err) + } + } } - /// Fetches Discord bots that matches the specified query. + /// Tries to get a cursor-based paginated list of votes for your project, ordered by creation date. /// /// # Panics /// @@ -315,102 +260,52 @@ impl Client { /// # 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]) + /// - HTTP request failure from the client-side. ([`InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][super::Error::Ratelimit]) /// /// # Example /// /// ```rust,no_run - /// let bots = client - /// .get_bots() - /// .limit(250) - /// .skip(50) - /// .sort_by_monthly_votes() - /// .await - /// .unwrap(); - /// - /// for bot in bots { - /// println!("{}", bot.name); - /// } - /// ``` - #[inline(always)] - pub fn get_bots(&self) -> BotQuery<'_> { - BotQuery::new(self) - } - - /// Checks if a Top.gg user has voted for your Discord bot in the past 12 hours. - /// - /// # Panics - /// - /// Panics if: - /// - The specified ID is invalid. - /// - The client uses an invalid API token. + /// use chrono::{TimeZone, Utc}; /// - /// # Errors + /// let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); + /// let first_page = client.get_votes(since).await.unwrap(); /// - /// 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]) + /// for vote in first_page.iter() { + /// println!("{vote:?}"); + /// } /// - /// # Example + /// let second_page = first_page.next().await.unwrap(); /// - /// ```rust,no_run - /// let has_voted = client.has_voted(8226924471638491136).await.unwrap(); + /// for vote in second_page.iter() { + /// println!("{vote:?}"); + /// } /// ``` - pub async fn has_voted(&self, user_id: I) -> Result + pub async fn get_votes(&self, since: DateTime) -> Result> where - I: Snowflake, + Tz: TimeZone, { self - .inner - .send::( + .send( Method::GET, - api!("/bots/check?userId={}", user_id.as_snowflake()), + api!( + "/projects/@me/votes?startDate={}", + urlencoding::encode(&since.to_rfc3339_opts(SecondsFormat::Millis, true)) + ), None, ) .await - .map(|res| res.voted != 0) + .map(|data| PaginatedVotes { data, client: self }) } - /// Checks if the weekend multiplier is active, where a single vote counts as two. - /// - /// # 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 is_weekend = client.is_weekend().await.unwrap(); - /// ``` - pub async fn is_weekend(&self) -> Result { + pub(super) async fn get_next_votes(&self, cursor: &str) -> Result { self - .inner - .send::(Method::GET, api!("/weekend"), None) + .send( + Method::GET, + api!("/projects/@me/votes?cursor={}", cursor), + None, + ) .await - .map(|res| res.is_weekend) - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "bot-autoposter")] { - impl bot_autoposter::AsClientSealed for Client { - #[inline(always)] - fn as_client(&self) -> Arc { - Arc::clone(&self.inner) - } - } - - impl bot_autoposter::AsClient for Client {} } } diff --git a/src/error.rs b/src/error.rs index 3139798..ca90242 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,8 @@ use std::{error, fmt, result}; -/// An error coming from this SDK. +use serde_json::Error as SerdeJsonError; + +/// An error coming from the SDK. #[derive(Debug)] pub enum Error { /// HTTP request failure from the client-side. @@ -12,8 +14,8 @@ pub enum Error { /// Attempted to send an invalid request to the API. InvalidRequest, - /// Such query does not exist. Inside is the message from the API if available. - NotFound(Option), + /// Such query does not exist. + NotFound, /// Ratelimited from sending more requests. Ratelimit { @@ -26,13 +28,13 @@ impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InternalClientError(err) => write!(f, "Internal Client Error: {err}"), + Self::InternalServerError => write!(f, "Internal Server Error"), + Self::InvalidRequest => write!(f, "Invalid Request"), - Self::NotFound(message) => write!( - f, - "Not Found: {}", - message.as_deref().unwrap_or("") - ), + + Self::NotFound => write!(f, "Not Found"), + Self::Ratelimit { retry_after } => write!( f, "Blocked by the API for an hour. Please try again in {retry_after} seconds", @@ -42,14 +44,60 @@ impl fmt::Display for Error { } impl error::Error for Error { - #[inline(always)] fn source(&self) -> Option<&(dyn error::Error + 'static)> { match self { Self::InternalClientError(err) => err.source(), + _ => None, } } } +/// An error coming from [`Client::post_commands`][super::Client::post_commands]. +#[derive(Debug)] +pub enum PostCommandsError { + /// Error happened while retrieving the bot commands in [`GetCommands`][super::GetCommands]. + Retrieval(E), + + /// Error happened while serializing the bot commands. + Serialization(SerdeJsonError), + + /// Error happened while sending the HTTP request. + Request(Error), +} + +impl fmt::Display for PostCommandsError +where + E: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Retrieval(err) => write!(f, "Error while retrieving bot commands: {err:?}"), + + Self::Serialization(err) => write!(f, "Error while serializing bot commands: {err:?}"), + + Self::Request(err) => write!(f, "Error while posting bot commands: {err:?}"), + } + } +} + +impl error::Error for PostCommandsError +where + E: error::Error + 'static, +{ + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::Retrieval(err) => Some(err), + + Self::Serialization(err) => Some(err), + + Self::Request(err) => err.source(), + } + } +} + /// The result type primarily used in this SDK. -pub type Result = result::Result; \ No newline at end of file +pub type Result = result::Result; + +/// The result type used in [`Client::post_commands`][super::Client::post_commands]. +pub type PostCommandsResult = result::Result>; diff --git a/src/lib.rs b/src/lib.rs index 1f1ac99..34fd38a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,51 +1,28 @@ #![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 project; mod snowflake; #[cfg(test)] mod test; +mod user; + +pub use project::*; +pub use user::*; cfg_if::cfg_if! { if #[cfg(feature = "api")] { - pub(crate) mod client; - mod bot; + mod client; mod error; mod util; - mod vote; - - #[cfg(feature = "bot-autoposter")] - pub(crate) use client::InnerClient; - #[doc(inline)] - pub use bot::{Bot, BotQuery}; pub use client::Client; - pub use error::{Error, Result}; + pub use error::{Error, PostCommandsError, PostCommandsResult, Result}; pub use snowflake::Snowflake; // for doc purposes - pub use vote::Voter; - - #[doc(hidden)] - #[cfg(any(feature = "twilight", feature = "twilight-cached"))] - pub use project::TwilightGetCommandsError; - } -} - -cfg_if::cfg_if! { - if #[cfg(feature = "bot-autoposter")] { - mod bot_autoposter; - - #[doc(inline)] - #[cfg_attr(docsrs, doc(cfg(feature = "bot-autoposter")))] - pub use bot_autoposter::{BotAutoposter, BotAutoposterHandler}; - - #[cfg(any(feature = "serenity", feature = "serenity-cached"))] - #[cfg_attr(docsrs, doc(cfg(all(feature = "bot-autoposter", any(feature = "serenity", feature = "serenity-cached")))))] - pub use bot_autoposter::Serenity as SerenityBotAutoposter; - #[cfg(any(feature = "twilight", feature = "twilight-cached"))] - #[cfg_attr(docsrs, doc(cfg(all(feature = "bot-autoposter", any(feature = "twilight", feature = "twilight-cached")))))] - pub use bot_autoposter::Twilight as TwilightBotAutoposter; + /// Widget generator functions. + pub mod widget; } } diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..8aaca47 --- /dev/null +++ b/src/project.rs @@ -0,0 +1,185 @@ +use super::snowflake; + +use serde::Deserialize; + +/// A project's platform. +#[non_exhaustive] +#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + Discord, +} + +/// A project's type. +#[non_exhaustive] +#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq)] +pub enum ProjectType { + #[serde(rename = "bot")] + DiscordBot, + + #[serde(rename = "server")] + DiscordServer, +} + +impl ProjectType { + #[cfg(feature = "api")] + pub(super) const fn as_widget_path(self) -> &'static str { + match self { + Self::DiscordBot => "discord/bot", + + Self::DiscordServer => "discord/server", + } + } +} + +/// A brief information on project listed on Top.gg. +#[cfg(feature = "webhooks")] +#[derive(Clone, Debug, Deserialize)] +#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] +pub struct PartialProject { + #[serde(deserialize_with = "snowflake::deserialize")] + /// The project's ID. + pub id: u64, + + /// The project's ID. + #[serde(rename = "type")] + pub kind: ProjectType, + + /// The project's platform. + pub platform: Platform, + + /// The project's platform ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub platform_id: u64, +} + +cfg_if::cfg_if! { + if #[cfg(feature = "api")] { + use serde::{Serialize, de::DeserializeOwned, ser::Error}; + + /// A project listed on Top.gg. + #[derive(Clone, Debug, Deserialize)] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub struct Project { + /// The project's ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub id: u64, + + /// The project's name sourced from the external platform. + pub name: String, + + /// The project's platform. + pub platform: Platform, + + /// The project's type. + #[serde(rename = "type")] + pub kind: ProjectType, + + /// The project's short description. + pub headline: String, + + /// The project's tag IDs. + pub tags: Vec, + + /// The project's current vote count that affects the project's ranking. + #[serde(rename = "votes")] + pub current_votes: u64, + + /// The project's total vote count. + #[serde(rename = "votes_total")] + pub total_votes: u64, + + /// The project's review score out of 5. + pub review_score: f64, + + /// The project's total review count. + pub review_count: u64, + } + + /// Retrieves an array of application commands in [Discord API's raw JSON format](https://discord.com/developers/docs/interactions/application-commands#application-command-object). Intended for use in [`Client::post_commands`][super::Client::post_commands]. + #[async_trait::async_trait] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub trait GetCommands + where + C: Serialize + DeserializeOwned, + { + async fn get_commands(self) -> Result, E>; + } + + #[async_trait::async_trait] + impl GetCommands for serde_json::Value { + async fn get_commands(self) -> Result, serde_json::Error> { + match self { + Self::Array(elements) => Ok(elements), + + _ => Err(serde_json::Error::custom( + "The object passed to get_commands() must be an array of commands.", + )), + } + } + } + + #[async_trait::async_trait] + impl GetCommands for Vec + where + C: Serialize + DeserializeOwned + Send, + { + async fn get_commands(self) -> Result { + Ok(self) + } + } + + cfg_if::cfg_if! { + if #[cfg(feature = "serenity")] { + use serenity::{ + client::Context as SerenityContext, + http::{ + HttpError as SerenityHttpError, LightMethod as SerenityHttpMethod, + Request as SerenityHttpRequest, Route as SerenityHttpRoute, + }, + Error as SerenityError, + }; + + #[async_trait::async_trait] + #[cfg_attr(docsrs, doc(cfg(all(feature = "api", feature = "serenity"))))] + impl GetCommands for &SerenityContext { + async fn get_commands(self) -> Result, SerenityError> { + let Some(application_id) = self.http.application_id() else { + return Err(SerenityHttpError::ApplicationIdMissing.into()); + }; + + self + .http + .fire::<_>(SerenityHttpRequest::new( + SerenityHttpRoute::Commands { application_id }, + SerenityHttpMethod::Get, + )) + .await + } + } + } + } + + cfg_if::cfg_if! { + if #[cfg(feature = "twilight")] { + use twilight_http::{error::Error as TwilightHttpError, response::DeserializeBodyError as TwilightHttpDeserializeBodyError, request::application::command::GetGlobalCommands as TwilightGetGlobalCommands}; + use twilight_model::application::command::Command as TwilightCommand; + + #[doc(hidden)] + #[derive(Debug)] + pub enum TwilightGetCommandsError { + Http(TwilightHttpError), + Deserialize(TwilightHttpDeserializeBodyError), + } + + #[async_trait::async_trait] + #[cfg_attr(docsrs, doc(cfg(all(feature = "api", feature = "twilight"))))] + impl GetCommands for TwilightGetGlobalCommands<'_> { + async fn get_commands(self) -> Result, TwilightGetCommandsError> { + self.await.map_err(TwilightGetCommandsError::Http)?.models().await.map_err(TwilightGetCommandsError::Deserialize) + } + } + } + } + } +} diff --git a/src/snowflake.rs b/src/snowflake.rs index 02eaf0b..f5250b6 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -1,7 +1,6 @@ -use serde::{de::Error, Deserialize, Deserializer}; +use serde::{Deserialize, Deserializer, de::Error}; -#[inline(always)] -pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result +pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { @@ -10,16 +9,8 @@ where cfg_if::cfg_if! { if #[cfg(feature = "api")] { - #[inline(always)] - pub(crate) fn deserialize_vec<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - Deserialize::deserialize(deserializer) - .map(|s: Vec| s.into_iter().filter_map(|next| next.parse().ok()).collect()) - } - /// Any data type that can be interpreted as a Discord ID. + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] pub trait Snowflake { /// Converts this value to a [`u64`]. fn as_snowflake(&self) -> u64; @@ -29,7 +20,6 @@ cfg_if::cfg_if! { ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { $(#[$attr])? impl Snowflake for $t { - #[inline(always)] fn as_snowflake(&$self) -> u64 { $body } @@ -46,116 +36,5 @@ cfg_if::cfg_if! { ); impl_string!(&str, String); - - cfg_if::cfg_if! { - if #[cfg(feature = "api")] { - macro_rules! impl_topgg_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(self, &$t, self.id); - )+} - ); - - impl_topgg_idstruct!( - crate::Bot, - crate::Voter - ); - } - } - - cfg_if::cfg_if! { - if #[cfg(feature = "serenity")] { - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, - &serenity::model::guild::Member, - self.user.id.get() - ); - - impl_snowflake!( - #[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, - &serenity::model::guild::PartialMember, - self.user.as_ref().expect("User property in PartialMember is None.").id.get() - ); - - macro_rules! impl_serenity_id( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, $t, self.get()); - )+} - ); - - impl_serenity_id!( - serenity::model::id::GenericId, - serenity::model::id::UserId - ); - - macro_rules! impl_serenity_idstruct( - ($($t:ty),+) => {$( - impl_snowflake!(#[cfg_attr(docsrs, doc(cfg(feature = "serenity")))] self, &$t, self.id.get()); - )+} - ); - - impl_serenity_idstruct!( - serenity::model::gateway::PresenceUser, - serenity::model::user::CurrentUser, - serenity::model::user::User - ); - } - } - - cfg_if::cfg_if! { - if #[cfg(feature = "serenity-cached")] { - use std::ops::Deref; - - 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_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() - ); - } - } } } diff --git a/src/test.rs b/src/test.rs index d7ed055..298fd70 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,5 +1,7 @@ -use crate::Client; -use tokio::time::{sleep, Duration}; +use super::{Client, UserSource}; + +use serde_json::json; +use tokio::time::{Duration, sleep}; macro_rules! delayed { ($($b:tt)*) => { @@ -9,46 +11,40 @@ macro_rules! delayed { } #[tokio::test] +#[allow(clippy::unreadable_literal)] 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); + let _project = client.get_self().await.unwrap(); } delayed! { - let _bots = client - .get_bots() - .limit(250) - .skip(50) - .sort_by_monthly_votes() - .await - .unwrap(); + client.post_commands(json!([{ + "id": "1", + "type": 1, + "application_id": "1", + "name": "test", + "description": "command description", + "default_member_permissions": "", + "version": "1" + }])).await.unwrap(); } delayed! { - client - .post_bot_server_count(2) - .await - .unwrap(); + let _vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); } delayed! { - assert_eq!(client.get_bot_server_count().await.unwrap().unwrap(), 2); + let _vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); } delayed! { - let _voters = client.get_voters(1).await.unwrap(); - } + use chrono::{TimeZone, Utc}; - delayed! { - let _has_voted = client.has_voted(661200758510977084).await.unwrap(); - } + let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); - delayed! { - let _is_weekend = client.is_weekend().await.unwrap(); + let _first_page = client.get_votes(since).await.unwrap(); + let _second_page = _first_page.next().await.unwrap(); } -} \ No newline at end of file +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..c60d1a3 --- /dev/null +++ b/src/user.rs @@ -0,0 +1,227 @@ +use super::snowflake; + +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +cfg_if::cfg_if! { + if #[cfg(feature = "api")] { + use super::{Client, Result, Snowflake}; + use std::ops::{Deref, DerefMut}; + + /// A user account from an external platform that is linked to a Top.gg user account. This data carries a [`Snowflake`]. + #[non_exhaustive] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub enum UserSource { + Discord(S), + Topgg(S), + } + + impl UserSource { + pub(super) const fn name(&self) -> &'static str { + match self { + Self::Topgg(_) => "topgg", + + Self::Discord(_) => "discord", + } + } + } + + impl Snowflake for UserSource + where + S: Snowflake, + { + fn as_snowflake(&self) -> u64 { + match self { + Self::Topgg(id) | Self::Discord(id) => id.as_snowflake(), + } + } + } + + /// A project's vote information. + #[derive(Clone, Debug, Deserialize)] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub struct Vote { + /// The voter's ID. + #[serde(deserialize_with = "snowflake::deserialize", rename = "user_id")] + pub voter_id: u64, + + /// The voter's ID on the project's platform. + #[serde(deserialize_with = "snowflake::deserialize")] + pub platform_id: u64, + + /// When the vote was cast. + #[serde(rename = "created_at")] + pub voted_at: DateTime, + + /// When the vote expires and the user is required to vote again. + pub expires_at: DateTime, + + /// The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. + pub weight: u64, + } + + /// An owned variation of [`PaginatedVotes`]. + #[derive(Clone, Deserialize)] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub struct PaginatedVotesOwned { + #[serde(rename = "data")] + votes: Vec, + cursor: String, + } + + impl From> for PaginatedVotesOwned { + fn from(votes: PaginatedVotes<'_>) -> Self { + votes.data + } + } + + impl PaginatedVotesOwned { + /// Tries to advance to the next page. + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][super::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// use chrono::{TimeZone, Utc}; + /// + /// let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); + /// let first_page = PaginatedVotesOwned::from(client.get_votes(since).await.unwrap()); + /// + /// for vote in first_page.iter() { + /// println!("{vote:?}"); + /// } + /// + /// let second_page = first_page.next(&client).await.unwrap(); + /// + /// for vote in second_page.iter() { + /// println!("{vote:?}"); + /// } + /// ``` + pub async fn next(&self, client: &Client) -> Result { + client.get_next_votes(&self.cursor).await + } + } + + impl Deref for PaginatedVotesOwned { + type Target = [Vote]; + + fn deref(&self) -> &Self::Target { + &self.votes + } + } + + impl DerefMut for PaginatedVotesOwned { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.votes + } + } + + /// A paginated list of a project's vote information. + /// + /// For an [owned variation][PaginatedVotesOwned], pass this to [`PaginatedVotesOwned::from`]. + #[derive(Clone)] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub struct PaginatedVotes<'c> { + pub(super) data: PaginatedVotesOwned, + pub(super) client: &'c Client, + } + + impl PaginatedVotes<'_> { + /// Tries to advance to the next page. + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][super::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// use chrono::{TimeZone, Utc}; + /// + /// let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); + /// let first_page = client.get_votes(since).await.unwrap(); + /// + /// for vote in first_page.iter() { + /// println!("{vote:?}"); + /// } + /// + /// let second_page = first_page.next().await.unwrap(); + /// + /// for vote in second_page.iter() { + /// println!("{vote:?}"); + /// } + /// ``` + pub async fn next(&self) -> Result { + Ok(Self { + data: self.data.next(self.client).await?, + client: self.client, + }) + } + } + + impl Deref for PaginatedVotes<'_> { + type Target = [Vote]; + + fn deref(&self) -> &Self::Target { + self.data.deref() + } + } + + impl DerefMut for PaginatedVotes<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.data.deref_mut() + } + } + } +} + +/// A Top.gg user. +#[cfg(feature = "webhooks")] +#[derive(Clone, Debug, Deserialize)] +#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] +pub struct User { + /// The user's ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub id: u64, + + /// The user's name. + pub name: String, + + /// The user's avatar URL. + pub avatar_url: String, + + /// The user's platform ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub platform_id: u64, +} + +/// A brief information of a project's vote. +#[derive(Clone, Debug, Deserialize)] +pub struct PartialVote { + /// When the vote was cast. + #[serde(rename = "created_at")] + pub voted_at: DateTime, + + /// When the vote expires and the user is required to vote again. + pub expires_at: DateTime, + + /// The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. + pub weight: u64, +} diff --git a/src/util.rs b/src/util.rs index 33ca6ad..86d421c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,62 +1,17 @@ -use crate::{snowflake, Error}; -use base64::Engine; -use reqwest::Response; -use serde::{de::DeserializeOwned, Deserialize, Deserializer}; - -#[inline(always)] -pub(crate) fn deserialize_optional_string<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok( - String::deserialize(deserializer) - .ok() - .filter(|s| !s.is_empty()), - ) -} +use super::Error; -#[inline(always)] -pub(crate) fn deserialize_default<'de, D, T>(deserializer: D) -> Result -where - T: Default + Deserialize<'de>, - D: Deserializer<'de>, -{ - Option::deserialize(deserializer).map(Option::unwrap_or_default) -} +use reqwest::Response; +use serde::de::DeserializeOwned; -#[inline(always)] -pub(crate) async fn parse_json(response: Response) -> crate::Result +pub async fn parse_json(response: Response) -> super::Result where T: DeserializeOwned, { - if let Ok(bytes) = response.bytes().await { - if let Ok(json) = serde_json::from_slice(&bytes) { - return Ok(json); - } + if let Ok(bytes) = response.bytes().await + && let Ok(json) = serde_json::from_slice(&bytes) + { + return Ok(json); } Err(Error::InternalServerError) } - -#[derive(Deserialize)] -#[allow(clippy::used_underscore_binding)] -struct TokenStructure { - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, -} - -pub(crate) fn parse_api_token(token: &str) -> u64 { - if let Some(base64_section) = token.split('.').nth(1) { - if let Ok(decoded_base64) = - base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) - { - if let Ok(token_structure) = serde_json::from_slice::(&decoded_base64) { - return token_structure.id; - } - } - } - - panic!("Got a malformed API token."); -} diff --git a/src/vote.rs b/src/vote.rs deleted file mode 100644 index 627bf5c..0000000 --- a/src/vote.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::snowflake; -use serde::Deserialize; - -#[derive(Deserialize)] -pub(crate) struct Voted { - pub(crate) voted: u8, -} - -/// A Top.gg voter. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct Voter { - /// This voter's ID. - #[serde(deserialize_with = "snowflake::deserialize")] - pub id: u64, - - /// This voter's username. - #[serde(rename = "username")] - pub name: String, - - /// This voter's avatar URL. - pub avatar: String, -} diff --git a/src/widget.rs b/src/widget.rs new file mode 100644 index 0000000..fb938f6 --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,73 @@ +use crate::{ProjectType, Snowflake}; + +/// Generate a large widget URL. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::large(topgg::ProjectType::DiscordBot, 574652751745777665); +/// ``` +pub fn large(project_type: ProjectType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/large/{}/{}", + project_type.as_widget_path(), + id.as_snowflake() + ) +} + +/// Generate a small widget URL for displaying votes. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::votes(topgg::ProjectType::DiscordBot, 574652751745777665); +/// ``` +pub fn votes(project_type: ProjectType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/small/votes/{}/{}", + project_type.as_widget_path(), + id.as_snowflake() + ) +} + +/// Generate a small widget URL for displaying a project's owner. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::owner(topgg::ProjectType::DiscordBot, 574652751745777665); +/// ``` +pub fn owner(project_type: ProjectType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/small/owner/{}/{}", + project_type.as_widget_path(), + id.as_snowflake() + ) +} + +/// Generate a small widget URL for displaying social stats. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::social(topgg::ProjectType::DiscordBot, 574652751745777665); +/// ``` +pub fn social(project_type: ProjectType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/small/social/{}/{}", + project_type.as_widget_path(), + id.as_snowflake() + ) +}