diff --git a/DisCatSharp.ApplicationCommands/Attributes/ContextMenu/ContextMenuCooldownAttribute.cs b/DisCatSharp.ApplicationCommands/Attributes/ContextMenu/ContextMenuCooldownAttribute.cs index c4cfb8d28..ade85a90b 100644 --- a/DisCatSharp.ApplicationCommands/Attributes/ContextMenu/ContextMenuCooldownAttribute.cs +++ b/DisCatSharp.ApplicationCommands/Attributes/ContextMenu/ContextMenuCooldownAttribute.cs @@ -1,305 +1,305 @@ // This file is part of the DisCatSharp project, based off DSharpPlus. // // Copyright (c) 2021-2022 AITSYS // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. using System; using System.Collections.Concurrent; using System.Globalization; using System.Threading; using System.Threading.Tasks; using DisCatSharp.ApplicationCommands.Context; using DisCatSharp.ApplicationCommands.Enums; namespace DisCatSharp.ApplicationCommands.Attributes; /// /// Defines a cooldown for this command. This allows you to define how many times can users execute a specific command /// [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = false)] public sealed class ContextMenuCooldownAttribute : ContextMenuCheckBaseAttribute { /// /// Gets the maximum number of uses before this command triggers a cooldown for its bucket. /// public int MaxUses { get; } /// /// Gets the time after which the cooldown is reset. /// public TimeSpan Reset { get; } /// /// Gets the type of the cooldown bucket. This determines how cooldowns are applied. /// public CooldownBucketType BucketType { get; } /// /// Gets the cooldown buckets for this command. /// private readonly ConcurrentDictionary _buckets; /// /// Defines a cooldown for this command. This means that users will be able to use the command a specific number of times before they have to wait to use it again. /// /// Number of times the command can be used before triggering a cooldown. /// Number of seconds after which the cooldown is reset. /// Type of cooldown bucket. This allows controlling whether the bucket will be cooled down per user, guild, channel, or globally. public ContextMenuCooldownAttribute(int maxUses, double resetAfter, CooldownBucketType bucketType) { this.MaxUses = maxUses; this.Reset = TimeSpan.FromSeconds(resetAfter); this.BucketType = bucketType; this._buckets = new ConcurrentDictionary(); } /// /// Gets a cooldown bucket for given command context. /// /// Command context to get cooldown bucket for. /// Requested cooldown bucket, or null if one wasn't present. public ContextMenuCooldownBucket GetBucket(ContextMenuContext ctx) { var bid = this.GetBucketId(ctx, out _, out _, out _); this._buckets.TryGetValue(bid, out var bucket); return bucket; } /// /// Calculates the cooldown remaining for given command context. /// /// Context for which to calculate the cooldown. /// Remaining cooldown, or zero if no cooldown is active. public TimeSpan GetRemainingCooldown(ContextMenuContext ctx) { var bucket = this.GetBucket(ctx); return bucket == null ? TimeSpan.Zero : bucket.RemainingUses > 0 ? TimeSpan.Zero : bucket.ResetsAt - DateTimeOffset.UtcNow; } /// /// Calculates bucket ID for given command context. /// /// Context for which to calculate bucket ID for. /// ID of the user with which this bucket is associated. /// ID of the channel with which this bucket is associated. /// ID of the guild with which this bucket is associated. /// Calculated bucket ID. private string GetBucketId(ContextMenuContext ctx, out ulong userId, out ulong channelId, out ulong guildId) { userId = 0ul; if ((this.BucketType & CooldownBucketType.User) != 0) userId = ctx.User.Id; channelId = 0ul; if ((this.BucketType & CooldownBucketType.Channel) != 0) channelId = ctx.Channel.Id; if ((this.BucketType & CooldownBucketType.Guild) != 0 && ctx.Guild == null) channelId = ctx.Channel.Id; guildId = 0ul; if (ctx.Guild != null && (this.BucketType & CooldownBucketType.Guild) != 0) guildId = ctx.Guild.Id; var bid = ContextMenuCooldownBucket.MakeId(userId, channelId, guildId); return bid; } /// /// Executes a check. /// /// The command context. public override async Task ExecuteChecksAsync(ContextMenuContext ctx) { var bid = this.GetBucketId(ctx, out var usr, out var chn, out var gld); if (!this._buckets.TryGetValue(bid, out var bucket)) { bucket = new ContextMenuCooldownBucket(this.MaxUses, this.Reset, usr, chn, gld); this._buckets.AddOrUpdate(bid, bucket, (k, v) => bucket); } return await bucket.DecrementUseAsync().ConfigureAwait(false); } } /// /// Represents a cooldown bucket for commands. /// public sealed class ContextMenuCooldownBucket : IEquatable { /// /// Gets the ID of the user with whom this cooldown is associated. /// public ulong UserId { get; } /// /// Gets the ID of the channel with which this cooldown is associated. /// public ulong ChannelId { get; } /// /// Gets the ID of the guild with which this cooldown is associated. /// public ulong GuildId { get; } /// /// Gets the ID of the bucket. This is used to distinguish between cooldown buckets. /// public string BucketId { get; } /// /// Gets the remaining number of uses before the cooldown is triggered. /// public int RemainingUses => Volatile.Read(ref this._remainingUses); private int _remainingUses; /// /// Gets the maximum number of times this command can be used in given timespan. /// public int MaxUses { get; } /// /// Gets the date and time at which the cooldown resets. /// public DateTimeOffset ResetsAt { get; internal set; } /// /// Gets the time after which this cooldown resets. /// public TimeSpan Reset { get; internal set; } /// /// Gets the semaphore used to lock the use value. /// private readonly SemaphoreSlim _usageSemaphore; /// /// Creates a new command cooldown bucket. /// /// Maximum number of uses for this bucket. /// Time after which this bucket resets. /// ID of the user with which this cooldown is associated. /// ID of the channel with which this cooldown is associated. /// ID of the guild with which this cooldown is associated. internal ContextMenuCooldownBucket(int maxUses, TimeSpan resetAfter, ulong userId = 0, ulong channelId = 0, ulong guildId = 0) { this._remainingUses = maxUses; this.MaxUses = maxUses; this.ResetsAt = DateTimeOffset.UtcNow + resetAfter; this.Reset = resetAfter; this.UserId = userId; this.ChannelId = channelId; this.GuildId = guildId; this.BucketId = MakeId(userId, channelId, guildId); this._usageSemaphore = new SemaphoreSlim(1, 1); } /// /// Decrements the remaining use counter. /// /// Whether decrement succeeded or not. internal async Task DecrementUseAsync() { await this._usageSemaphore.WaitAsync().ConfigureAwait(false); // if we're past reset time... var now = DateTimeOffset.UtcNow; if (now >= this.ResetsAt) { // ...do the reset and set a new reset time Interlocked.Exchange(ref this._remainingUses, this.MaxUses); this.ResetsAt = now + this.Reset; } // check if we have any uses left, if we do... var success = false; if (this.RemainingUses > 0) { // ...decrement, and return success... Interlocked.Decrement(ref this._remainingUses); success = true; } // ...otherwise just fail this._usageSemaphore.Release(); return success; } /// /// Returns a string representation of this command cooldown bucket. /// /// String representation of this command cooldown bucket. - public override string ToString() => $"Command bucket {this.BucketId}"; + public override string ToString() => $"Context Menu Command bucket {this.BucketId}"; /// /// Checks whether this is equal to another object. /// /// Object to compare to. /// Whether the object is equal to this . public override bool Equals(object obj) => this.Equals(obj as ContextMenuCooldownBucket); /// /// Checks whether this is equal to another . /// /// to compare to. /// Whether the is equal to this . public bool Equals(ContextMenuCooldownBucket other) => other is not null && (ReferenceEquals(this, other) || (this.UserId == other.UserId && this.ChannelId == other.ChannelId && this.GuildId == other.GuildId)); /// /// Gets the hash code for this . /// /// The hash code for this . public override int GetHashCode() => HashCode.Combine(this.UserId, this.ChannelId, this.GuildId); /// /// Gets whether the two objects are equal. /// /// First bucket to compare. /// Second bucket to compare. /// Whether the two buckets are equal. public static bool operator ==(ContextMenuCooldownBucket bucket1, ContextMenuCooldownBucket bucket2) { var null1 = bucket1 is null; var null2 = bucket2 is null; return (null1 && null2) || (null1 == null2 && null1.Equals(null2)); } /// /// Gets whether the two objects are not equal. /// /// First bucket to compare. /// Second bucket to compare. /// Whether the two buckets are not equal. public static bool operator !=(ContextMenuCooldownBucket bucket1, ContextMenuCooldownBucket bucket2) => !(bucket1 == bucket2); /// /// Creates a bucket ID from given bucket parameters. /// /// ID of the user with which this cooldown is associated. /// ID of the channel with which this cooldown is associated. /// ID of the guild with which this cooldown is associated. /// Generated bucket ID. public static string MakeId(ulong userId = 0, ulong channelId = 0, ulong guildId = 0) => $"{userId.ToString(CultureInfo.InvariantCulture)}:{channelId.ToString(CultureInfo.InvariantCulture)}:{guildId.ToString(CultureInfo.InvariantCulture)}"; }