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 diff --git a/Cargo.toml b/Cargo.toml index 3cfece0..ccd3772 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,10 @@ [package] name = "topgg" version = "2.0.0" -edition = "2021" +edition = "2024" +rust-version = "1.87" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] -description = "A simple API wrapper for Top.gg written in Rust." +description = "The community-maintained Rust SDK for Top.gg." readme = "README.md" repository = "https://github.com/Top-gg-Community/rust-sdk" license = "MIT" @@ -12,50 +13,38 @@ categories = ["api-bindings", "web-programming::http-client"] exclude = [".gitattributes", ".github/", ".gitignore", "rustfmt.toml"] [dependencies] -base64 = { version = "0.22", optional = true } cfg-if = "1" -paste = { version = "1", optional = true } reqwest = { version = "0.12", optional = true } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt", "sync", "time"], optional = true } -urlencoding = "2" +urlencoding = { version = "2", optional = true } +bytes = { version = "1.11", optional = true } +hex = { version = "0.4", optional = true } +sha2 = { version = "0.10", optional = true } +hmac = { version = "0.12", optional = true } +futures-core = { version = "0.3", optional = true } serenity = { version = "0.12", features = ["builder", "client", "gateway", "model", "utils"], optional = true } twilight-http = { version = "0.15", optional = true } twilight-model = { version = "0.15", optional = true } -twilight-cache-inmemory = { version = "0.15", optional = true } -chrono = { version = "0.4", default-features = false, optional = true, features = ["serde", "now"] } -serde_json = { version = "1", optional = true } +chrono = { version = "0.4", default-features = false, features = ["serde", "now"] } +serde_json = "1" +serde_variant = { version = "0.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"] } 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"] } 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 +52,24 @@ rustc-args = ["--cfg", "docsrs"] [features] default = ["api"] -api = ["async-trait", "base64", "chrono", "reqwest", "serde_json"] -bot-autoposter = ["api", "tokio"] -autoposter = ["bot-autoposter"] +api = ["async-trait", "reqwest", "serde_variant", "urlencoding"] + +serenity = ["dep:serenity"] +twilight = ["twilight-http", "twilight-model"] -serenity = ["dep:serenity", "paste"] -serenity-cached = ["serenity", "serenity/cache"] +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"] -twilight = ["twilight-model", "twilight-http"] -twilight-cached = ["twilight", "twilight-cache-inmemory"] +[lints.rust] +unsafe_code = "forbid" -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 +[lints.rustdoc] +broken_intra_doc_links = "deny" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index a258444..a249c9b 100644 --- a/README.md +++ b/README.md @@ -1,234 +1,201 @@ -# [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: . -## Getting Started +The community-maintained Rust SDK for Top.gg. -Make sure to have a [Top.gg API](https://docs.top.gg) token handy. If not, then [view this tutorial on how to retrieve yours](https://github.com/top-gg/rust-sdk/assets/60427892/d2df5bd3-bc48-464c-b878-a04121727bff). After that, add the following line to the `dependencies` section of your `Cargo.toml`: +## Chapters + +- [Installation](#installation) +- [Features](#features) +- [Setting up](#setting-up) +- [Usage](#usage) + - [Getting your project's information](#getting-your-projects-information) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) + - [Getting a cursor-based paginated list of votes for your project](#getting-a-cursor-based-paginated-list-of-votes-for-your-project) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + +## Installation + +Add the following line to the `dependencies` section of your `Cargo.toml`: ```toml -topgg = "1.4" +topgg = "2" ``` -For more information, please read [the documentation](https://docs.rs/topgg)! - ## Features -This library provides several feature flags that can be enabled/disabled in `Cargo.toml`. Such as: +This 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) - - **`autoposter`**: Automating the process of periodically posting bot statistics to the [Top.gg API](https://docs.top.gg). -- **`webhook`**: Accessing the [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) `topgg::Vote` struct. +- **`webhooks`**: Accessing [serde deserializable](https://docs.rs/serde/latest/serde/de/trait.DeserializeOwned.html) webhook payload structs. - **`actix-web`**: Wrapper for working with the [actix-web](https://actix.rs/) web framework. - **`axum`**: Wrapper for working with the [axum](https://crates.io/crates/axum) web framework. - **`rocket`**: Wrapper for working with the [rocket](https://rocket.rs/) web framework. - **`warp`**: Wrapper for working with the [warp](https://crates.io/crates/warp) web framework. -- **`serenity`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching disabled). - - **`serenity-cached`**: Extra helpers for working with [serenity](https://crates.io/crates/serenity) library (with bot caching enabled). -- **`twilight`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching disabled). - - **`twilight-cached`**: Extra helpers for working with [twilight](https://twilight.rs) library (with bot caching enabled). +- **`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 +## Setting up ```rust,no_run -use topgg::Client; +let client = topgg::Client::new(env!("TOPGG_TOKEN").into()); +``` -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); - let user = client.get_user(661200758510977084).await.unwrap(); - - assert_eq!(user.username, "null"); - assert_eq!(user.id, 661200758510977084); - - println!("{:?}", user); -} +## Usage + +### Getting your project's information + +```rust,no_run +let project = client.get_self().await.unwrap(); ``` -### Posting your bot's statistics +### Getting your project's vote information of a user + +#### Discord ID ```rust,no_run -use topgg::{Client, Stats}; +let vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); +``` -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); +#### Top.gg ID - let server_count = 12345; - client - .post_stats(Stats::from(server_count)) - .await - .unwrap(); -} +```rust,no_run +let vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); ``` -### Checking if a user has voted your bot +### Getting a cursor-based paginated list of votes for your project ```rust,no_run -use topgg::Client; +use chrono::{TimeZone, Utc}; -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); +let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); +let first_page = client.get_votes(since).await.unwrap(); - if client.has_voted(661200758510977084).await.unwrap() { - println!("checks out"); - } +for vote in first_page.iter() { + println!("{vote:?}"); } -``` -### Autoposting with [serenity](https://crates.io/crates/serenity) +let second_page = first_page.next().await.unwrap(); -In your `Cargo.toml`: +for vote in second_page.iter() { + println!("{vote:?}"); +} +``` -```toml -[dependencies] -# using serenity with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "serenity"] } +### Posting your bot's application commands list -# using serenity with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "serenity-cached"] } +#### Serenity + +```rust,no_run +client.post_commands(&ctx).await.unwrap(); ``` -In your code: +#### Twilight ```rust,no_run -use core::time::Duration; -use serenity::{client::{Client, Context, EventHandler}, model::{channel::Message, gateway::Ready}}; -use topgg::Autoposter; - -struct Handler; - -#[serenity::async_trait] -impl EventHandler for Handler { - async fn message(&self, ctx: Context, msg: Message) { - if msg.content == "!ping" { - if let Err(why) = msg.channel_id.say(&ctx.http, "Pong!").await { - println!("Error sending message: {why:?}"); - } - } - } +let application_id = bot.current_user_application().await.unwrap().model().await.unwrap().id; +let interaction = bot.interaction(application_id); - async fn ready(&self, _: Context, ready: Ready) { - println!("{} is connected!", ready.user.name); - } -} +client.post_commands(interaction.global_commands()).await.unwrap(); +``` -#[tokio::main] -async fn main() { - let topgg_client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - let autoposter = Autoposter::serenity(&topgg_client, Duration::from_secs(1800)); - - let bot_token = env!("DISCORD_TOKEN").to_string(); - let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::GUILDS | GatewayIntents::MESSAGE_CONTENT; - - let mut client = Client::builder(&bot_token, intents) - .event_handler(Handler) - .event_handler_arc(autoposter.handler()) - .await - .unwrap(); +#### Raw - if let Err(why) = client.start().await { - println!("Client error: {why:?}"); - } -} +```rust,no_run +let commands = json!([{ + "id": "1", + "type": 1, + "application_id": "1", + "name": "test", + "description": "command description", + "default_member_permissions": "", + "version": "1" +}]); // Array of application commands that + // can be serialized to Discord API's raw JSON format. + +client.post_commands(commands).await.unwrap(); ``` -### Autoposting with [twilight](https://twilight.rs) +### Generating widget URLs -In your `Cargo.toml`: - -```toml -[dependencies] -# using twilight with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "twilight"] } +#### Large -# using twilight with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "twilight-cached"] } +```rust,no_run +let widget_url = topgg::widget::large(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); ``` -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::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); +``` -#[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::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); +``` - loop { - let event = match shard.next_event().await { - Ok(event) => event, - Err(source) => { - if source.is_fatal() { - break; - } +#### Social - continue; - } - }; - - autoposter.handle(&event).await; - - match event { - Event::Ready(_) => { - println!("Bot is ready!"); - }, - - _ => {} - } - } -} +```rust,no_run +let widget_url = topgg::widget::social(topgg::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); ``` -### 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, PayloadResult}; +use std::io; + use actix_web::{ - error::{Error, ErrorUnauthorized}, - get, post, App, HttpServer, + App, HttpServer, + error::{Error, ErrorBadRequest, ErrorForbidden, ErrorInternalServerError, ErrorUnauthorized}, + get, post, }; -use std::io; -use topgg::IncomingVote; #[get("/")] async fn index() -> &'static str { "Hello, World!" } +// POST /webhook #[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")) { + 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")), } } @@ -241,28 +208,37 @@ 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::{ + Router, + http::status::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use tokio::net::TcpListener; + +struct MyTopggListener {} -struct MyVoteHandler {} +#[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,102 +248,111 @@ 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( + // POST /webhook + 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").into()), ); - 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, PayloadResult}; -use rocket::{get, http::Status, post, routes}; -use topgg::IncomingVote; +use rocket::{Build, Rocket, get, http::Status, launch, post, routes}; #[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 +#[post("/webhook", data = "")] +fn webhook(payload: IncomingPayload) -> Status { + match payload.authenticate(env!("TOPGG_WEBHOOK_SECRET")) { + PayloadResult::Accepted(payload) => { + println!("{payload:?}"); - Status::Ok - }, - _ => { - println!("found an unauthorized attacker."); - - Status::Unauthorized + Status::NoContent } + + PayloadResult::Forbidden => Status::Forbidden, + + PayloadResult::BadRequest => Status::BadRequest, + + PayloadResult::Unauthorized => Status::Unauthorized, + + PayloadResult::DeserializationFailure => Status::NoContent, + + PayloadResult::InternalServerError => Status::InternalServerError, } } -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 topgg::PayloadResult; +use std::net::SocketAddr; -struct MyVoteHandler {} - -#[async_trait::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); - } -} +use warp::{Filter, http::StatusCode, reply}; #[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), - ); + 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); 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..7a941ae 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,33 +1,16 @@ -use crate::{ - bot::{Bot, BotQuery, BotStats, Bots, IsWeekend}, - util, - vote::{Voted, Voter}, - Error, Result, Snowflake, +use super::{ + Error, GetCommands, PaginatedVotes, PaginatedVotesOwned, PartialVote, PostCommandsError, + PostCommandsResult, Project, Result, Snowflake, UserSource, util, }; -use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; -use serde::{de::DeserializeOwned, Deserialize}; -cfg_if::cfg_if! { - if #[cfg(feature = "bot-autoposter")] { - use crate::bot_autoposter; - use std::sync::Arc; - - type SyncedClient = Arc; - } else { - type SyncedClient = InnerClient; - } -} - -#[derive(Deserialize)] -#[serde(rename = "kebab-case")] -struct Ratelimit { - retry_after: u16, -} +use chrono::{DateTime, SecondsFormat, TimeZone}; +use reqwest::{IntoUrl, Method, Response, StatusCode, Version, header}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; #[macro_export] macro_rules! api { ($e:literal) => { - concat!("https://top.gg/api", $e) + concat!("https://top.gg/api/v1", $e) }; ($e:literal, $($rest:tt)*) => { @@ -35,29 +18,33 @@ macro_rules! api { }; } -pub(crate) use api; - -pub struct InnerClient { - http: reqwest::Client, - token: String, - id: u64, -} +pub(super) use api; #[derive(Deserialize)] -pub(crate) struct ErrorJson { - #[serde(default, alias = "message", alias = "detail")] - message: Option, +#[serde(rename = "kebab-case")] +struct Ratelimit { + retry_after: u16, } -// This is implemented here because the Discord bot autoposter needs to access this struct from a different thread. -impl InnerClient { - pub(crate) fn new(token: String) -> Self { - let id = util::parse_api_token(&token); +/// Interact with Top.gg API v1's endpoints. +#[must_use] +pub struct Client { + http: reqwest::Client, + token: String, +} +impl Client { + /// Creates a new client instance. + /// + /// # Example + /// + /// ```rust,no_run + /// let client = topgg::Client::new(env!("TOPGG_TOKEN").into()); + /// ``` + pub fn new(token: String) -> Self { Self { http: reqwest::Client::new(), - token, - id, + token: format!("Bearer {token}"), } } @@ -90,19 +77,19 @@ impl InnerClient { Ok(response) } else { Err(match status { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => panic!("Invalid API token."), - StatusCode::NOT_FOUND => Error::NotFound( - util::parse_json::(response) - .await - .ok() - .and_then(|err| err.message), - ), - StatusCode::TOO_MANY_REQUESTS => match util::parse_json::(response).await { - Ok(ratelimit) => Error::Ratelimit { + StatusCode::UNAUTHORIZED => panic!("Invalid API token."), + + StatusCode::FORBIDDEN => Error::Forbidden, + + 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 +99,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); - } - - 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 +141,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 +262,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 + /// use chrono::{TimeZone, Utc}; /// - /// Panics if: - /// - The specified ID is invalid. - /// - The client uses an invalid API token. - /// - /// # 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..a001ea9 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,11 @@ 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), + /// You don't have access to this endpoint. + Forbidden, + + /// Such route does not exist. + NotFound, /// Ratelimited from sending more requests. Ratelimit { @@ -26,13 +31,15 @@ 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::InvalidRequest => write!(f, "Attempted to send an invalid request to the API"), + + Self::NotFound => write!(f, "Such route does not exist"), + + Self::Forbidden => write!(f, "You don't have access to this endpoint"), + Self::Ratelimit { retry_after } => write!( f, "Blocked by the API for an hour. Please try again in {retry_after} seconds", @@ -42,14 +49,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..ce1a7ca --- /dev/null +++ b/src/project.rs @@ -0,0 +1,172 @@ +use super::snowflake; + +use serde::{Deserialize, Serialize}; + +/// A project's platform. +#[non_exhaustive] +#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + Discord, +} + +/// A project's type. +#[non_exhaustive] +#[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ProjectType { + Bot, + Server, +} + +/// A brief information on a 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 type. + #[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::{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: f32, + + /// 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..10b48f7 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()); + let client = Client::new(env!("TOPGG_TOKEN").into()); delayed! { - let bot = client.get_bot(264811613708746752).await.unwrap(); - - assert_eq!(bot.name, "Luca"); - assert_eq!(bot.id, 264811613708746752); + let _project = client.get_self().await.unwrap(); } delayed! { - let _bots = client - .get_bots() - .limit(250) - .skip(50) - .sort_by_monthly_votes() - .await - .unwrap(); + client.post_commands(json!([{ + "id": "1", + "type": 1, + "application_id": "1", + "name": "test", + "description": "command description", + "default_member_permissions": "", + "version": "1" + }])).await.unwrap(); } delayed! { - client - .post_bot_server_count(2) - .await - .unwrap(); + let _vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); } delayed! { - assert_eq!(client.get_bot_server_count().await.unwrap().unwrap(), 2); + let _vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); } delayed! { - let _voters = client.get_voters(1).await.unwrap(); - } + use chrono::{TimeZone, Utc}; - delayed! { - let _has_voted = client.has_voted(661200758510977084).await.unwrap(); - } + let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); - delayed! { - let _is_weekend = client.is_weekend().await.unwrap(); + let _first_page = client.get_votes(since).await.unwrap(); + let _second_page = _first_page.next().await.unwrap(); } -} \ No newline at end of file +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..c60d1a3 --- /dev/null +++ b/src/user.rs @@ -0,0 +1,227 @@ +use super::snowflake; + +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +cfg_if::cfg_if! { + if #[cfg(feature = "api")] { + use super::{Client, Result, Snowflake}; + use std::ops::{Deref, DerefMut}; + + /// A user account from an external platform that is linked to a Top.gg user account. This data carries a [`Snowflake`]. + #[non_exhaustive] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub enum UserSource { + Discord(S), + Topgg(S), + } + + impl UserSource { + pub(super) const fn name(&self) -> &'static str { + match self { + Self::Topgg(_) => "topgg", + + Self::Discord(_) => "discord", + } + } + } + + impl Snowflake for UserSource + where + S: Snowflake, + { + fn as_snowflake(&self) -> u64 { + match self { + Self::Topgg(id) | Self::Discord(id) => id.as_snowflake(), + } + } + } + + /// A project's vote information. + #[derive(Clone, Debug, Deserialize)] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub struct Vote { + /// The voter's ID. + #[serde(deserialize_with = "snowflake::deserialize", rename = "user_id")] + pub voter_id: u64, + + /// The voter's ID on the project's platform. + #[serde(deserialize_with = "snowflake::deserialize")] + pub platform_id: u64, + + /// When the vote was cast. + #[serde(rename = "created_at")] + pub voted_at: DateTime, + + /// When the vote expires and the user is required to vote again. + pub expires_at: DateTime, + + /// The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. + pub weight: u64, + } + + /// An owned variation of [`PaginatedVotes`]. + #[derive(Clone, Deserialize)] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub struct PaginatedVotesOwned { + #[serde(rename = "data")] + votes: Vec, + cursor: String, + } + + impl From> for PaginatedVotesOwned { + fn from(votes: PaginatedVotes<'_>) -> Self { + votes.data + } + } + + impl PaginatedVotesOwned { + /// Tries to advance to the next page. + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][super::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// use chrono::{TimeZone, Utc}; + /// + /// let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); + /// let first_page = PaginatedVotesOwned::from(client.get_votes(since).await.unwrap()); + /// + /// for vote in first_page.iter() { + /// println!("{vote:?}"); + /// } + /// + /// let second_page = first_page.next(&client).await.unwrap(); + /// + /// for vote in second_page.iter() { + /// println!("{vote:?}"); + /// } + /// ``` + pub async fn next(&self, client: &Client) -> Result { + client.get_next_votes(&self.cursor).await + } + } + + impl Deref for PaginatedVotesOwned { + type Target = [Vote]; + + fn deref(&self) -> &Self::Target { + &self.votes + } + } + + impl DerefMut for PaginatedVotesOwned { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.votes + } + } + + /// A paginated list of a project's vote information. + /// + /// For an [owned variation][PaginatedVotesOwned], pass this to [`PaginatedVotesOwned::from`]. + #[derive(Clone)] + #[cfg_attr(docsrs, doc(cfg(feature = "api")))] + pub struct PaginatedVotes<'c> { + pub(super) data: PaginatedVotesOwned, + pub(super) client: &'c Client, + } + + impl PaginatedVotes<'_> { + /// Tries to advance to the next page. + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - HTTP request failure from the client-side. ([`InternalClientError`][super::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][super::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][super::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// use chrono::{TimeZone, Utc}; + /// + /// let since = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).single().unwrap(); + /// let first_page = client.get_votes(since).await.unwrap(); + /// + /// for vote in first_page.iter() { + /// println!("{vote:?}"); + /// } + /// + /// let second_page = first_page.next().await.unwrap(); + /// + /// for vote in second_page.iter() { + /// println!("{vote:?}"); + /// } + /// ``` + pub async fn next(&self) -> Result { + Ok(Self { + data: self.data.next(self.client).await?, + client: self.client, + }) + } + } + + impl Deref for PaginatedVotes<'_> { + type Target = [Vote]; + + fn deref(&self) -> &Self::Target { + self.data.deref() + } + } + + impl DerefMut for PaginatedVotes<'_> { + fn deref_mut(&mut self) -> &mut Self::Target { + self.data.deref_mut() + } + } + } +} + +/// A Top.gg user. +#[cfg(feature = "webhooks")] +#[derive(Clone, Debug, Deserialize)] +#[cfg_attr(docsrs, doc(cfg(feature = "webhooks")))] +pub struct User { + /// The user's ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub id: u64, + + /// The user's name. + pub name: String, + + /// The user's avatar URL. + pub avatar_url: String, + + /// The user's platform ID. + #[serde(deserialize_with = "snowflake::deserialize")] + pub platform_id: u64, +} + +/// A brief information of a project's vote. +#[derive(Clone, Debug, Deserialize)] +pub struct PartialVote { + /// When the vote was cast. + #[serde(rename = "created_at")] + pub voted_at: DateTime, + + /// When the vote expires and the user is required to vote again. + pub expires_at: DateTime, + + /// The number of votes this vote counted for. This is a rounded integer value which determines how many points this individual vote was worth. + pub weight: u64, +} diff --git a/src/util.rs b/src/util.rs index 33ca6ad..86d421c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,62 +1,17 @@ -use crate::{snowflake, Error}; -use base64::Engine; -use reqwest::Response; -use serde::{de::DeserializeOwned, Deserialize, Deserializer}; - -#[inline(always)] -pub(crate) fn deserialize_optional_string<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - Ok( - String::deserialize(deserializer) - .ok() - .filter(|s| !s.is_empty()), - ) -} +use super::Error; -#[inline(always)] -pub(crate) fn deserialize_default<'de, D, T>(deserializer: D) -> Result -where - T: Default + Deserialize<'de>, - D: Deserializer<'de>, -{ - Option::deserialize(deserializer).map(Option::unwrap_or_default) -} +use reqwest::Response; +use serde::de::DeserializeOwned; -#[inline(always)] -pub(crate) async fn parse_json(response: Response) -> crate::Result +pub async fn parse_json(response: Response) -> super::Result where T: DeserializeOwned, { - if let Ok(bytes) = response.bytes().await { - if let Ok(json) = serde_json::from_slice(&bytes) { - return Ok(json); - } + if let Ok(bytes) = response.bytes().await + && let Ok(json) = serde_json::from_slice(&bytes) + { + return Ok(json); } Err(Error::InternalServerError) } - -#[derive(Deserialize)] -#[allow(clippy::used_underscore_binding)] -struct TokenStructure { - #[serde(deserialize_with = "snowflake::deserialize")] - id: u64, -} - -pub(crate) fn parse_api_token(token: &str) -> u64 { - if let Some(base64_section) = token.split('.').nth(1) { - if let Ok(decoded_base64) = - base64::engine::general_purpose::STANDARD_NO_PAD.decode(base64_section) - { - if let Ok(token_structure) = serde_json::from_slice::(&decoded_base64) { - return token_structure.id; - } - } - } - - panic!("Got a malformed API token."); -} diff --git a/src/vote.rs b/src/vote.rs deleted file mode 100644 index 627bf5c..0000000 --- a/src/vote.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::snowflake; -use serde::Deserialize; - -#[derive(Deserialize)] -pub(crate) struct Voted { - pub(crate) voted: u8, -} - -/// A Top.gg voter. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct Voter { - /// This voter's ID. - #[serde(deserialize_with = "snowflake::deserialize")] - pub id: u64, - - /// This voter's username. - #[serde(rename = "username")] - pub name: String, - - /// This voter's avatar URL. - pub avatar: String, -} diff --git a/src/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..12b16c1 --- /dev/null +++ b/src/webhooks/payload.rs @@ -0,0 +1,104 @@ +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 { + /// 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. + #[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, + }, + + /// 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. + #[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. + #[serde(rename = "created_at")] + voted_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")) } diff --git a/src/widget.rs b/src/widget.rs new file mode 100644 index 0000000..546dc2b --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,81 @@ +use crate::{Platform, ProjectType, Snowflake}; + +/// Generates a large widget URL. +/// +/// # Example +/// +/// ```rust,no_run +/// 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 +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/large/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), + id.as_snowflake() + ) +} + +/// Generates a small widget URL for displaying votes. +/// +/// # Example +/// +/// ```rust,no_run +/// 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 +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/small/votes/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), + 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::Platform::Discord, topgg::ProjectType::Bot, 1026525568344264724); +/// ``` +#[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/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), + id.as_snowflake() + ) +} + +/// Generates a small widget URL for displaying social stats. +/// +/// # Example +/// +/// ```rust,no_run +/// 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 +where + I: Snowflake, +{ + crate::client::api!( + "/widgets/small/social/{}/{}/{}", + serde_variant::to_variant_name(&platform).unwrap(), + serde_variant::to_variant_name(&project_type).unwrap(), + id.as_snowflake() + ) +}