From da95451f5ada8d0825a0e4ba2ace0ad64605ce25 Mon Sep 17 00:00:00 2001 From: null8626 Date: Mon, 15 Sep 2025 21:59:15 +0700 Subject: [PATCH] feat: adapt to v1 --- Cargo.toml | 2 +- README.md | 416 +++++++++++++++++++++++++++++------------------ src/bot.rs | 16 +- src/client.rs | 179 +++++++++++++++++++- src/error.rs | 46 +++++- src/lib.rs | 14 +- src/project.rs | 73 +++++++++ src/snowflake.rs | 28 ++++ src/test.rs | 287 +++++++++++++++++++++++++++++++- src/vote.rs | 23 +++ src/widget.rs | 89 ++++++++++ 11 files changed, 981 insertions(+), 192 deletions(-) create mode 100644 src/project.rs create mode 100644 src/widget.rs diff --git a/Cargo.toml b/Cargo.toml index 3cfece0..c121414 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "topgg" version = "2.0.0" edition = "2021" authors = ["null (https://github.com/null8626)", "Top.gg (https://top.gg)"] -description = "A simple API wrapper for Top.gg written in Rust." +description = "A community-maintained Rust API Client for the Top.gg API." readme = "README.md" repository = "https://github.com/Top-gg-Community/rust-sdk" license = "MIT" diff --git a/README.md b/README.md index a258444..01e74e9 100644 --- a/README.md +++ b/README.md @@ -1,174 +1,241 @@ -# [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 + +The community-maintained Rust library for Top.gg. + +## Chapters + +- [Installation](#installation) +- [Setting up](#setting-up) +- [Usage](#usage) + - [API v1](#api-v1) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [API v0](#api-v0) + - [Getting a bot](#getting-a-bot) + - [Getting several bots](#getting-several-bots) + - [Getting your project's voters](#getting-your-projects-voters) + - [Check if a user has voted for your project](#check-if-a-user-has-voted-for-your-project) + - [Getting your bot's server count](#getting-your-bots-server-count) + - [Posting your bot's server count](#posting-your-bots-server-count) + - [Automatically posting your bot's server count every few minutes](#automatically-posting-your-bots-server-count-every-few-minutes) + - [Checking if the weekend vote multiplier is active](#checking-if-the-weekend-vote-multiplier-is-active) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + - [Being notified whenever someone voted for your project](#being-notified-whenever-someone-voted-for-your-project) + +## Installation -[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 +In your `Cargo.toml`: -The official Rust SDK for the [Top.gg API](https://docs.top.gg). +```toml +[dependencies] +topgg = "2" +``` -## Getting Started +## Setting up -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`: +### v1 -```toml -topgg = "1.4" +```rust,no_run +let client = topgg::V1Client::new(env!("TOPGG_TOKEN").to_string()); ``` -For more information, please read [the documentation](https://docs.rs/topgg)! +### v0 -## Features +```rust,no_run +let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); +``` -This library provides several feature flags that can be enabled/disabled in `Cargo.toml`. Such as: +## Usage -- **`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. - - **`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). +### API v1 -## Examples +#### Getting your project's vote information of a user -### Fetching a user from its Discord ID +##### Discord ID ```rust,no_run -use topgg::Client; +use topgg::UserSource; -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); - let user = client.get_user(661200758510977084).await.unwrap(); - - assert_eq!(user.username, "null"); - assert_eq!(user.id, 661200758510977084); - - println!("{:?}", user); -} +let vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); ``` -### Posting your bot's statistics +##### Top.gg ID ```rust,no_run -use topgg::{Client, Stats}; +use topgg::UserSource; -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); +let vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); +``` - let server_count = 12345; - client - .post_stats(Stats::from(server_count)) - .await - .unwrap(); -} +#### Posting your bot's application commands list + +##### Serenity + +```rust,no_run +client.post_bot_commands(&ctx).await.unwrap(); ``` -### Checking if a user has voted your bot +##### Twilight ```rust,no_run -use topgg::Client; +let application_id = bot.current_user_application().await.unwrap().model().await.unwrap().id; +let interaction = bot.interaction(application_id); -#[tokio::main] -async fn main() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); +client.post_bot_commands(interaction.global_commands()).await.unwrap(); +``` - if client.has_voted(661200758510977084).await.unwrap() { - println!("checks out"); - } +##### Others + +```rust,no_run +let commands = vec![...]; // Array of application commands that + // can be serialized to Discord API's raw JSON format. +client.post_bot_commands(commands).await.unwrap(); +``` + +### API v0 + +#### Getting a bot + +```rust,no_run +let bot = client.get_bot(264811613708746752).await.unwrap(); +``` + +#### Getting several bots + +```rust,no_run +let bots = client + .get_bots() + .limit(250) + .skip(50) + .sort_by_monthly_votes() + .await + .unwrap(); + +for bot in bots { + println!("{}", bot.name); +} +``` + +#### Getting your project's voters + +```rust,no_run +// Page number +let voters = client.get_voters(1).await.unwrap(); + +for voter in voters { + println!("{}", voter.username); } ``` -### Autoposting with [serenity](https://crates.io/crates/serenity) +#### Check if a user has voted for your project + +```rust,no_run +let has_voted = client.has_voted(661200758510977084).await.unwrap(); +``` + +#### Getting your bot's server count + +```rust,no_run +let server_count = client.get_bot_server_count().await.unwrap(); +``` + +#### Posting your bot's server count + +```rust,no_run +client.post_bot_server_count(bot.server_count()).await.unwrap(); +``` + +#### Automatically posting your bot's server count every few minutes + +##### Serenity In your `Cargo.toml`: ```toml [dependencies] # using serenity with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "serenity"] } +topgg = { version = "2", features = ["bot-autoposter", "serenity"] } # using serenity with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "serenity-cached"] } +topgg = { version = "2", features = ["bot-autoposter", "serenity-cached"] } ``` In your code: ```rust,no_run -use core::time::Duration; -use serenity::{client::{Client, Context, EventHandler}, model::{channel::Message, gateway::Ready}}; -use topgg::Autoposter; +use std::time::Duration; +use serenity::{client::{Client, Context, EventHandler}, model::gateway::{GatewayIntents, Ready}}; +use topgg::BotAutoposter; -struct Handler; +struct BotAutoposterHandler; #[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:?}"); - } - } - } - +impl EventHandler for BotAutoposterHandler { async fn ready(&self, _: Context, ready: Ready) { - println!("{} is connected!", ready.user.name); + println!("{} is now ready!", ready.user.name); } } #[tokio::main] async fn main() { - let topgg_client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - let autoposter = Autoposter::serenity(&topgg_client, Duration::from_secs(1800)); + let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); + + // Posts once every 30 minutes + let mut bot_autoposter = BotAutoposter::serenity(&client, Duration::from_secs(1800)); - let bot_token = env!("DISCORD_TOKEN").to_string(); - let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::GUILDS | GatewayIntents::MESSAGE_CONTENT; + let bot_token = env!("BOT_TOKEN").to_string(); + let intents = GatewayIntents::GUILDS; - let mut client = Client::builder(&bot_token, intents) - .event_handler(Handler) - .event_handler_arc(autoposter.handler()) + let mut bot = Client::builder(&bot_token, intents) + .event_handler(BotAutoposterHandler) + .event_handler_arc(bot_autoposter.handler()) .await .unwrap(); - if let Err(why) = client.start().await { + 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:?}"); } } ``` -### Autoposting with [twilight](https://twilight.rs) +##### Twilight In your `Cargo.toml`: ```toml [dependencies] # using twilight with guild caching disabled -topgg = { version = "1.4", features = ["autoposter", "twilight"] } +topgg = { version = "2", features = ["bot-autoposter", "twilight"] } # using twilight with guild caching enabled -topgg = { version = "1.4", features = ["autoposter", "twilight-cached"] } +topgg = { version = "2", features = ["bot-autoposter", "twilight-cached"] } ``` In your code: ```rust,no_run -use core::time::Duration; -use topgg::Autoposter; +use std::time::Duration; +use topgg::{BotAutoposter, Client}; use twilight_gateway::{Event, Intents, Shard, ShardId}; #[tokio::main] async fn main() { - let client = topgg::Client::new(env!("TOPGG_TOKEN").to_string()); - let autoposter = Autoposter::twilight(&client, Duration::from_secs(1800)); + 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!("DISCORD_TOKEN").to_string(), - Intents::GUILD_MEMBERS | Intents::GUILDS, + env!("BOT_TOKEN").to_string(), + Intents::GUILD_MESSAGES | Intents::GUILDS, ); loop { @@ -183,11 +250,11 @@ async fn main() { } }; - autoposter.handle(&event).await; + bot_autoposter.handle(&event).await; match event { Event::Ready(_) => { - println!("Bot is ready!"); + println!("Bot is now ready!"); }, _ => {} @@ -196,13 +263,49 @@ async fn main() { } ``` -### Writing an [actix-web](https://actix.rs) webhook for listening to votes +#### Checking if the weekend vote multiplier is active + +```rust,no_run +let is_weekend = client.is_weekend().await.unwrap(); +``` + +#### Generating widget URLs + +##### Large + +```rust,no_run +let widget_url = topgg::widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); +``` + +##### Votes + +```rust,no_run +let widget_url = topgg::widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); +``` + +##### Owner + +```rust,no_run +let widget_url = topgg::widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); +``` + +##### Social + +```rust,no_run +let widget_url = topgg::widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); +``` + +### Webhooks + +#### Being notified whenever someone voted for your project + +##### 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: @@ -212,57 +315,58 @@ use actix_web::{ error::{Error, ErrorUnauthorized}, get, post, App, HttpServer, }; +use topgg::{Incoming, VoteEvent}; use std::io; -use topgg::IncomingVote; -#[get("/")] -async fn index() -> &'static str { - "Hello, World!" -} - -#[post("/webhook")] -async fn webhook(vote: IncomingVote) -> Result<&'static str, Error> { - match vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { +#[post("/votes")] +async fn voted(vote: Incoming) -> Result<&'static str, Error> { + match vote.authenticate(env!("MY_TOPGG_WEBHOOK_SECRET")) { Some(vote) => { - println!("{:?}", vote); + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); Ok("ok") - } + }, _ => Err(ErrorUnauthorized("401")), } } +#[get("/")] +async fn index() -> &'static str { + "Hello, World!" +} + #[actix_web::main] async fn main() -> io::Result<()> { - HttpServer::new(|| App::new().service(index).service(webhook)) + HttpServer::new(|| App::new().service(index).service(voted)) .bind("127.0.0.1:8080")? .run() .await } ``` -### Writing an [axum](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 axum::{routing::get, Router}; +use topgg::{VoteEvent, Webhook}; +use tokio::net::TcpListener; +use std::sync::Arc; -struct MyVoteHandler {} +struct MyVoteListener {} -#[axum::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); +#[async_trait::async_trait] +impl Webhook for MyVoteListener { + async fn callback(&self, vote: VoteEvent) { + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); } } @@ -272,100 +376,90 @@ async fn index() -> &'static str { #[tokio::main] async fn main() { - let state = Arc::new(MyVoteHandler {}); + let state = Arc::new(MyVoteListener {}); - let app = Router::new().route("/", get(index)).nest( - "/webhook", - topgg::axum::webhook(env!("TOPGG_WEBHOOK_PASSWORD").to_string(), Arc::clone(&state)), + let router = Router::new().route("/", get(index)).nest( + "/votes", + topgg::axum::webhook(env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), Arc::clone(&state)), ); - let addr: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap(); - Server::bind(&addr) - .serve(app.into_make_service()) - .await - .unwrap(); + axum::serve(listener, router).await.unwrap(); } ``` -### Writing a [rocket](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 rocket::{get, http::Status, post, routes}; -use topgg::IncomingVote; - -#[get("/")] -fn index() -> &'static str { - "Hello, World!" -} +use rocket::{get, http::Status, launch, post, routes}; +use topgg::{Incoming, VoteEvent}; -#[post("/webhook", data = "")] -fn webhook(vote: IncomingVote) -> Status { - match vote.authenticate(env!("TOPGG_WEBHOOK_PASSWORD")) { +#[post("/votes", data = "")] +fn voted(vote: Incoming) -> Status { + match vote.authenticate(env!("MY_TOPGG_WEBHOOK_SECRET")) { Some(vote) => { - println!("{:?}", vote); + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); - Status::Ok + Status::NoContent }, - _ => { - println!("found an unauthorized attacker."); - - Status::Unauthorized - } + _ => Status::Unauthorized, } } -fn main() { - rocket::ignite() - .mount("/", routes![index, webhook]) - .launch(); +#[get("/")] +fn index() -> &'static str { + "Hello, World!" +} + +#[launch] +fn start() -> _ { + rocket::build().mount("/", routes![index, voted]) } ``` -### Writing a [warp](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 topgg::{VoteEvent, Webhook}; use warp::Filter; -struct MyVoteHandler {} +struct MyVoteListener {} #[async_trait::async_trait] -impl VoteHandler for MyVoteHandler { - async fn voted(&self, vote: Vote) { - println!("{:?}", vote); +impl Webhook for MyVoteListener { + async fn callback(&self, vote: VoteEvent) { + println!("A user with the ID of {} has voted us on Top.gg!", vote.voter_id); } } #[tokio::main] async fn main() { - let state = Arc::new(MyVoteHandler {}); + let state = Arc::new(MyVoteListener {}); - // POST /webhook + // POST /votes let webhook = topgg::warp::webhook( - "webhook", - env!("TOPGG_WEBHOOK_PASSWORD").to_string(), + "votes", + env!("MY_TOPGG_WEBHOOK_SECRET").to_string(), Arc::clone(&state), ); @@ -375,4 +469,4 @@ async fn main() { warp::serve(routes).run(addr).await } -``` +``` \ No newline at end of file diff --git a/src/bot.rs b/src/bot.rs index b11436b..e83c0ed 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,4 +1,4 @@ -use crate::{snowflake, util, Client}; +use crate::{snowflake, util, Client, Reviews}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::{ @@ -9,18 +9,6 @@ use std::{ pin::Pin, }; -/// A Discord bot's reviews on Top.gg. -#[must_use] -#[derive(Clone, Debug, Deserialize)] -pub struct BotReviews { - /// This bot's average review score out of 5. - #[serde(rename = "averageScore")] - pub score: f64, - - /// This bot's review count. - pub count: usize, -} - /// A Discord bot listed on Top.gg. #[must_use] #[derive(Clone, Debug, Deserialize)] @@ -101,7 +89,7 @@ pub struct Bot { /// This bot's reviews. #[serde(rename = "reviews")] - pub review: BotReviews, + pub review: Reviews, } #[derive(Serialize, Deserialize)] diff --git a/src/client.rs b/src/client.rs index ea85b09..b72691c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,11 +1,16 @@ +use std::ops::Deref; + use crate::{ bot::{Bot, BotQuery, BotStats, Bots, IsWeekend}, + error::PostBotCommandsResult, + project::GetBotCommands, + snowflake::UserSource, util, - vote::{Voted, Voter}, - Error, Result, Snowflake, + vote::{Vote, Voted, Voter}, + Error, PostBotCommandsError, Result, Snowflake, }; use reqwest::{header, IntoUrl, Method, Response, StatusCode, Version}; -use serde::{de::DeserializeOwned, Deserialize}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; cfg_if::cfg_if! { if #[cfg(feature = "bot-autoposter")] { @@ -68,7 +73,7 @@ impl InnerClient { self .http .request(method, url) - .header(header::AUTHORIZATION, &self.token) + .header(header::AUTHORIZATION, format!("Bearer {}", self.token)) .header(header::CONNECTION, "close") .header(header::CONTENT_LENGTH, body.len()) .header(header::CONTENT_TYPE, "application/json") @@ -147,7 +152,9 @@ impl InnerClient { } } -/// Interact with the API's endpoints. +/// Interact with the API's v0 endpoints. +/// +/// For interacting with the v1 endpoints, see [`V1Client`]. #[must_use] pub struct Client { inner: SyncedClient, @@ -156,8 +163,6 @@ pub struct Client { 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). - /// /// # Panics /// /// Panics if the client uses an invalid API token. @@ -414,3 +419,163 @@ cfg_if::cfg_if! { impl bot_autoposter::AsClient for Client {} } } + +/// Interact with the API's v1 endpoints. +#[must_use] +pub struct V1Client { + inner: Client, +} + +impl V1Client { + /// Creates a new instance. + /// + /// # Panics + /// + /// Panics if the client uses an invalid API token. + /// + /// # Example + /// + /// ```rust,no_run + /// let client = topgg::V1Client::new(env!("TOPGG_TOKEN").to_string()); + /// ``` + #[inline(always)] + pub fn new(token: String) -> Self { + Self { + inner: Client::new(token), + } + } + + /// Fetches the latest vote information of a user on your project. Returns [`None`] if the user has not voted. + /// + /// # Panics + /// + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - The specified user has not logged in to Top.gg. ([`NotFound`][crate::Error::NotFound]) + /// - HTTP request failure from the client-side. ([`InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// use topgg::UserSource; + /// + /// // Discord ID: + /// let vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); + /// + /// // Top.gg ID: + /// let vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); + /// ``` + pub async fn get_vote(&self, user: UserSource) -> Result> + where + I: Snowflake, + { + match self + .inner + .inner + .send::( + Method::GET, + api!( + "/v1/projects/@me/votes/{}?source={}", + user.as_snowflake(), + user.name() + ), + None, + ) + .await + { + Ok(vote) => Ok(Some(vote)), + Err(err) => { + if let Error::NotFound(Some(message)) = &err { + if message == "User has not voted in the last 12 hours." { + return Ok(None); + } + } + + Err(err) + } + } + } + + /// Updates the application commands list in your Discord bot's Top.gg page. + /// + /// # Panics + /// + /// Panics if: + /// - The specified ID is invalid. + /// - The client uses an invalid API token. + /// + /// # Errors + /// + /// Returns [`Err`] if: + /// - Unable to retrieve the list of bot commands. ([`PostBotCommandsError::Retrieval`][crate::PostBotCommandsError::Retrieval]) + /// - Unable to serialize the list of bot commands. ([`PostBotCommandsError::Serialization`][crate::PostBotCommandsError::Serialization]) + /// - The list of bot commands supplied do not match [Discord API's raw JSON format](https://discord.com/developers/docs/interactions/application-commands#application-command-object). ([`Error::InvalidRequest`][crate::Error::InvalidRequest]) + /// - HTTP request failure from the client-side. ([`Error::InternalClientError`][crate::Error::InternalClientError]) + /// - HTTP request failure from the server-side. ([`Error::InternalServerError`][crate::Error::InternalServerError]) + /// - Ratelimited from sending more requests. ([`Error::Ratelimit`][crate::Error::Ratelimit]) + /// + /// # Example + /// + /// ```rust,no_run + /// // Serenity: + /// client.post_bot_commands(&ctx).await.unwrap(); + /// + /// // Twilight: + /// let application_id = bot.current_user_application().await.unwrap().model().await.unwrap().id; + /// let interaction = bot.interaction(application_id); + /// + /// client.post_bot_commands(interaction.global_commands()).await.unwrap(); + /// + /// // Others: + /// let commands = vec![...]; // Array of application commands that + /// // can be serialized to Discord API's raw JSON format. + /// client.post_bot_commands(commands).await.unwrap(); + /// ``` + pub async fn post_bot_commands(&self, context: C) -> PostBotCommandsResult<(), E> + where + L: Serialize + DeserializeOwned, + C: GetBotCommands, + { + let commands = context + .get_bot_commands() + .await + .map_err(PostBotCommandsError::Retrieval)?; + + match self + .inner + .inner + .send_inner( + Method::POST, + api!("/v1/projects/@me/commands"), + serde_json::to_vec(&commands).map_err(PostBotCommandsError::Serialization)?, + ) + .await + { + Ok(_) => Ok(()), + Err(err) => Err(PostBotCommandsError::Request(err)), + } + } +} + +impl AsRef for V1Client { + #[inline(always)] + fn as_ref(&self) -> &Client { + &self.inner + } +} + +impl Deref for V1Client { + type Target = Client; + + #[inline(always)] + fn deref(&self) -> &Self::Target { + &self.inner + } +} diff --git a/src/error.rs b/src/error.rs index 3139798..c748c29 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,4 @@ +use serde_json::Error as SerdeJsonError; use std::{error, fmt, result}; /// An error coming from this SDK. @@ -52,4 +53,47 @@ impl error::Error for Error { } /// The result type primarily used in this SDK. -pub type Result = result::Result; \ No newline at end of file +pub type Result = result::Result; + +/// An error coming from [`V1Client::post_bot_commands`][crate::V1Client::post_bot_commands]. +#[derive(Debug)] +pub enum PostBotCommandsError { + /// Error happened while retrieving the bot commands in [`GetBotCommands`][crate::GetBotCommands]. + Retrieval(E), + + /// Error happened while serializing the bot commands. + Serialization(SerdeJsonError), + + /// Error happened while sending the HTTP request. + Request(Error), +} + +impl fmt::Display for PostBotCommandsError +where + E: fmt::Debug, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Retrieval(err) => write!(f, "Error while retrieving bot commands: {err:?}"), + Self::Serialization(err) => write!(f, "Error while serializing bot commands: {err:?}"), + Self::Request(err) => write!(f, "Error while posting bot commands: {err:?}"), + } + } +} + +impl error::Error for PostBotCommandsError +where + E: error::Error + 'static, +{ + #[inline(always)] + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Self::Retrieval(err) => Some(err), + Self::Serialization(err) => Some(err), + Self::Request(err) => err.source(), + } + } +} + +/// The result type used in [`V1Client::post_bot_commands`][crate::V1Client::post_bot_commands]. +pub type PostBotCommandsResult = result::Result>; diff --git a/src/lib.rs b/src/lib.rs index 1f1ac99..b1818e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,18 +12,24 @@ cfg_if::cfg_if! { pub(crate) mod client; mod bot; mod error; + mod project; mod util; mod vote; #[cfg(feature = "bot-autoposter")] pub(crate) use client::InnerClient; + /// Widget generator functions. + pub mod widget; + #[doc(inline)] pub use bot::{Bot, BotQuery}; - pub use client::Client; - pub use error::{Error, Result}; - pub use snowflake::Snowflake; // for doc purposes - pub use vote::Voter; + pub use client::{Client, V1Client}; + pub use error::{Error, Result, PostBotCommandsError, PostBotCommandsResult}; + pub use project::{GetBotCommands, Reviews}; + pub use snowflake::{Snowflake, UserSource}; // for doc purposes + pub use vote::{Vote, Voter}; + pub use widget::WidgetType; #[doc(hidden)] #[cfg(any(feature = "twilight", feature = "twilight-cached"))] diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..377d70b --- /dev/null +++ b/src/project.rs @@ -0,0 +1,73 @@ +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +/// A project's reviews on Top.gg. +#[must_use] +#[derive(Clone, Debug, Deserialize)] +pub struct Reviews { + /// This project's average review score out of 5. + #[serde(rename = "averageScore")] + pub score: f64, + + /// This project's review count. + pub count: usize, +} + +/// Retrieves an array of application commands in [Discord API's raw JSON format](https://discord.com/developers/docs/interactions/application-commands#application-command-object). For use in [`V1Client::post_bot_commands`][crate::V1Client::post_bot_commands]. +#[async_trait::async_trait] +pub trait GetBotCommands +where + C: Serialize + DeserializeOwned, +{ + async fn get_bot_commands(self) -> Result, E>; +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "serenity", feature = "serenity-cached"))] { + use serenity::{ + client::Context as SerenityContext, + http::{ + HttpError as SerenityHttpError, LightMethod as SerenityHttpMethod, + Request as SerenityHttpRequest, Route as SerenityHttpRoute, + }, + Error as SerenityError, + }; + + #[async_trait::async_trait] + impl GetBotCommands for &SerenityContext { + async fn get_bot_commands(self) -> Result, SerenityError> { + let Some(application_id) = self.http.application_id() else { + return Err(SerenityHttpError::ApplicationIdMissing.into()); + }; + + self + .http + .fire::<_>(SerenityHttpRequest::new( + SerenityHttpRoute::Commands { application_id }, + SerenityHttpMethod::Get, + )) + .await + } + } + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "twilight", feature = "twilight-cached"))] { + use twilight_http::{error::Error as TwilightHttpError, response::DeserializeBodyError as TwilightHttpDeserializeBodyError, request::application::command::GetGlobalCommands as TwilightGetGlobalCommands}; + use twilight_model::application::command::Command as TwilightCommand; + + #[doc(hidden)] + #[derive(Debug)] + pub enum TwilightGetCommandsError { + Http(TwilightHttpError), + Deserialize(TwilightHttpDeserializeBodyError), + } + + #[async_trait::async_trait] + impl GetBotCommands for TwilightGetGlobalCommands<'_> { + async fn get_bot_commands(self) -> Result, TwilightGetCommandsError> { + self.await.map_err(TwilightGetCommandsError::Http)?.models().await.map_err(TwilightGetCommandsError::Deserialize) + } + } + } +} diff --git a/src/snowflake.rs b/src/snowflake.rs index 02eaf0b..c149ea9 100644 --- a/src/snowflake.rs +++ b/src/snowflake.rs @@ -25,6 +25,34 @@ cfg_if::cfg_if! { fn as_snowflake(&self) -> u64; } + /// A user account from an external platform that is linked to a Top.gg user account. + #[non_exhaustive] + pub enum UserSource { + Topgg(I), + Discord(I), + } + + impl UserSource { + pub(crate) const fn name(&self) -> &'static str { + match self { + Self::Topgg(_) => "topgg", + Self::Discord(_) => "discord", + } + } + } + + impl Snowflake for UserSource + where + I: Snowflake, + { + #[inline(always)] + fn as_snowflake(&self) -> u64 { + match self { + Self::Topgg(id) | Self::Discord(id) => id.as_snowflake(), + } + } + } + macro_rules! impl_snowflake( ($(#[$attr:meta] )?$self:ident,$t:ty,$body:expr) => { $(#[$attr])? diff --git a/src/test.rs b/src/test.rs index d7ed055..1755165 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,6 +1,9 @@ -use crate::Client; +use crate::{UserSource, V1Client}; use tokio::time::{sleep, Duration}; +#[cfg(feature = "bot-autoposter")] +use crate::BotAutoposter; + macro_rules! delayed { ($($b:tt)*) => { $($b)* @@ -8,9 +11,188 @@ macro_rules! delayed { }; } +cfg_if::cfg_if! { + if #[cfg(any(feature = "serenity", feature = "serenity-cached", feature = "twilight", feature = "twilight-cached"))] { + use crate::PostBotCommandsError; + use std::sync::Arc; + use tokio::sync::{mpsc, OnceCell}; + + #[cfg(feature = "bot-autoposter")] + use tokio::sync::Mutex; + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "serenity", feature = "serenity-cached"))] { + use serenity::{ + Error as SerenityError, + builder::CreateCommand as SerenityCreateCommand, + client::{ + Client as SerenityClient, + Context as SerenityContext, + EventHandler as SerenityEventHandler + }, + model::{application::Command as SerenityCommand, gateway::{Ready as SerenityReadyEvent, GatewayIntents as SerenityGatewayIntents}} + }; + + #[cfg(feature = "bot-autoposter")] + use tokio::time::timeout; + + #[derive(Debug)] + #[allow(dead_code)] + enum SerenityTestError { + PostBotCommandsSerenity(SerenityError), + PostBotCommandsTopgg(PostBotCommandsError), + #[cfg(feature = "bot-autoposter")] + BotAutoposterThread(crate::Error), + #[cfg(feature = "bot-autoposter")] + BotAutoposterTimeout, + } + + struct SerenityTestEventHandler { + client: Arc, + result_sender: mpsc::Sender>, + #[cfg(feature = "bot-autoposter")] + bot_autoposter: Mutex>, + } + + impl SerenityTestEventHandler { + async fn test_post_bot_commands(&self, ctx: &SerenityContext) -> Result<(), SerenityTestError> { + SerenityCommand::set_global_commands(&ctx.http, vec![SerenityCreateCommand::new("test").description("command description")]).await.map_err(SerenityTestError::PostBotCommandsSerenity)?; + + self.client.post_bot_commands(ctx).await.map_err(SerenityTestError::PostBotCommandsTopgg) + } + } + + static SERENITY_TEST_EVENT_HANDLER_READY_ONCE: OnceCell<()> = OnceCell::const_new(); + + #[async_trait::async_trait] + impl SerenityEventHandler for SerenityTestEventHandler { + #[cfg_attr(not(feature = "bot-autoposter"), allow(unused_mut))] + async fn ready(&self, ctx: SerenityContext, _ready: SerenityReadyEvent) { + SERENITY_TEST_EVENT_HANDLER_READY_ONCE.get_or_init(|| async { + let mut test_result = self.test_post_bot_commands(&ctx).await; + + #[cfg(feature = "bot-autoposter")] + if test_result.is_ok() { + let mut bot_autoposter_guard = self.bot_autoposter.lock().await; + let mut bot_autoposter_receiver = bot_autoposter_guard.receiver(); + + match timeout(Duration::from_secs(10), async move { + let mut bot_autopost_counter = 0; + + while let Some(posted) = bot_autoposter_receiver.recv().await { + if let Err(err) = posted { + return Err(err); + } + + bot_autopost_counter += 1; + + if bot_autopost_counter == 3 { + break; + } + } + + Ok(()) + }).await { + Ok(Err(err)) => test_result = Err(SerenityTestError::BotAutoposterThread(err)), + Err(_) => test_result = Err(SerenityTestError::BotAutoposterTimeout), + _ => {}, + } + } + + self.result_sender.send(test_result).await.unwrap(); + }).await; + } + } + } +} + +cfg_if::cfg_if! { + if #[cfg(any(feature = "twilight", feature = "twilight-cached"))] { + use std::sync::atomic::{self, AtomicBool}; + use tokio::time::timeout; + use twilight_gateway::{Event as TwilightGatewayEvent, Intents as TwilightGatewayIntents, Shard as TwilightShard, ShardId as TwilightShardId}; + use twilight_http::{Error as TwilightHttpError, response::DeserializeBodyError as TwilightHttpDeserializeBodyError, Client as TwilightHttpClient}; + use twilight_model::{application::command::{Command as TwilightCommand, CommandType as TwilightCommandType}, id::Id as TwilightId}; + + static TWILIGHT_TEST_EVENT_HANDLER_READY_ONCE: OnceCell<()> = OnceCell::const_new(); + + #[derive(Debug)] + #[allow(dead_code)] + enum TwilightTestError { + PostBotCommandsTwilightApplicationIdHttp(TwilightHttpError), + PostBotCommandsTwilightApplicationIdDeserialization(TwilightHttpDeserializeBodyError), + PostBotCommandsTwilightSetBotCommands(TwilightHttpError), + PostBotCommandsTopgg(PostBotCommandsError), + #[cfg(feature = "bot-autoposter")] + BotAutoposterThread(crate::Error), + } + + struct TwilightTestContext { + bot: TwilightHttpClient, + running: AtomicBool, + result_sender: mpsc::Sender>, + #[cfg(feature = "bot-autoposter")] + autoposter_receiver: Mutex>>, + } + + async fn test_twilight<'a>(client: &'a V1Client, context: &'a TwilightTestContext) -> Result<(), TwilightTestError> { + let application_id = context.bot.current_user_application().await.map_err(TwilightTestError::PostBotCommandsTwilightApplicationIdHttp)?.model().await.map_err(TwilightTestError::PostBotCommandsTwilightApplicationIdDeserialization)?.id; + let interaction = context.bot.interaction(application_id); + + interaction.set_global_commands(&[TwilightCommand { + application_id: None, + default_member_permissions: None, + dm_permission: None, + description: String::from("command description"), + description_localizations: None, + guild_id: None, + id: None, + kind: TwilightCommandType::ChatInput, + name: String::from("test"), + name_localizations: None, + nsfw: Some(false), + options: vec![], + version: TwilightId::new(1) + }]).await.map_err(TwilightTestError::PostBotCommandsTwilightSetBotCommands)?; + + client.post_bot_commands(interaction.global_commands()).await.map_err(TwilightTestError::PostBotCommandsTopgg)?; + + cfg_if::cfg_if! { + if #[cfg(feature = "bot-autoposter")] { + let mut bot_autopost_counter = 0; + + while let Some(posted) = context.autoposter_receiver.lock().await.recv().await { + if let Err(err) = posted { + return Err(TwilightTestError::BotAutoposterThread(err)); + } + + bot_autopost_counter += 1; + + if bot_autopost_counter == 3 { + break; + } + } + } + } + + Ok(()) + } + } +} + #[tokio::test] async fn api() { - let client = Client::new(env!("TOPGG_TOKEN").to_string()); + let client = V1Client::new(env!("TOPGG_TOKEN").to_string()); + + #[cfg(any( + feature = "serenity", + feature = "serenity-cached", + feature = "twilight", + feature = "twilight-cached" + ))] + let client = Arc::new(client); delayed! { let bot = client.get_bot(264811613708746752).await.unwrap(); @@ -45,10 +227,107 @@ async fn api() { } delayed! { - let _has_voted = client.has_voted(661200758510977084).await.unwrap(); + let _vote = client.get_vote(UserSource::Discord(661200758510977084)).await.unwrap(); + } + + delayed! { + let _vote = client.get_vote(UserSource::Topgg(8226924471638491136)).await.unwrap(); } delayed! { let _is_weekend = client.is_weekend().await.unwrap(); } -} \ No newline at end of file + + #[cfg(any(feature = "serenity", feature = "serenity-cached"))] + delayed! { + let bot_token = env!("BOT_TOKEN").to_string(); + let (test_result_sender, mut test_result_receiver) = mpsc::channel(1); + + cfg_if::cfg_if! { + if #[cfg(feature = "bot-autoposter")] { + let bot_autoposter = BotAutoposter::serenity(client.as_ref(), Duration::from_secs(2)); + let bot_autoposter_handler = bot_autoposter.handler(); + } + } + + let bot = SerenityClient::builder(&bot_token, SerenityGatewayIntents::GUILD_MESSAGES | SerenityGatewayIntents::GUILDS) + .event_handler(SerenityTestEventHandler { + client: Arc::clone(&client), + result_sender: test_result_sender, + #[cfg(feature = "bot-autoposter")] + bot_autoposter: Mutex::const_new(bot_autoposter), + }); + + #[cfg(feature = "bot-autoposter")] + let bot = bot.event_handler_arc(bot_autoposter_handler); + + let mut bot = bot.await.unwrap(); + let shard_manager = Arc::clone(&bot.shard_manager); + + let test_serenity_thread = tokio::spawn(async move { + let test_result = test_result_receiver.recv().await; + + shard_manager.shutdown_all().await; + + test_result + }); + + bot.start().await.unwrap(); + test_serenity_thread.await.unwrap().unwrap().unwrap(); + } + + #[cfg(any(feature = "twilight", feature = "twilight-cached"))] + delayed! { + let bot_token = env!("BOT_TOKEN").to_string(); + let (test_result_sender, mut test_result_receiver) = mpsc::channel(1); + + #[cfg(feature = "bot-autoposter")] + let mut bot_autoposter = BotAutoposter::twilight(client.as_ref(), Duration::from_secs(2)); + + let context = Arc::new(TwilightTestContext { + bot: TwilightHttpClient::new(bot_token.clone()), + running: AtomicBool::new(true), + result_sender: test_result_sender, + #[cfg(feature = "bot-autoposter")] + autoposter_receiver: Mutex::const_new(bot_autoposter.receiver()), + }); + + let mut shard = TwilightShard::new( + TwilightShardId::ONE, + bot_token, + TwilightGatewayIntents::GUILD_MESSAGES | TwilightGatewayIntents::GUILDS, + ); + + while context.running.load(atomic::Ordering::Relaxed) { + let event = match shard.next_event().await { + Ok(event) => event, + Err(source) => { + if source.is_fatal() { + break; + } + + continue; + } + }; + + #[cfg(feature = "bot-autoposter")] + bot_autoposter.handle(&event).await; + + if matches!(event, TwilightGatewayEvent::Ready(_)) { + let thread_client = Arc::clone(&client); + let thread_context = Arc::clone(&context); + + TWILIGHT_TEST_EVENT_HANDLER_READY_ONCE.get_or_init(|| async move { + tokio::spawn(async move { + let test_result = test_twilight(&thread_client, &thread_context).await; + + thread_context.running.store(false, atomic::Ordering::Relaxed); + thread_context.result_sender.send(test_result).await.unwrap(); + }); + }).await; + } + } + + timeout(Duration::from_secs(10), test_result_receiver.recv()).await.unwrap().unwrap().unwrap(); + } +} diff --git a/src/vote.rs b/src/vote.rs index 627bf5c..6c7057c 100644 --- a/src/vote.rs +++ b/src/vote.rs @@ -1,6 +1,29 @@ use crate::snowflake; +use chrono::{DateTime, Utc}; use serde::Deserialize; +/// A Top.gg vote. +#[derive(Deserialize)] +pub struct Vote { + /// When the vote was cast. + #[serde(rename = "created_at")] + pub voted_at: DateTime, + + /// When the vote expires and the user is required to vote again. + pub expires_at: DateTime, + + /// This vote's weight. + pub weight: usize, +} + +impl Vote { + /// Whether this vote is now expired. + #[inline(always)] + pub fn expired(&self) -> bool { + Utc::now() >= self.expires_at + } +} + #[derive(Deserialize)] pub(crate) struct Voted { pub(crate) voted: u8, diff --git a/src/widget.rs b/src/widget.rs new file mode 100644 index 0000000..db15763 --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,89 @@ +use crate::Snowflake; + +/// Widget type. +#[non_exhaustive] +pub enum WidgetType { + DiscordBot, + DiscordServer, +} + +impl WidgetType { + const fn as_path(&self) -> &'static str { + match self { + Self::DiscordBot => "discord/bot", + Self::DiscordServer => "discord/server", + } + } +} + +/// Generates a large widget URL. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::large(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` +#[inline(always)] +pub fn large(ty: WidgetType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!("/v1/widgets/large/{}/{}", ty.as_path(), id.as_snowflake()) +} + +/// Generates a small widget URL for displaying votes. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::votes(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` +#[inline(always)] +pub fn votes(ty: WidgetType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/v1/widgets/small/votes/{}/{}", + ty.as_path(), + id.as_snowflake() + ) +} + +/// Generates a small widget URL for displaying a project's owner. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::owner(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` +#[inline(always)] +pub fn owner(ty: WidgetType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/v1/widgets/small/owner/{}/{}", + ty.as_path(), + id.as_snowflake() + ) +} + +/// Generates a small widget URL for displaying social stats. +/// +/// # Example +/// +/// ```rust,no_run +/// let widget_url = topgg::widget::social(topgg::WidgetType::DiscordBot, 574652751745777665); +/// ``` +#[inline(always)] +pub fn social(ty: WidgetType, id: I) -> String +where + I: Snowflake, +{ + crate::client::api!( + "/v1/widgets/small/social/{}/{}", + ty.as_path(), + id.as_snowflake() + ) +}