diff --git a/DisCatSharp.ApplicationCommands/Attributes/ContextMenu/ContextMenuCooldownAttribute.cs b/DisCatSharp.ApplicationCommands/Attributes/ContextMenu/ContextMenuCooldownAttribute.cs index ade85a90b..bd88a20f2 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 +public sealed class ContextMenuCooldownAttribute : ContextMenuCheckBaseAttribute, ICooldown { /// /// 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() => $"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)}"; } diff --git a/DisCatSharp.ApplicationCommands/Attributes/ICooldown.cs b/DisCatSharp.ApplicationCommands/Attributes/ICooldown.cs new file mode 100644 index 000000000..03fc52845 --- /dev/null +++ b/DisCatSharp.ApplicationCommands/Attributes/ICooldown.cs @@ -0,0 +1,67 @@ +// 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 DisCatSharp.ApplicationCommands.Context; +using DisCatSharp.ApplicationCommands.Enums; + +namespace DisCatSharp.ApplicationCommands.Attributes; + +/// +/// Cooldown feature contract +/// +/// Type of in which this cooldown handles +/// Type of Cooldown bucket +public interface ICooldown + where TContextType : BaseContext + where TBucketType : IEquatable +{ + /// + /// Gets the maximum number of uses before this command triggers a cooldown for its bucket. + /// + int MaxUses { get; } + + /// + /// Gets the time after which the cooldown is reset. + /// + TimeSpan Reset { get; } + + /// + /// Gets the type of the cooldown bucket. This determines how a cooldown is applied. + /// + CooldownBucketType BucketType { get; } + + /// + /// Calculates the cooldown remaining for given context. + /// + /// Context for which to calculate the cooldown. + /// Remaining cooldown, or zero if no cooldown is active + TimeSpan GetRemainingCooldown(TContextType ctx); + + /// + /// Gets a cooldown bucket for given context + /// + /// Command context to get cooldown bucket for. + /// Requested cooldown bucket, or null if one wasn't present + TBucketType GetBucket(TContextType ctx); +} diff --git a/DisCatSharp.ApplicationCommands/Attributes/SlashCommand/SlashCommandCooldownAttribute.cs b/DisCatSharp.ApplicationCommands/Attributes/SlashCommand/SlashCommandCooldownAttribute.cs index bd4479f93..f4da2088a 100644 --- a/DisCatSharp.ApplicationCommands/Attributes/SlashCommand/SlashCommandCooldownAttribute.cs +++ b/DisCatSharp.ApplicationCommands/Attributes/SlashCommand/SlashCommandCooldownAttribute.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 SlashCommandCooldownAttribute : SlashCheckBaseAttribute +public sealed class SlashCommandCooldownAttribute : SlashCheckBaseAttribute, ICooldown { /// /// 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 SlashCommandCooldownAttribute(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 SlashCommandCooldownBucket GetBucket(InteractionContext 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(InteractionContext 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(InteractionContext 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 = SlashCommandCooldownBucket.MakeId(userId, channelId, guildId); return bid; } /// /// Executes a check. /// /// The command context. public override async Task ExecuteChecksAsync(InteractionContext 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 SlashCommandCooldownBucket(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 SlashCommandCooldownBucket : 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 SlashCommandCooldownBucket(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() => $"Slash 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 SlashCommandCooldownBucket); /// /// Checks whether this is equal to another . /// /// to compare to. /// Whether the is equal to this . public bool Equals(SlashCommandCooldownBucket 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 ==(SlashCommandCooldownBucket bucket1, SlashCommandCooldownBucket 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 !=(SlashCommandCooldownBucket bucket1, SlashCommandCooldownBucket 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)}"; }