From beb266f889fee7fdce30d608a7b65347effa6584 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:07:46 +0700 Subject: [PATCH 01/33] feat: apply v1-based rewrite --- Cargo.toml | 61 ++-- README.md | 309 ++++++++------------ src/bot.rs | 206 -------------- src/bot_autoposter/client.rs | 18 -- src/bot_autoposter/mod.rs | 354 ----------------------- src/bot_autoposter/serenity_impl.rs | 202 -------------- src/bot_autoposter/twilight_impl.rs | 65 ----- src/client.rs | 419 +++++++++++----------------- src/error.rs | 68 ++++- src/lib.rs | 41 +-- src/project.rs | 185 ++++++++++++ src/snowflake.rs | 127 +-------- src/test.rs | 48 ++-- src/user.rs | 227 +++++++++++++++ src/util.rs | 57 ++-- src/vote.rs | 23 -- src/widget.rs | 73 +++++ 17 files changed, 902 insertions(+), 1581 deletions(-) delete mode 100644 src/bot.rs delete mode 100644 src/bot_autoposter/client.rs delete mode 100644 src/bot_autoposter/mod.rs delete mode 100644 src/bot_autoposter/serenity_impl.rs delete mode 100644 src/bot_autoposter/twilight_impl.rs create mode 100644 src/project.rs create mode 100644 src/user.rs delete mode 100644 src/vote.rs create mode 100644 src/widget.rs diff --git a/Cargo.toml b/Cargo.toml index 3cfece0..478d702 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [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." readme = "README.md" @@ -14,19 +15,22 @@ 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 +43,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 +50,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", "base64", "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 } \ No newline at end of file diff --git a/README.md b/README.md index a258444..625d0e5 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The official Rust SDK for the [Top.gg API](https://docs.top.gg). Make sure to have a [Top.gg API](https://docs.top.gg) token handy. If not, then [view this tutorial on how to retrieve yours](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). After that, add the following line to the `dependencies` section of your `Cargo.toml`: ```toml -topgg = "1.4" +topgg = "2" ``` For more information, please read [the documentation](https://docs.rs/topgg)! @@ -21,199 +21,136 @@ For more information, please read [the documentation](https://docs.rs/topgg)! This library provides several feature flags that can be enabled/disabled in `Cargo.toml`. Such as: - **`api`**: Interacting with the [Top.gg API](https://docs.top.gg) and accessing the `top.gg/api/*` endpoints. (enabled by default) - - **`autoposter`**: Automating the process of periodically posting bot statistics to the [Top.gg API](https://docs.top.gg). -- **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Vote` struct. +- **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Payload` struct. - **`actix-web`**: Wrapper for working with the [actix-web](https://actix.rs/) web framework. - **`axum`**: Wrapper for working with the [axum](https://crates.io/crates/axum) web framework. - **`rocket`**: Wrapper for working with the [rocket](https://rocket.rs/) web framework. - **`warp`**: Wrapper for working with the [warp](https://crates.io/crates/warp) web framework. -- **`serenity`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching disabled). - - **`serenity-cached`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching enabled). -- **`twilight`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching disabled). - - **`twilight-cached`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching enabled). +- **`serenity`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity). +- **`twilight`**: Extra helpers for working with [twilight](https://twilight.rs). ## Examples -### Fetching a user from its Discord ID +### Getting your project's information ```rust,no_run -use topgg::Client; - -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); - let user = client.get_user(661200758510977084).await.unwrap(); - - assert_eq!(user.username, "null"); - assert_eq!(user.id, 661200758510977084); - - println!("{:?}", user); -} +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 + +#### Serenity -# using serenity with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "serenity-cached"] } +```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`: +#### Large -```toml -[dependencies] -# using twilight with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "twilight"] } - -# 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 +158,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 +179,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 {} + +#[async_trait::async_trait] +impl topgg::axum::Listener for MyTopggListener { + async fn callback(self: Arc, payload: Payload, _trace: &str) -> Response { + println!("{payload:?}"); -#[axum::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); + (StatusCode::NO_CONTENT, ()).into_response() } } @@ -272,49 +214,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 +264,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..9bc0211 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,39 @@ macro_rules! api { }; } -pub(crate) use api; +pub(super) use api; -pub struct InnerClient { - http: reqwest::Client, - token: String, - id: u64, +#[derive(Deserialize)] +#[serde(rename = "kebab-case")] +struct Ratelimit { + retry_after: u16, } -#[derive(Deserialize)] -pub(crate) struct ErrorJson { - #[serde(default, alias = "message", alias = "detail")] - message: Option, +/// Interact with the API v1's endpoints. +#[must_use] +pub struct Client { + http: reqwest::Client, + token: String, } -// 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); +impl Client { + /// Creates a new instance. + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Example + /// + /// ```rust,no_run + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// ``` + pub fn new(token: String) -> Self { + util::validate_api_token(&token); Self { http: reqwest::Client::new(), - token, - id, + token: format!("Bearer {token}"), } } @@ -91,18 +84,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 +103,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 +145,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 +266,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..e1940ea --- /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")] + pub user_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..c3ef6e7 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,40 +1,17 @@ -use crate::{snowflake, Error}; +use super::{Error, snowflake}; + 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()), - ) -} - -#[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 serde::{Deserialize, 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) @@ -43,19 +20,17 @@ where #[derive(Deserialize)] #[allow(clippy::used_underscore_binding)] struct TokenStructure { - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, + #[serde(deserialize_with = "snowflake::deserialize", rename = "id")] + _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) = +pub fn validate_api_token(token: &str) { + if let Some(base64_section) = token.split('.').nth(1) + && 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; - } - } + && serde_json::from_slice::(&decoded_base64).is_ok() + { + return; } 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..e668410 --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,73 @@ +use crate::{ProjectType, Snowflake}; + +/// Generates 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() + ) +} + +/// Generates 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() + ) +} + +/// Generates 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() + ) +} + +/// Generates 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() + ) +} From 4c9e3f2252dc26548b62ccea9370ddd77faf6d9f Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:09:44 +0700 Subject: [PATCH 02/33] feat: update to webhooks v2 --- src/webhooks/actix_web.rs | 71 +++++++++--------- src/webhooks/axum.rs | 95 ++++++++++++++---------- src/webhooks/mod.rs | 150 +++++++++++++++++++++++++++++--------- src/webhooks/payload.rs | 103 ++++++++++++++++++++++++++ src/webhooks/rocket.rs | 31 ++++---- src/webhooks/vote.rs | 67 ----------------- src/webhooks/warp.rs | 78 ++++++++------------ 7 files changed, 355 insertions(+), 240 deletions(-) create mode 100644 src/webhooks/payload.rs delete mode 100644 src/webhooks/vote.rs diff --git a/src/webhooks/actix_web.rs b/src/webhooks/actix_web.rs index 9393140..96ffbb7 100644 --- a/src/webhooks/actix_web.rs +++ b/src/webhooks/actix_web.rs @@ -1,62 +1,61 @@ -use crate::Incoming; -use actix_web::{ - dev::Payload, - error::{Error, ErrorBadRequest, ErrorUnauthorized}, - web::Json, - FromRequest, HttpRequest, -}; -use serde::de::DeserializeOwned; +use super::IncomingPayload; use std::{ future::Future, pin::Pin, - task::{ready, Context, Poll}, + task::{Context, Poll, ready}, +}; + +use actix_web::{ + FromRequest, HttpRequest, + dev::Payload, + error::{Error, ErrorBadRequest, ErrorUnauthorized}, }; +use futures_core::stream::Stream; #[doc(hidden)] -pub struct IncomingFut { +pub struct IncomingPayloadFut { req: HttpRequest, - json_fut: as FromRequest>::Future, + payload: Payload, + body: Vec, } -impl Future for IncomingFut -where - T: DeserializeOwned, -{ - type Output = Result, Error>; +impl Future for IncomingPayloadFut { + type Output = Result; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - if let Ok(json) = ready!(Pin::new(&mut self.json_fut).poll(cx)) { - let headers = self.req.headers(); - - if let Some(authorization) = headers.get("Authorization") { - if let Ok(authorization) = authorization.to_str() { - return Poll::Ready(Ok(Incoming { - authorization: authorization.to_owned(), - data: json.into_inner(), - })); - } + while let Some(body) = ready!(Pin::new(&mut self.payload).poll_next(cx)) { + match body { + Ok(body) => self.body.extend_from_slice(&body), + + Err(_) => return Poll::Ready(Err(ErrorBadRequest("400"))), } + } + + let headers = self.req.headers(); - return Poll::Ready(Err(ErrorUnauthorized("401"))); + if let (Some(signature), Some(trace)) = ( + headers.get("x-topgg-signature"), + headers.get("x-topgg-trace"), + ) && let (Ok(signature), Ok(trace)) = (signature.to_str(), trace.to_str()) + && let Some(incoming) = IncomingPayload::new(signature, self.body.clone(), trace) + { + return Poll::Ready(Ok(incoming)); } - Poll::Ready(Err(ErrorBadRequest("400"))) + Poll::Ready(Err(ErrorUnauthorized("401"))) } } #[cfg_attr(docsrs, doc(cfg(feature = "actix-web")))] -impl FromRequest for Incoming -where - T: DeserializeOwned, -{ +impl FromRequest for IncomingPayload { type Error = Error; - type Future = IncomingFut; + type Future = IncomingPayloadFut; - #[inline(always)] fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { - IncomingFut { + IncomingPayloadFut { req: req.clone(), - json_fut: Json::from_request(req, payload), + payload: payload.take(), + body: vec![], } } } diff --git a/src/webhooks/axum.rs b/src/webhooks/axum.rs index 4175b1b..7a163e5 100644 --- a/src/webhooks/axum.rs +++ b/src/webhooks/axum.rs @@ -1,45 +1,69 @@ -use super::Webhook; +use super::Payload; +use std::sync::Arc; + use axum::{ + Router, extract::State, http::{HeaderMap, StatusCode}, - response::IntoResponse, + response::{IntoResponse, Response}, routing::post, - Router, }; -use serde::de::DeserializeOwned; -use std::sync::Arc; + +/// An axum webhook listener for listening to payloads. +/// +/// # Example +/// +/// ```rust,no_run +/// struct MyTopggListener {} +/// +/// #[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() +/// } +/// } +/// ``` +#[async_trait::async_trait] +#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] +pub trait Listener: Send + Sync + 'static { + async fn callback(self: Arc, payload: Payload, trace: &str) -> Response; +} struct WebhookState { state: Arc, - password: Arc, + secret: Arc, } impl Clone for WebhookState { - #[inline(always)] fn clone(&self) -> Self { Self { - state: Arc::clone(&self.state), - password: Arc::clone(&self.password), + state: self.state.clone(), + secret: self.secret.clone(), } } } -/// Creates a new axum [`Router`] for receiving vote events. +/// Creates a new axum [`Router`] for receiving webhook payloads. /// /// # Example /// /// ```rust,no_run -/// use axum::{routing::get, Router}; -/// use topgg::{VoteEvent, Webhook}; -/// use tokio::net::TcpListener; +/// use topgg::Payload; /// use std::sync::Arc; /// -/// struct MyVoteListener {} +/// use axum::{http::status::StatusCode, response::{IntoResponse, Response}, routing::get, Router}; +/// use tokio::net::TcpListener; +/// +/// struct MyTopggListener {} /// /// #[async_trait::async_trait] -/// impl Webhook for MyVoteListener { -/// async fn callback(&self, vote: VoteEvent) { -/// println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); +/// impl topgg::axum::Listener for MyTopggListener { +/// async fn callback(self: Arc, payload: Payload, _trace: &str) -> Response { +/// println!("{payload:?}"); +/// +/// (StatusCode::NO_CONTENT, ()).into_response() /// } /// } /// @@ -49,11 +73,11 @@ impl Clone for WebhookState { /// /// #[tokio::main] /// async fn main() { -/// let state = Arc::new(MyVoteListener {}); +/// let state = Arc::new(MyTopggListener {}); /// /// let router = Router::new().route("/", get(index)).nest( -/// "/votes", -/// topgg::axum::webhook(env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), Arc::clone(&state)), +/// "/webhook", +/// topgg::axum::webhook(Arc::clone(&state), env!("TOPGG_WEBHOOK_SECRET").to_string()), /// ); /// /// let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); @@ -61,36 +85,31 @@ impl Clone for WebhookState { /// axum::serve(listener, router).await.unwrap(); /// } /// ``` -#[inline(always)] #[cfg_attr(docsrs, doc(cfg(feature = "axum")))] -pub fn webhook(password: String, state: Arc) -> Router +pub fn webhook(state: Arc, secret: String) -> Router where - D: DeserializeOwned + Send, - T: Webhook, + S: Listener, { 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(); - } - } - } + async |headers: HeaderMap, State(wrapped_state): State>, body: String| { + if let Some(signature) = headers.get("x-topgg-signature") + && let Ok(signature) = signature.to_str() + && let Some(trace) = headers.get("x-topgg-trace") + && let Ok(trace) = trace.to_str() + && let Some(payload) = Payload::new(signature, &body, &wrapped_state.secret) + { + wrapped_state.state.callback(payload, trace).await + } else { + (StatusCode::UNAUTHORIZED, ()).into_response() } - - (StatusCode::UNAUTHORIZED, ()).into_response() }, ), ) .with_state(WebhookState { state, - password: Arc::new(password), + secret: Arc::new(secret), }) } diff --git a/src/webhooks/mod.rs b/src/webhooks/mod.rs index a5ec591..124daf9 100644 --- a/src/webhooks/mod.rs +++ b/src/webhooks/mod.rs @@ -1,6 +1,6 @@ -mod vote; -#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] -pub use vote::*; +mod payload; + +pub use payload::Payload; #[cfg(feature = "actix-web")] mod actix_web; @@ -26,49 +26,127 @@ cfg_if::cfg_if! { cfg_if::cfg_if! { if #[cfg(any(feature = "actix-web", feature = "rocket"))] { - /// An unauthenticated incoming Top.gg webhook request. - #[must_use] + use std::collections::HashMap; + + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + /// An incoming [`Payload`] that is yet to be [authenticated with a secret][IncomingPayload::authenticate]. + /// + /// # Examples + /// + /// With actix-web: + /// + /// ```rust,no_run + /// use topgg::IncomingPayload; + /// use std::io; + /// + /// use actix_web::{ + /// error::{Error, ErrorUnauthorized}, + /// get, post, App, HttpServer, + /// }; + /// + /// #[get("/")] + /// async fn index() -> &'static str { + /// "Hello, World!" + /// } + /// + /// #[post("/webhook")] + /// 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")), + /// } + /// } + /// + /// #[actix_web::main] + /// async fn main() -> io::Result<()> { + /// HttpServer::new(|| App::new().service(index).service(webhook)) + /// .bind("127.0.0.1:8080")? + /// .run() + /// .await + /// } + /// ``` + /// + /// With rocket: + /// + /// ```rust,no_run + /// use topgg::IncomingPayload; + /// + /// use rocket::{get, http::Status, launch, post, routes, Build, Rocket}; + /// + /// #[get("/")] + /// fn index() -> &'static str { + /// "Hello, World!" + /// } + /// + /// #[post("/webhook", data = "")] + /// fn webhook(payload: IncomingPayload) -> Status { + /// match payload.authenticate(env!("TOPGG_WEBHOOK_SECRET")) { + /// Some(payload) => { + /// println!("{payload:?}"); + /// + /// Status::Ok + /// }, + /// _ => { + /// println!("found an unauthorized attacker."); + /// + /// Status::Unauthorized + /// } + /// } + /// } + /// + /// #[launch] + /// fn rocket() -> Rocket { + /// rocket::build().mount("/", routes![index, webhook]) + /// } + /// ``` #[cfg_attr(docsrs, doc(cfg(any(feature = "actix-web", feature = "rocket"))))] - pub struct Incoming { - pub(crate) authorization: String, - pub(crate) data: T, + pub struct IncomingPayload { + t: String, + signature: String, + body: String, + trace: String, } - impl Incoming { - /// Authenticates a valid password with this request. + impl IncomingPayload { + pub(super) fn new(signature: &str, body: Vec, trace: &str) -> Option { + let signature = signature.split(',').filter_map(|p| p.split_once('=')).collect::>(); + + Some(Self { + t: signature.get("t")?.to_string(), + signature: signature.get("v1")?.to_string(), + body: String::from_utf8(body).ok()?, + trace: trace.into(), + }) + } + + /// Tries to authenticate a valid secret with this request. #[must_use] - #[inline(always)] - pub fn authenticate(self, password: &str) -> Option { - if self.authorization == password { - Some(self.data) + pub fn authenticate(&self, secret: &str) -> Option { + let mut hmac = Hmac::::new_from_slice(secret.as_bytes()).ok()?; + + hmac.update(format!("{}.{}", self.t, self.body).as_bytes()); + + let digest = hex::encode(hmac.finalize().into_bytes()); + + if digest == self.signature && let Ok(payload) = serde_json::from_str(&self.body) { + Some(payload) } else { None } } - } - impl Clone for Incoming - where - T: Clone, - { - #[inline(always)] - fn clone(&self) -> Self { - Self { - authorization: self.authorization.clone(), - data: self.data.clone(), - } + /// Retrieves the payload's `x-topgg-trace` header for debugging and correlating requests with Top.gg support. + #[must_use] + pub fn get_trace(&self) -> &str { + &self.trace } } } } - -cfg_if::cfg_if! { - if #[cfg(any(feature = "axum", feature = "warp"))] { - /// Webhook event handler. - #[cfg_attr(docsrs, doc(cfg(any(feature = "axum", feature = "warp"))))] - #[async_trait::async_trait] - pub trait Webhook: Send + Sync + 'static { - async fn callback(&self, data: T); - } - } -} diff --git a/src/webhooks/payload.rs b/src/webhooks/payload.rs new file mode 100644 index 0000000..a62e3b6 --- /dev/null +++ b/src/webhooks/payload.rs @@ -0,0 +1,103 @@ +use super::super::{PartialProject, User, snowflake}; + +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// A webhook payload. +#[non_exhaustive] +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", content = "data")] +#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] +pub enum Payload { + /// A `integration.create` webhook payload. Fires when a user has connected to your webhook integration. + #[serde(rename = "integration.create")] + IntegrationCreate { + /// The unique identifier for this connection. + #[serde(deserialize_with = "snowflake::deserialize")] + connection_id: u64, + + /// The secret used to verify future webhook deliveries. + #[serde(rename = "webhook_secret")] + secret: String, + + /// The project that the integration refers to. + project: PartialProject, + + /// The user who triggered this event. + user: User, + }, + + /// A `integration.delete` webhook payload. Fires when a user has disconnected from your webhook integration. + #[serde(rename = "integration.delete")] + IntegrationDelete { + /// The unique identifier for this connection. + #[serde(deserialize_with = "snowflake::deserialize")] + connection_id: u64, + }, + + /// A `webhook.test` webhook payload. Fires upon sent test from the project dashboard. + #[serde(rename = "webhook.test")] + Test { + /// The project that the test refers to. + project: PartialProject, + + /// The user who triggered this test. + user: User, + }, + + /// A `vote.create` webhook payload. Fires when a user votes for your project. + #[serde(rename = "vote.create")] + VoteCreate { + /// The vote's ID. + #[serde(deserialize_with = "snowflake::deserialize")] + id: u64, + + /// The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. + weight: u64, + + /// When the vote was cast. + created_at: DateTime, + + /// When the vote expires and the user is required to vote again. + expires_at: DateTime, + + /// The project that received this vote. + project: PartialProject, + + /// The user who voted for this project. + user: User, + }, +} + +impl Payload { + #[cfg(any(feature = "axum", feature = "warp"))] + pub(super) fn new(signature: &str, body: &str, secret: &str) -> Option { + use std::collections::HashMap; + + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let signature = signature + .split(',') + .filter_map(|p| p.split_once('=')) + .collect::>(); + + let (Some(t), Some(signature)) = (signature.get("t"), signature.get("v1")) else { + return None; + }; + + let mut hmac = Hmac::::new_from_slice(secret.as_bytes()).ok()?; + + hmac.update(format!("{t}.{body}").as_bytes()); + + let digest = hex::encode(hmac.finalize().into_bytes()); + + if &digest == signature + && let Ok(payload) = serde_json::from_str(body) + { + Some(payload) + } else { + None + } + } +} diff --git a/src/webhooks/rocket.rs b/src/webhooks/rocket.rs index 0bfb988..31d0034 100644 --- a/src/webhooks/rocket.rs +++ b/src/webhooks/rocket.rs @@ -1,31 +1,30 @@ -use crate::Incoming; +use super::IncomingPayload; + use rocket::{ - data::{Data, FromData, Outcome}, + data::{Data, FromData, Outcome, ToByteUnit}, http::Status, request::Request, - serde::json::Json, }; -use serde::de::DeserializeOwned; #[cfg_attr(docsrs, doc(cfg(feature = "rocket")))] #[rocket::async_trait] -impl<'r, T> FromData<'r> for Incoming -where - T: DeserializeOwned, -{ +impl<'r> FromData<'r> for IncomingPayload { type Error = (); async fn from_data(request: &'r Request<'_>, data: Data<'r>) -> Outcome<'r, Self> { let headers = request.headers(); - if let Some(authorization) = headers.get_one("Authorization") { - return match as FromData>::from_data(request, data).await { - Outcome::Success(data) => Outcome::Success(Self { - authorization: authorization.to_owned(), - data: data.into_inner(), - }), - _ => Outcome::Error((Status::BadRequest, ())), - }; + if let (Some(signature), Some(trace)) = ( + headers.get_one("x-topgg-signature"), + headers.get_one("x-topgg-trace"), + ) { + if let Ok(body) = data.open(2.mebibytes()).into_bytes().await + && let Some(output) = Self::new(signature, body.into_inner(), trace) + { + return Outcome::Success(output); + } + + return Outcome::Error((Status::BadRequest, ())); } Outcome::Error((Status::Unauthorized, ())) diff --git a/src/webhooks/vote.rs b/src/webhooks/vote.rs deleted file mode 100644 index 0bbbb54..0000000 --- a/src/webhooks/vote.rs +++ /dev/null @@ -1,67 +0,0 @@ -use crate::snowflake; -use serde::{Deserialize, Deserializer}; -use std::collections::HashMap; - -#[inline(always)] -fn deserialize_is_test<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - String::deserialize(deserializer).map(|s| s == "test") -} - -fn deserialize_query_string<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok( - String::deserialize(deserializer) - .map(|s| { - let mut output = HashMap::new(); - - for mut it in s - .trim_start_matches('?') - .split('&') - .map(|pair| pair.split('=')) - { - if let (Some(k), Some(v)) = (it.next(), it.next()) { - if let Ok(v) = urlencoding::decode(v) { - output.insert(k.to_owned(), v.into_owned()); - } - } - } - - output - }) - .unwrap_or_default(), - ) -} - -/// A dispatched Top.gg vote event. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct VoteEvent { - /// The ID of the project that received a vote. - #[serde( - deserialize_with = "snowflake::deserialize", - alias = "bot", - alias = "guild" - )] - pub receiver_id: u64, - - /// The ID of the Top.gg user who voted. - #[serde(deserialize_with = "snowflake::deserialize", rename = "user")] - pub voter_id: u64, - - /// Whether this vote is just a test done from the page settings. - #[serde(deserialize_with = "deserialize_is_test", rename = "type")] - pub is_test: bool, - - /// Whether the weekend multiplier is active, where a single vote counts as two. - #[serde(default, rename = "isWeekend")] - pub is_weekend: bool, - - /// Query strings found on the vote page. - #[serde(default, deserialize_with = "deserialize_query_string")] - pub query: HashMap, -} diff --git a/src/webhooks/warp.rs b/src/webhooks/warp.rs index 51c108b..30d3dba 100644 --- a/src/webhooks/warp.rs +++ b/src/webhooks/warp.rs @@ -1,36 +1,34 @@ -use super::Webhook; -use serde::de::DeserializeOwned; -use std::sync::Arc; -use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; +use super::Payload; + +use bytes::Bytes; +use warp::{Filter, Rejection, body, header, path}; /// Creates a new warp [`Filter`] for receiving webhook events. /// /// # Example /// /// ```rust,no_run -/// use std::{net::SocketAddr, sync::Arc}; -/// use topgg::{VoteEvent, Webhook}; -/// use warp::Filter; -/// -/// struct MyVoteListener {} +/// use std::net::SocketAddr; /// -/// #[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); -/// } -/// } +/// use warp::{http::StatusCode, reply, Filter}; /// /// #[tokio::main] /// async fn main() { -/// let state = Arc::new(MyVoteListener {}); -/// -/// // POST /votes +/// // POST /webhook /// let webhook = topgg::warp::webhook( -/// "votes", -/// env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), -/// Arc::clone(&state), -/// ); +/// "webhook", +/// 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); /// @@ -39,34 +37,20 @@ use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// warp::serve(routes).run(addr).await /// } /// ``` +#[must_use] #[cfg_attr(docsrs, doc(cfg(feature = "warp")))] -pub fn webhook( +pub fn webhook( endpoint: &'static str, - password: String, - state: Arc, -) -> impl Filter + Clone -where - D: DeserializeOwned + Send, - T: Webhook, -{ - let password = Arc::new(password); - + secret: String, +) -> impl Filter, String), Error = Rejection> + Clone { warp::post() .and(path(endpoint)) - .and(header("Authorization")) - .and(body::json()) - .then(move |auth: String, data: D| { - let current_state = Arc::clone(&state); - let current_password = Arc::clone(&password); - - async move { - if auth == *current_password { - current_state.callback(data).await; - - StatusCode::NO_CONTENT - } else { - StatusCode::UNAUTHORIZED - } - } + .and(header("x-topgg-signature")) + .and(body::bytes()) + .map(move |signature: String, body: Bytes| { + str::from_utf8(&body) + .ok() + .and_then(|body| Payload::new(&signature, body, &secret)) }) + .and(header("x-topgg-trace")) } From fe8718263fdd35f31793160d15276a6c29877436 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:13:10 +0700 Subject: [PATCH 03/33] doc: minor grammatical correction --- src/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 9bc0211..81e0c32 100644 --- a/src/client.rs +++ b/src/client.rs @@ -26,7 +26,7 @@ struct Ratelimit { retry_after: u16, } -/// Interact with the API v1's endpoints. +/// Interact with API v1's endpoints. #[must_use] pub struct Client { http: reqwest::Client, From 4fb0852b22cd8e389727f6a5acf8e2d3bf42e403 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:58:29 +0700 Subject: [PATCH 04/33] doc: use an, not a --- src/webhooks/payload.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/webhooks/payload.rs b/src/webhooks/payload.rs index a62e3b6..6723f88 100644 --- a/src/webhooks/payload.rs +++ b/src/webhooks/payload.rs @@ -9,7 +9,7 @@ use serde::Deserialize; #[serde(tag = "type", content = "data")] #[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] pub enum Payload { - /// A `integration.create` webhook payload. Fires when a user has connected to your webhook integration. + /// An `integration.create` webhook payload. Fires when a user has connected to your webhook integration. #[serde(rename = "integration.create")] IntegrationCreate { /// The unique identifier for this connection. @@ -27,7 +27,7 @@ pub enum Payload { user: User, }, - /// A `integration.delete` webhook payload. Fires when a user has disconnected from your webhook integration. + /// An `integration.delete` webhook payload. Fires when a user has disconnected from your webhook integration. #[serde(rename = "integration.delete")] IntegrationDelete { /// The unique identifier for this connection. From 83517df6136aee99732354b41224f654fc8544c1 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:32:17 +0700 Subject: [PATCH 05/33] doc: grammatical fixes --- src/widget.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/widget.rs b/src/widget.rs index e668410..fb938f6 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,6 +1,6 @@ use crate::{ProjectType, Snowflake}; -/// Generates a large widget URL. +/// Generate a large widget URL. /// /// # Example /// @@ -18,7 +18,7 @@ where ) } -/// Generates a small widget URL for displaying votes. +/// Generate a small widget URL for displaying votes. /// /// # Example /// @@ -36,7 +36,7 @@ where ) } -/// Generates a small widget URL for displaying a project's owner. +/// Generate a small widget URL for displaying a project's owner. /// /// # Example /// @@ -54,7 +54,7 @@ where ) } -/// Generates a small widget URL for displaying social stats. +/// Generate a small widget URL for displaying social stats. /// /// # Example /// From c4f56f7a34ca6f7f8d46b93eaf4cf8cdbabac418 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Mon, 2 Mar 2026 20:47:23 +0700 Subject: [PATCH 06/33] doc: more consistent README with the other SDKs --- README.md | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 625d0e5..3390e9b 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,34 @@ -# [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 = "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: @@ -29,7 +42,13 @@ This library provides several feature flags that can be enabled/disabled in `Car - **`serenity`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity). - **`twilight`**: Extra helpers for working with [twilight](https://twilight.rs). -## Examples +## Setting up + +```rust,no_run +let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); +``` + +## Usage ### Getting your project's information From c9aec2263a8bf0b4bed35c0d791009424a8819ea Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:25:07 +0700 Subject: [PATCH 07/33] refactor: remove token validation --- Cargo.toml | 3 +-- src/client.rs | 6 ------ src/util.rs | 24 ++---------------------- 3 files changed, 3 insertions(+), 30 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 478d702..41a6402 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ categories = ["api-bindings", "web-programming::http-client"] exclude = [".gitattributes", ".github/", ".gitignore", "rustfmt.toml"] [dependencies] -base64 = { version = "0.22", optional = true } cfg-if = "1" reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } @@ -50,7 +49,7 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["async-trait", "base64", "reqwest", "serde_json", "urlencoding"] +api = ["async-trait", "reqwest", "serde_json", "urlencoding"] serenity = ["dep:serenity"] twilight = ["twilight-http", "twilight-model"] diff --git a/src/client.rs b/src/client.rs index 81e0c32..a615004 100644 --- a/src/client.rs +++ b/src/client.rs @@ -36,18 +36,12 @@ pub struct Client { impl Client { /// Creates a new instance. /// - /// # Panics - /// - /// Panics if the client uses an invalid API token. - /// /// # Example /// /// ```rust,no_run /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); /// ``` pub fn new(token: String) -> Self { - util::validate_api_token(&token); - Self { http: reqwest::Client::new(), token: format!("Bearer {token}"), diff --git a/src/util.rs b/src/util.rs index c3ef6e7..86d421c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,8 +1,7 @@ -use super::{Error, snowflake}; +use super::Error; -use base64::Engine; use reqwest::Response; -use serde::{Deserialize, de::DeserializeOwned}; +use serde::de::DeserializeOwned; pub async fn parse_json(response: Response) -> super::Result where @@ -16,22 +15,3 @@ where Err(Error::InternalServerError) } - -#[derive(Deserialize)] -#[allow(clippy::used_underscore_binding)] -struct TokenStructure { - #[serde(deserialize_with = "snowflake::deserialize", rename = "id")] - _id: u64, -} - -pub fn validate_api_token(token: &str) { - if let Some(base64_section) = token.split('.').nth(1) - && let Ok(decoded_base64) = - base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) - && serde_json::from_slice::(&decoded_base64).is_ok() - { - return; - } - - panic!("Got a malformed API token."); -} From b6442c03f2dbc70afe2c49a566c70317bfe44210 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 3 Mar 2026 17:25:12 +0700 Subject: [PATCH 08/33] style: prettier --- src/webhooks/actix_web.rs | 4 ++-- src/webhooks/axum.rs | 2 +- src/webhooks/warp.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/webhooks/actix_web.rs b/src/webhooks/actix_web.rs index 9393140..ec6bdcc 100644 --- a/src/webhooks/actix_web.rs +++ b/src/webhooks/actix_web.rs @@ -1,15 +1,15 @@ use crate::Incoming; use actix_web::{ + FromRequest, HttpRequest, dev::Payload, error::{Error, ErrorBadRequest, ErrorUnauthorized}, web::Json, - FromRequest, HttpRequest, }; use serde::de::DeserializeOwned; use std::{ future::Future, pin::Pin, - task::{ready, Context, Poll}, + task::{Context, Poll, ready}, }; #[doc(hidden)] diff --git a/src/webhooks/axum.rs b/src/webhooks/axum.rs index 4175b1b..0e27d30 100644 --- a/src/webhooks/axum.rs +++ b/src/webhooks/axum.rs @@ -1,10 +1,10 @@ use super::Webhook; use axum::{ + Router, extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::post, - Router, }; use serde::de::DeserializeOwned; use std::sync::Arc; diff --git a/src/webhooks/warp.rs b/src/webhooks/warp.rs index 51c108b..5933bb8 100644 --- a/src/webhooks/warp.rs +++ b/src/webhooks/warp.rs @@ -1,7 +1,7 @@ use super::Webhook; use serde::de::DeserializeOwned; use std::sync::Arc; -use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; +use warp::{Filter, Rejection, Reply, body, header, http::StatusCode, path}; /// Creates a new warp [`Filter`] for receiving webhook events. /// From 181a3257e13db2f4451c0594f711bfecc6d1e96d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:00:21 +0700 Subject: [PATCH 09/33] doc: update project description in Cargo.toml --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 41a6402..908e9e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "2.0.0" 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" @@ -69,4 +69,4 @@ broken_intra_doc_links = "deny" [lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } \ No newline at end of file +nursery = { level = "warn", priority = -1 } From d93289e8195e92c871a71e956eaa8e983f606389 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:19:59 +0700 Subject: [PATCH 10/33] feat: rename created_at to voted_at --- src/webhooks/payload.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/webhooks/payload.rs b/src/webhooks/payload.rs index 6723f88..12b16c1 100644 --- a/src/webhooks/payload.rs +++ b/src/webhooks/payload.rs @@ -56,7 +56,8 @@ pub enum Payload { weight: u64, /// When the vote was cast. - created_at: DateTime, + #[serde(rename = "created_at")] + voted_at: DateTime, /// When the vote expires and the user is required to vote again. expires_at: DateTime, From d795ca5391197e4c6cd442427f21e8868214a0f4 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:46:04 +0700 Subject: [PATCH 11/33] doc: fix minor punctuation issue in README features section --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3390e9b..c0d06a5 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ topgg = "2" ## 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) - **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Payload` struct. From 61e4e6f9e588de99092576df3538bcae5c652561 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:48:16 +0700 Subject: [PATCH 12/33] doc: fix typo in README for webhook feature flags --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c0d06a5..b857e43 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ topgg = "2" 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) -- **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Payload` 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. From a428c976bc816a235d47d34bd02011cd9352de41 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:48:49 +0700 Subject: [PATCH 13/33] revert: revert webhooks changes to prevent conflicts with the other pull request --- src/webhooks/actix_web.rs | 4 ++-- src/webhooks/axum.rs | 2 +- src/webhooks/warp.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/webhooks/actix_web.rs b/src/webhooks/actix_web.rs index ec6bdcc..9393140 100644 --- a/src/webhooks/actix_web.rs +++ b/src/webhooks/actix_web.rs @@ -1,15 +1,15 @@ use crate::Incoming; use actix_web::{ - FromRequest, HttpRequest, dev::Payload, error::{Error, ErrorBadRequest, ErrorUnauthorized}, web::Json, + FromRequest, HttpRequest, }; use serde::de::DeserializeOwned; use std::{ future::Future, pin::Pin, - task::{Context, Poll, ready}, + task::{ready, Context, Poll}, }; #[doc(hidden)] diff --git a/src/webhooks/axum.rs b/src/webhooks/axum.rs index 0e27d30..4175b1b 100644 --- a/src/webhooks/axum.rs +++ b/src/webhooks/axum.rs @@ -1,10 +1,10 @@ use super::Webhook; use axum::{ - Router, extract::State, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::post, + Router, }; use serde::de::DeserializeOwned; use std::sync::Arc; diff --git a/src/webhooks/warp.rs b/src/webhooks/warp.rs index 5933bb8..51c108b 100644 --- a/src/webhooks/warp.rs +++ b/src/webhooks/warp.rs @@ -1,7 +1,7 @@ use super::Webhook; use serde::de::DeserializeOwned; use std::sync::Arc; -use warp::{Filter, Rejection, Reply, body, header, http::StatusCode, path}; +use warp::{body, header, http::StatusCode, path, Filter, Rejection, Reply}; /// Creates a new warp [`Filter`] for receiving webhook events. /// From 25aba8802f4834571c9eb225f27ec2cca4659bc9 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:39:15 +0700 Subject: [PATCH 14/33] feat: rename user_id to voter_id for clarity --- src/user.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/user.rs b/src/user.rs index e1940ea..c60d1a3 100644 --- a/src/user.rs +++ b/src/user.rs @@ -42,8 +42,8 @@ cfg_if::cfg_if! { #[cfg_attr(docsrs, doc(cfg(feature = "api")))] pub struct Vote { /// The voter's ID. - #[serde(deserialize_with = "snowflake::deserialize")] - pub user_id: u64, + #[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")] From 8d9e27984f5fd75abf69dbea76791f415ecca99d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:53:58 +0700 Subject: [PATCH 15/33] style: minor dependabot.yml reformatting --- .github/dependabot.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5505ae2..5ef95a7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,10 @@ version: 2 updates: - - package-ecosystem: "cargo" - directory: "/" + - package-ecosystem: cargo + directory: '/' commit-message: - prefix: "deps: " + prefix: 'deps: ' schedule: - day: "saturday" - interval: "weekly" - time: "07:15" \ No newline at end of file + day: saturday + interval: weekly + time: '07:15' \ No newline at end of file From 67cadd16e5d5f629720b99037564db1f6616cdd7 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Mon, 9 Mar 2026 22:13:22 +0700 Subject: [PATCH 16/33] perf: use f32 instead of redundant f64 --- src/project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/project.rs b/src/project.rs index 8aaca47..b937694 100644 --- a/src/project.rs +++ b/src/project.rs @@ -90,7 +90,7 @@ cfg_if::cfg_if! { pub total_votes: u64, /// The project's review score out of 5. - pub review_score: f64, + pub review_score: f32, /// The project's total review count. pub review_count: u64, From f58e8df4592084ec19a503aa8dc99163c83d26e5 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:16:31 +0700 Subject: [PATCH 17/33] doc: minor docstring tweaks for widgets --- src/widget.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/widget.rs b/src/widget.rs index fb938f6..e668410 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,6 +1,6 @@ use crate::{ProjectType, Snowflake}; -/// Generate a large widget URL. +/// Generates a large widget URL. /// /// # Example /// @@ -18,7 +18,7 @@ where ) } -/// Generate a small widget URL for displaying votes. +/// Generates a small widget URL for displaying votes. /// /// # Example /// @@ -36,7 +36,7 @@ where ) } -/// Generate a small widget URL for displaying a project's owner. +/// Generates a small widget URL for displaying a project's owner. /// /// # Example /// @@ -54,7 +54,7 @@ where ) } -/// Generate a small widget URL for displaying social stats. +/// Generates a small widget URL for displaying social stats. /// /// # Example /// From 3f80dbb2fa7957e75439a701be0857dd533cd28b Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:03:05 +0700 Subject: [PATCH 18/33] doc: improve and further clarify client documentation --- src/client.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client.rs b/src/client.rs index a615004..334281a 100644 --- a/src/client.rs +++ b/src/client.rs @@ -26,7 +26,7 @@ struct Ratelimit { retry_after: u16, } -/// Interact with API v1's endpoints. +/// Interact with Top.gg API v1's endpoints. #[must_use] pub struct Client { http: reqwest::Client, @@ -34,7 +34,7 @@ pub struct Client { } impl Client { - /// Creates a new instance. + /// Creates a new client instance. /// /// # Example /// From 99133e983d373e1e5990055977243da19fea60fe Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 10 Mar 2026 21:18:33 +0700 Subject: [PATCH 19/33] doc: minor grammar fixes --- src/project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/project.rs b/src/project.rs index b937694..5bdec27 100644 --- a/src/project.rs +++ b/src/project.rs @@ -32,7 +32,7 @@ impl ProjectType { } } -/// A brief information on project listed on Top.gg. +/// A brief information on a project listed on Top.gg. #[cfg(feature = "webhooks")] #[derive(Clone, Debug, Deserialize)] #[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] From b8cd5553216f428a09649e3e145546de809c0038 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:22:04 +0700 Subject: [PATCH 20/33] doc: fix documentation link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b857e43..28e5600 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [crates-io-downloads-image]: https://img.shields.io/crates/d/topgg?style=flat-square [crates-io-url]: https://crates.io/crates/topgg -> For more information, see the documentation here: https://docs.rs/topgg. +> For more information, see the documentation here: . The community-maintained Rust library for Top.gg. From 5b2a506047f23a699539d605251069a825fbd2fc Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:22:44 +0700 Subject: [PATCH 21/33] deps: add log dependency to select webhook impls --- Cargo.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 908e9e2..136c33f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ twilight-model = { version = "0.15", optional = true } chrono = { version = "0.4", default-features = false, features = ["serde", "now"] } serde_json = { version = "1", optional = true } +log = { version = "0.4", optional = true } rocket = { version = "0.5", default-features = false, features = ["json"], optional = true } axum = { version = "0.8", default-features = false, optional = true, features = ["http1", "tokio"] } @@ -55,10 +56,10 @@ serenity = ["dep:serenity"] twilight = ["twilight-http", "twilight-model"] webhooks = [] -rocket = ["webhooks", "hex", "hmac", "serde_json", "sha2", "dep:rocket"] -axum = ["webhooks", "async-trait", "hex", "hmac", "serde_json", "sha2", "dep:axum"] +rocket = ["webhooks", "hex", "hmac", "log", "serde_json", "sha2", "dep:rocket"] +axum = ["webhooks", "async-trait", "hex", "hmac", "log", "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"] +actix-web = ["webhooks", "futures-core", "hex", "hmac", "log", "serde_json", "sha2", "dep:actix-web"] [lints.rust] unsafe_code = "forbid" From fae3d3a0263caa180b3de414f580e9bd45f8a9ec Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 01:25:08 +0700 Subject: [PATCH 22/33] doc: make example return 204 instead of 200 for consistency --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 28e5600..42e0f53 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ fn webhook(payload: IncomingPayload) -> Status { Some(payload) => { println!("{payload:?}"); - Status::Ok + Status::NoContent }, _ => { println!("found an unauthorized attacker."); From 47e6174453a02544d1982ac40bd448ea0f357f99 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:42:04 +0700 Subject: [PATCH 23/33] meta: refer to itself as an SDK, not a library --- Cargo.toml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 136c33f..5a3d9e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "2.0.0" edition = "2024" rust-version = "1.87" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] -description = "The community-maintained Rust library for Top.gg." +description = "The community-maintained Rust SDK for Top.gg." readme = "README.md" repository = "https://github.com/Top-gg-Community/rust-sdk" license = "MIT" diff --git a/README.md b/README.md index 42e0f53..80cd26a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ > For more information, see the documentation here: . -The community-maintained Rust library for Top.gg. +The community-maintained Rust SDK for Top.gg. ## Chapters From dfa6787c7adbd7960a44f4b7df035246dd41312b Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:01:16 +0700 Subject: [PATCH 24/33] feat: improve widgets --- Cargo.toml | 3 ++- README.md | 8 ++++---- src/project.rs | 23 +++++------------------ src/widget.rs | 42 +++++++++++++++++++++++++----------------- 4 files changed, 36 insertions(+), 40 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5a3d9e4..37b8398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ twilight-model = { version = "0.15", optional = true } chrono = { version = "0.4", default-features = false, features = ["serde", "now"] } serde_json = { version = "1", optional = true } +serde_variant = { version = "0.1", optional = true } log = { version = "0.4", optional = true } rocket = { version = "0.5", default-features = false, features = ["json"], optional = true } @@ -50,7 +51,7 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["async-trait", "reqwest", "serde_json", "urlencoding"] +api = ["async-trait", "reqwest", "serde_json", "serde_variant", "urlencoding"] serenity = ["dep:serenity"] twilight = ["twilight-http", "twilight-model"] diff --git a/README.md b/README.md index 80cd26a..0b23b0b 100644 --- a/README.md +++ b/README.md @@ -128,25 +128,25 @@ client.post_commands(commands).await.unwrap(); #### Large ```rust,no_run -let widget_url = topgg::widget::large(topgg::ProjectType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::large(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); ``` #### Votes ```rust,no_run -let widget_url = topgg::widget::votes(topgg::ProjectType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::votes(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); ``` #### Owner ```rust,no_run -let widget_url = topgg::widget::owner(topgg::ProjectType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::owner(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); ``` #### Social ```rust,no_run -let widget_url = topgg::widget::social(topgg::ProjectType::DiscordBot, 574652751745777665); +let widget_url = topgg::widget::social(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); ``` ### Webhooks diff --git a/src/project.rs b/src/project.rs index 5bdec27..b698d9e 100644 --- a/src/project.rs +++ b/src/project.rs @@ -4,7 +4,7 @@ use serde::Deserialize; /// A project's platform. #[non_exhaustive] -#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq)] +#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Platform { Discord, @@ -12,24 +12,11 @@ pub enum Platform { /// A project's type. #[non_exhaustive] -#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq)] +#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] 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", - } - } + Bot, + Server, } /// A brief information on a project listed on Top.gg. diff --git a/src/widget.rs b/src/widget.rs index e668410..7515124 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,19 +1,21 @@ -use crate::{ProjectType, Snowflake}; +use crate::{Platform, ProjectType, Snowflake}; /// Generates a large widget URL. /// /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::large(topgg::ProjectType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::large(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); /// ``` -pub fn large(project_type: ProjectType, id: I) -> String +#[allow(clippy::missing_panics_doc)] +pub fn large(platform: Platform, project_type: ProjectType, id: I) -> String where I: Snowflake, { crate::client::api!( - "/widgets/large/{}/{}", - project_type.as_widget_path(), + "/widgets/large/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), id.as_snowflake() ) } @@ -23,15 +25,17 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::votes(topgg::ProjectType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::votes(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); /// ``` -pub fn votes(project_type: ProjectType, id: I) -> String +#[allow(clippy::missing_panics_doc)] +pub fn votes(platform: Platform, project_type: ProjectType, id: I) -> String where I: Snowflake, { crate::client::api!( - "/widgets/small/votes/{}/{}", - project_type.as_widget_path(), + "/widgets/small/votes/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), id.as_snowflake() ) } @@ -41,15 +45,17 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::owner(topgg::ProjectType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::owner(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); /// ``` -pub fn owner(project_type: ProjectType, id: I) -> String +#[allow(clippy::missing_panics_doc)] +pub fn owner(platform: Platform, project_type: ProjectType, id: I) -> String where I: Snowflake, { crate::client::api!( - "/widgets/small/owner/{}/{}", - project_type.as_widget_path(), + "/widgets/small/owner/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), id.as_snowflake() ) } @@ -59,15 +65,17 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::social(topgg::ProjectType::DiscordBot, 574652751745777665); +/// let widget_url = topgg::widget::social(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); /// ``` -pub fn social(project_type: ProjectType, id: I) -> String +#[allow(clippy::missing_panics_doc)] +pub fn social(platform: Platform, project_type: ProjectType, id: I) -> String where I: Snowflake, { crate::client::api!( - "/widgets/small/social/{}/{}", - project_type.as_widget_path(), + "/widgets/small/social/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), id.as_snowflake() ) } From e2f70873aa51554c24abfbe7f11be2197204aa10 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 11 Mar 2026 07:30:55 +0700 Subject: [PATCH 25/33] doc: update widget example ID --- README.md | 8 ++++---- src/widget.rs | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0b23b0b..3e81145 100644 --- a/README.md +++ b/README.md @@ -128,25 +128,25 @@ client.post_commands(commands).await.unwrap(); #### Large ```rust,no_run -let widget_url = topgg::widget::large(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +let widget_url = topgg::widget::large(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); ``` #### Votes ```rust,no_run -let widget_url = topgg::widget::votes(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +let widget_url = topgg::widget::votes(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); ``` #### Owner ```rust,no_run -let widget_url = topgg::widget::owner(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +let widget_url = topgg::widget::owner(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); ``` #### Social ```rust,no_run -let widget_url = topgg::widget::social(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +let widget_url = topgg::widget::social(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); ``` ### Webhooks diff --git a/src/widget.rs b/src/widget.rs index 7515124..546dc2b 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -5,7 +5,7 @@ use crate::{Platform, ProjectType, Snowflake}; /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::large(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +/// let widget_url = topgg::widget::large(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); /// ``` #[allow(clippy::missing_panics_doc)] pub fn large(platform: Platform, project_type: ProjectType, id: I) -> String @@ -25,7 +25,7 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::votes(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +/// let widget_url = topgg::widget::votes(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); /// ``` #[allow(clippy::missing_panics_doc)] pub fn votes(platform: Platform, project_type: ProjectType, id: I) -> String @@ -45,7 +45,7 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::owner(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +/// let widget_url = topgg::widget::owner(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); /// ``` #[allow(clippy::missing_panics_doc)] pub fn owner(platform: Platform, project_type: ProjectType, id: I) -> String @@ -65,7 +65,7 @@ where /// # Example /// /// ```rust,no_run -/// let widget_url = topgg::widget::social(topgg::Platform::Discord, topgg::ProjectType::Bot, 574652751745777665); +/// let widget_url = topgg::widget::social(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); /// ``` #[allow(clippy::missing_panics_doc)] pub fn social(platform: Platform, project_type: ProjectType, id: I) -> String From 0c8bfc9cd6ed0ce390cdd261235f294e18e67e2c Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:34:45 +0700 Subject: [PATCH 26/33] doc: fix duplicate project ID docstring Updated documentation to reflect the correct field description. --- src/project.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/project.rs b/src/project.rs index b698d9e..2f6e9b4 100644 --- a/src/project.rs +++ b/src/project.rs @@ -28,7 +28,7 @@ pub struct PartialProject { /// The project's ID. pub id: u64, - /// The project's ID. + /// The project's type. #[serde(rename = "type")] pub kind: ProjectType, From 6f5957122858df98946c4bc9d57e409a8e840d45 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Mon, 16 Mar 2026 17:23:10 +0700 Subject: [PATCH 27/33] doc: refer to itself as an SDK, not a library --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e81145..fd07e6f 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ topgg = "2" ## Features -This library provides several feature flags that can be enabled/disabled in `Cargo.toml`, such as: +This SDK 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) - **`webhooks`**: Accessing [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) webhook payload structs. From 30fa6cdff3dab9b3ceb5f4c2671976ea1710cee8 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 18 Mar 2026 06:29:48 +0700 Subject: [PATCH 28/33] deps: add timeout handling to actix-web, rocket, and axum --- Cargo.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 37b8398..c2d15fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ axum = { version = "0.8", default-features = false, optional = true, features = 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 } +tower = { version = "0.5", features = ["timeout"], optional = true } [dev-dependencies] tokio = { version = "1", features = ["rt", "macros"] } @@ -57,8 +58,8 @@ serenity = ["dep:serenity"] twilight = ["twilight-http", "twilight-model"] webhooks = [] -rocket = ["webhooks", "hex", "hmac", "log", "serde_json", "sha2", "dep:rocket"] -axum = ["webhooks", "async-trait", "hex", "hmac", "log", "serde_json", "sha2", "dep:axum"] +rocket = ["webhooks", "hex", "hmac", "log", "serde_json", "sha2", "tokio/time", "dep:rocket"] +axum = ["webhooks", "async-trait", "hex", "hmac", "log", "serde_json", "sha2", "tower", "dep:axum"] warp = ["webhooks", "bytes", "hex", "hmac", "serde_json", "sha2", "dep:warp"] actix-web = ["webhooks", "futures-core", "hex", "hmac", "log", "serde_json", "sha2", "dep:actix-web"] From f28957a33465a5d4f09f437cb4d046dca90bd0e1 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:54:59 +0700 Subject: [PATCH 29/33] doc: change 404 error description --- src/error.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index ca90242..e6dab54 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,7 +14,7 @@ pub enum Error { /// Attempted to send an invalid request to the API. InvalidRequest, - /// Such query does not exist. + /// Such route does not exist. NotFound, /// Ratelimited from sending more requests. From c1edc1d195dcd403f04afc54dbd3927bb0a05c49 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:25:48 +0700 Subject: [PATCH 30/33] feat: add forbidden error type --- src/client.rs | 4 +++- src/error.rs | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/client.rs b/src/client.rs index 334281a..0c6a651 100644 --- a/src/client.rs +++ b/src/client.rs @@ -77,7 +77,9 @@ impl Client { Ok(response) } else { Err(match status { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => panic!("Invalid API token."), + StatusCode::UNAUTHORIZED => panic!("Invalid API token."), + + StatusCode::FORBIDDEN => Error::Forbidden, StatusCode::NOT_FOUND => Error::NotFound, diff --git a/src/error.rs b/src/error.rs index e6dab54..a001ea9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { /// Attempted to send an invalid request to the API. InvalidRequest, + /// You don't have access to this endpoint. + Forbidden, + /// Such route does not exist. NotFound, @@ -31,9 +34,11 @@ impl fmt::Display for Error { Self::InternalServerError => write!(f, "Internal Server Error"), - Self::InvalidRequest => write!(f, "Invalid Request"), + Self::InvalidRequest => write!(f, "Attempted to send an invalid request to the API"), + + Self::NotFound => write!(f, "Such route does not exist"), - Self::NotFound => write!(f, "Not Found"), + Self::Forbidden => write!(f, "You don't have access to this endpoint"), Self::Ratelimit { retry_after } => write!( f, From 8cfa23a0657425fa7cb4c07e4300f3cf84d4816d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:39:15 +0700 Subject: [PATCH 31/33] deps: optimize Cargo.toml dependencies --- Cargo.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c2d15fa..ccd3772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ twilight-http = { version = "0.15", optional = true } twilight-model = { version = "0.15", optional = true } chrono = { version = "0.4", default-features = false, features = ["serde", "now"] } -serde_json = { version = "1", optional = true } +serde_json = "1" serde_variant = { version = "0.1", optional = true } log = { version = "0.4", optional = true } @@ -52,16 +52,16 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["async-trait", "reqwest", "serde_json", "serde_variant", "urlencoding"] +api = ["async-trait", "reqwest", "serde_variant", "urlencoding"] serenity = ["dep:serenity"] twilight = ["twilight-http", "twilight-model"] -webhooks = [] -rocket = ["webhooks", "hex", "hmac", "log", "serde_json", "sha2", "tokio/time", "dep:rocket"] -axum = ["webhooks", "async-trait", "hex", "hmac", "log", "serde_json", "sha2", "tower", "dep:axum"] -warp = ["webhooks", "bytes", "hex", "hmac", "serde_json", "sha2", "dep:warp"] -actix-web = ["webhooks", "futures-core", "hex", "hmac", "log", "serde_json", "sha2", "dep:actix-web"] +webhooks = ["hex", "hmac", "log", "sha2"] +rocket = ["webhooks", "tokio/time", "dep:rocket"] +axum = ["webhooks", "async-trait", "tower", "dep:axum"] +warp = ["webhooks", "bytes", "dep:warp"] +actix-web = ["webhooks", "futures-core", "dep:actix-web"] [lints.rust] unsafe_code = "forbid" From d6cc9ede584d3fdd63015d976a430dfa411096c1 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:40:29 +0700 Subject: [PATCH 32/33] [fix,doc]: fix invalid imports and change to_string to into --- src/client.rs | 2 +- src/project.rs | 4 ++-- src/test.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client.rs b/src/client.rs index 0c6a651..7a941ae 100644 --- a/src/client.rs +++ b/src/client.rs @@ -39,7 +39,7 @@ impl Client { /// # Example /// /// ```rust,no_run - /// let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").into()); /// ``` pub fn new(token: String) -> Self { Self { diff --git a/src/project.rs b/src/project.rs index 2f6e9b4..ce1a7ca 100644 --- a/src/project.rs +++ b/src/project.rs @@ -1,6 +1,6 @@ use super::snowflake; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; /// A project's platform. #[non_exhaustive] @@ -42,7 +42,7 @@ pub struct PartialProject { cfg_if::cfg_if! { if #[cfg(feature = "api")] { - use serde::{Serialize, de::DeserializeOwned, ser::Error}; + use serde::{de::DeserializeOwned, ser::Error}; /// A project listed on Top.gg. #[derive(Clone, Debug, Deserialize)] diff --git a/src/test.rs b/src/test.rs index 298fd70..10b48f7 100644 --- a/src/test.rs +++ b/src/test.rs @@ -13,7 +13,7 @@ macro_rules! delayed { #[tokio::test] #[allow(clippy::unreadable_literal)] async fn api() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); + let client = Client::new(env!("TOPGG_TOKEN").into()); delayed! { let _project = client.get_self().await.unwrap(); From 2ca9f38fbafa00bd2501e0fb9b4ae823ee1601c2 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:40:53 +0700 Subject: [PATCH 33/33] doc: update readme to reflect changes done to webhooks --- README.md | 93 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index fd07e6f..a249c9b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ This SDK provides several feature flags that can be enabled/disabled in `Cargo.t ## Setting up ```rust,no_run -let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); +let client = topgg::Client::new(env!("TOPGG_TOKEN").into()); ``` ## Usage @@ -163,12 +163,13 @@ topgg = { version = "2", default-features = false, features = ["actix-web"] } In your code: ```rust,no_run -use topgg::IncomingPayload; +use topgg::{IncomingPayload, PayloadResult}; use std::io; use actix_web::{ - error::{Error, ErrorUnauthorized}, - get, post, App, HttpServer, + App, HttpServer, + error::{Error, ErrorBadRequest, ErrorForbidden, ErrorInternalServerError, ErrorUnauthorized}, + get, post, }; #[get("/")] @@ -176,16 +177,25 @@ async fn index() -> &'static str { "Hello, World!" } +// POST /webhook #[post("/webhook")] async fn webhook(payload: IncomingPayload) -> Result<&'static str, Error> { match payload.authenticate(env!("TOPGG_WEBHOOK_SECRET")) { - Some(payload) => { + PayloadResult::Accepted(payload) => { println!("{payload:?}"); Ok("ok") } - _ => Err(ErrorUnauthorized("401")), + PayloadResult::Forbidden => Err(ErrorForbidden("Forbidden")), + + PayloadResult::BadRequest => Err(ErrorBadRequest("Bad Request")), + + PayloadResult::Unauthorized => Err(ErrorUnauthorized("Unauthorized")), + + PayloadResult::DeserializationFailure => Ok(""), + + PayloadResult::InternalServerError => Err(ErrorInternalServerError("Internal Server Error")), } } @@ -213,7 +223,12 @@ In your code: use topgg::Payload; use std::sync::Arc; -use axum::{http::status::StatusCode, response::{IntoResponse, Response}, routing::get, Router}; +use axum::{ + Router, + http::status::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; use tokio::net::TcpListener; struct MyTopggListener {} @@ -235,9 +250,10 @@ async fn index() -> &'static str { async fn main() { let state = Arc::new(MyTopggListener {}); + // POST /webhook let router = Router::new().route("/", get(index)).nest( "/webhook", - topgg::axum::webhook(Arc::clone(&state), env!("TOPGG_WEBHOOK_SECRET").to_string()), + topgg::axum::webhook(Arc::clone(&state), env!("TOPGG_WEBHOOK_SECRET").into()), ); let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); @@ -258,28 +274,34 @@ topgg = { version = "2", default-features = false, features = ["rocket"] } In your code: ```rust,no_run -use topgg::IncomingPayload; +use topgg::{IncomingPayload, PayloadResult}; -use rocket::{get, http::Status, launch, post, routes, Build, Rocket}; +use rocket::{Build, Rocket, get, http::Status, launch, post, routes}; #[get("/")] fn index() -> &'static str { "Hello, World!" } +// POST /webhook #[post("/webhook", data = "")] fn webhook(payload: IncomingPayload) -> Status { match payload.authenticate(env!("TOPGG_WEBHOOK_SECRET")) { - Some(payload) => { + PayloadResult::Accepted(payload) => { println!("{payload:?}"); Status::NoContent - }, - _ => { - println!("found an unauthorized attacker."); - - Status::Unauthorized } + + PayloadResult::Forbidden => Status::Forbidden, + + PayloadResult::BadRequest => Status::BadRequest, + + PayloadResult::Unauthorized => Status::Unauthorized, + + PayloadResult::DeserializationFailure => Status::NoContent, + + PayloadResult::InternalServerError => Status::InternalServerError, } } @@ -301,27 +323,36 @@ topgg = { version = "2", default-features = false, features = ["warp"] } In your code: ```rust,no_run +use topgg::PayloadResult; use std::net::SocketAddr; -use warp::{http::StatusCode, reply, Filter}; +use warp::{Filter, http::StatusCode, reply}; #[tokio::main] async fn main() { // POST /webhook - let webhook = topgg::warp::webhook( - "webhook", - 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 webhook = + topgg::warp::webhook("webhook", env!("TOPGG_WEBHOOK_SECRET").into()).then(|payload, _trace| async move { + match payload { + PayloadResult::Accepted(payload) => { + println!("{payload:?}"); + + reply::with_status("", StatusCode::NO_CONTENT) + } + + PayloadResult::Forbidden => reply::with_status("Forbidden", StatusCode::FORBIDDEN), + + PayloadResult::BadRequest => reply::with_status("Bad Request", StatusCode::BAD_REQUEST), + + PayloadResult::Unauthorized => reply::with_status("Unauthorized", StatusCode::UNAUTHORIZED), + + PayloadResult::DeserializationFailure => reply::with_status("", StatusCode::NO_CONTENT), + + PayloadResult::InternalServerError => { + reply::with_status("Internal Server Error", StatusCode::INTERNAL_SERVER_ERROR) + } + } + }); let routes = warp::get().map(|| "Hello, World!").or(webhook);