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}