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