twilight_bucket/lib.rs
1//! # Twilight Bucket
2//! A utility crate to limit users' usage, a third party crate of the
3//! [Twilight ecosystem](https://docs.rs/twilight)
4//!
5//! All the functionality of this crate is under [`Bucket`], see its
6//! documentation for usage info
7//!
8//! This crate can be used with any library, but it shares Twilight's non-goals,
9//! such as trying to be more verbose and less opinionated and
10//! [Serenity already has a bucket implementation
11//! ](https://docs.rs/serenity/latest/serenity/framework/standard/buckets)
12//!
13//! # Example
14//! ```
15//! use std::{num::NonZeroU64, time::Duration};
16//!
17//! use twilight_bucket::{Bucket, Limit};
18//!
19//! #[tokio::main]
20//! async fn main() {
21//! // A user can use it once every 10 seconds
22//! let my_command_user_bucket = Bucket::new(Limit::new(Duration::from_secs(10), 1));
23//! // It can be used up to 5 times every 30 seconds in one channel
24//! let my_command_channel_bucket = Bucket::new(Limit::new(Duration::from_secs(30), 5));
25//! run_my_command(
26//! my_command_user_bucket,
27//! my_command_channel_bucket,
28//! 12345,
29//! 123,
30//! )
31//! .await;
32//! }
33//!
34//! async fn run_my_command(
35//! user_bucket: Bucket,
36//! channel_bucket: Bucket,
37//! user_id: u64,
38//! channel_id: u64,
39//! ) -> String {
40//! if let Some(channel_limit_duration) = channel_bucket.limit_duration(channel_id) {
41//! return format!(
42//! "This was used too much in this channel, please wait {} seconds",
43//! channel_limit_duration.as_secs()
44//! );
45//! }
46//! if let Some(user_limit_duration) = user_bucket.limit_duration(user_id) {
47//! if Duration::from_secs(5) > user_limit_duration {
48//! tokio::time::sleep(user_limit_duration).await;
49//! } else {
50//! return format!(
51//! "You've been using this too much, please wait {} seconds",
52//! user_limit_duration.as_secs()
53//! );
54//! }
55//! }
56//! user_bucket.register(user_id);
57//! channel_bucket.register(channel_id);
58//! "Ran your command".to_owned()
59//! }
60//! ```
61
62#![warn(clippy::cargo, clippy::nursery, clippy::pedantic, clippy::restriction)]
63#![allow(
64 clippy::blanket_clippy_restriction_lints,
65 clippy::missing_inline_in_public_items,
66 clippy::implicit_return,
67 clippy::shadow_same,
68 clippy::separated_literal_suffix
69)]
70
71use std::{
72 num::NonZeroU64,
73 time::{Duration, Instant},
74};
75
76use dashmap::DashMap;
77
78/// Information about how often something is able to be used
79///
80/// # examples
81/// Something can be used every 3 seconds
82/// ```
83/// twilight_bucket::Limit::new(std::time::Duration::from_secs(3), 1);
84/// ```
85/// Something can be used 10 times in 1 minute, so the limit resets every minute
86/// ```
87/// twilight_bucket::Limit::new(std::time::Duration::from_secs(60), 10);
88/// ```
89#[must_use]
90#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
91pub struct Limit {
92 /// How often something can be done [`Limit::count`] times
93 duration: Duration,
94 /// How many times something can be done in the [`Limit::duration`] period
95 count: u16,
96}
97
98impl Limit {
99 /// Create a new [`Limit`]
100 pub const fn new(duration: Duration, count: u16) -> Self {
101 Self { duration, count }
102 }
103}
104
105/// Usage information about an ID
106#[must_use]
107#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
108struct Usage {
109 /// The last time it was used
110 time: Instant,
111 /// How many times it was used
112 count: u16,
113}
114
115impl Usage {
116 /// Make a `Usage` with now as `time` and 1 as `count`
117 fn new() -> Self {
118 Self {
119 time: Instant::now(),
120 count: 1,
121 }
122 }
123}
124
125/// This is the main struct to do everything you need
126///
127/// # Global or task-based
128/// Essentially buckets just store usages and limits, meaning you can create a
129/// different bucket for each kind of limit: each of your commands, separate
130/// buckets for channel and user usage if you want to have different limits for
131/// each etc.
132///
133/// # Usage
134/// Register usages using the [`Bucket::register`] method **after** getting the
135/// limit with [`Bucket::limit_duration`]
136///
137/// `ID`s use [`NonZeroU64`](std::num::NonZeroU64) to be compatible with any
138/// kind of ID: users, guilds or even your custom IDs
139#[must_use]
140#[derive(Debug)]
141pub struct Bucket {
142 /// The limit for this bucket
143 limit: Limit,
144 /// Usage information for IDs
145 usages: DashMap<NonZeroU64, Usage>,
146}
147
148impl Bucket {
149 /// Create a new [`Bucket`] with the given limit
150 pub fn new(limit: Limit) -> Self {
151 Self {
152 limit,
153 usages: DashMap::new(),
154 }
155 }
156
157 /// Register a usage, you should call this every time something you want to
158 /// limit is done **after** waiting for the limit
159 ///
160 /// ```
161 /// # use std::time::Duration;
162 /// # use twilight_bucket::{Bucket, Limit};
163 /// # #[tokio::main]
164 /// # async fn main() {
165 /// # let user_id = 123;
166 /// # let bucket = Bucket::new(Limit::new(Duration::from_secs(1), 1));
167 /// if let Some(duration) = bucket.limit_duration(user_id) {
168 /// tokio::time::sleep(duration).await;
169 /// }
170 /// bucket.register(user_id);
171 /// # }
172 /// ```
173 ///
174 /// # Panics
175 /// If the `id` is 0 or when the usage count is over [`u16::MAX`]
176 #[allow(clippy::unwrap_used, clippy::integer_arithmetic)]
177 pub fn register(&self, id: u64) {
178 let id_non_zero = id.try_into().unwrap();
179 match self.usages.get_mut(&id_non_zero) {
180 Some(mut usage) => {
181 let now = Instant::now();
182 usage.count = if now - usage.time > self.limit.duration {
183 1
184 } else {
185 usage.count + 1
186 };
187 usage.time = now;
188 }
189 None => {
190 self.usages.insert(id_non_zero, Usage::new());
191 }
192 }
193 }
194
195 /// Get the duration to wait until the next usage by `id`, returns `None`
196 /// if the `id` isn't limited, you should call this **before** registering a
197 /// usage
198 ///
199 /// ```
200 /// # use std::time::Duration;
201 /// # use twilight_bucket::{Bucket, Limit};
202 /// # #[tokio::main]
203 /// # async fn main() {
204 /// # let user_id = 123;
205 /// # let bucket = Bucket::new(Limit::new(Duration::from_secs(1), 1));
206 /// if let Some(duration) = bucket.limit_duration(user_id) {
207 /// tokio::time::sleep(duration).await;
208 /// }
209 /// bucket.register(user_id);
210 /// # }
211 /// ```
212 ///
213 /// # Panics
214 /// If the `id` is 0
215 #[must_use]
216 #[allow(clippy::unwrap_in_result, clippy::unwrap_used)]
217 pub fn limit_duration(&self, id: u64) -> Option<Duration> {
218 let usage = self.usages.get(&id.try_into().unwrap())?;
219 let elapsed = Instant::now() - usage.time;
220 (usage.count >= self.limit.count && self.limit.duration > elapsed)
221 .then(|| self.limit.duration - elapsed)
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use std::time::Duration;
228
229 use tokio::time::sleep;
230
231 use crate::{Bucket, Limit};
232
233 #[allow(clippy::unwrap_used)]
234 #[tokio::test]
235 async fn limit_count_1() {
236 let bucket = Bucket::new(Limit::new(Duration::from_secs(2), 1));
237 let id = 123;
238
239 assert!(bucket.limit_duration(id).is_none());
240
241 bucket.register(id);
242 assert!(
243 bucket.limit_duration(id).unwrap()
244 > bucket.limit.duration - Duration::from_secs_f32(0.1)
245 );
246 sleep(bucket.limit.duration).await;
247 assert!(bucket.limit_duration(id).is_none());
248 }
249
250 #[allow(clippy::unwrap_used)]
251 #[tokio::test]
252 async fn limit_count_5() {
253 let bucket = Bucket::new(Limit::new(Duration::from_secs(5), 5));
254 let id = 123;
255
256 for _ in 0_u8..5 {
257 assert!(bucket.limit_duration(id).is_none());
258 bucket.register(id);
259 }
260
261 assert!(
262 bucket.limit_duration(id).unwrap()
263 > bucket.limit.duration - Duration::from_secs_f32(0.1)
264 );
265 sleep(bucket.limit.duration).await;
266 assert!(bucket.limit_duration(id).is_none());
267 }
268}