Skip to content

Commit 8ab32b6

Browse files
LulalabyTheXorog
andauthored
feat: Implement cooldowns for application commands (#431)
* feat: make cooldowns work feat: move cooldown to discatsharp base package chore: some experimental stuff fix: commandsnext cooldowns * [ci skip] chore: resharper disable for sealed on CooldownBucket * fix: try fixing translations (while i'm on it) * fix: really fix translation export * fix: fixed translations (while i'm on it) * feat: add by discord added allowed locales * [ci skip] chore: update release notes * fix: fix registration of applicaiton commands when commands cleared or non existent in advance * feat: custom cooldown responder * docs: fix space -> tab * Update RELEASENOTES.md * fix: fix registration of translations for subcommands * fix: nre * chore: remove command grouping type --------- Co-authored-by: Mira <[email protected]>
1 parent 237d1f0 commit 8ab32b6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+746
-1098
lines changed

DisCatSharp.ApplicationCommands/ApplicationCommandsExtension.cs

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
using DisCatSharp.ApplicationCommands.EventArgs;
1414
using DisCatSharp.ApplicationCommands.Exceptions;
1515
using DisCatSharp.ApplicationCommands.Workers;
16-
using DisCatSharp.Attributes;
1716
using DisCatSharp.Common;
1817
using DisCatSharp.Common.Utilities;
1918
using DisCatSharp.Entities;
2019
using DisCatSharp.Enums;
20+
using DisCatSharp.Enums.Core;
2121
using DisCatSharp.EventArgs;
2222
using DisCatSharp.Exceptions;
2323

@@ -661,39 +661,37 @@ private async Task RegisterCommands(List<ApplicationCommandsModuleConfiguration>
661661
if (Configuration.GenerateTranslationFilesOnly)
662662
{
663663
var cgwsgs = new List<CommandGroupWithSubGroups>();
664-
var cgs2 = new List<CommandGroup>();
665664
foreach (var cmd in slashGroupsTuple.applicationCommands)
666665
if (cmd.Type is ApplicationCommandType.ChatInput)
667666
{
668667
var cgs = new List<CommandGroup>();
668+
var cs2 = new List<Command>();
669669
if (cmd.Options is not null)
670+
{
670671
foreach (var scg in cmd.Options.Where(x => x.Type is ApplicationCommandOptionType.SubCommandGroup))
671672
{
672673
var cs = new List<Command>();
673674
if (scg.Options is not null)
674675
foreach (var sc in scg.Options)
675676
if (sc.Options is null || sc.Options.Count is 0)
676-
cs.Add(new(sc.Name, sc.Description, null, null));
677+
cs.Add(new(sc.Name, sc.Description, null, null, sc.RawNameLocalizations, sc.RawDescriptionLocalizations));
677678
else
678-
cs.Add(new(sc.Name, sc.Description, [.. sc.Options], null));
679-
cgs.Add(new(scg.Name, scg.Description, cs, null));
679+
cs.Add(new(sc.Name, sc.Description, [.. sc.Options], null, sc.RawNameLocalizations, sc.RawDescriptionLocalizations));
680+
cgs.Add(new(scg.Name, scg.Description, cs, null, scg.RawNameLocalizations, scg.RawDescriptionLocalizations));
680681
}
681682

682-
cgwsgs.Add(new(cmd.Name, cmd.Description, cgs, cmd.Type));
683+
foreach (var sc2 in cmd.Options.Where(x => x.Type is ApplicationCommandOptionType.SubCommand))
684+
if (sc2.Options == null || sc2.Options.Count == 0)
685+
cs2.Add(new(sc2.Name, sc2.Description, null, null, sc2.RawNameLocalizations, sc2.RawDescriptionLocalizations));
686+
else
687+
cs2.Add(new(sc2.Name, sc2.Description, [.. sc2.Options], null, sc2.RawNameLocalizations, sc2.RawDescriptionLocalizations));
688+
}
683689

684-
var cs2 = new List<Command>();
685-
foreach (var sc2 in cmd.Options.Where(x => x.Type is ApplicationCommandOptionType.SubCommand))
686-
if (sc2.Options == null || sc2.Options.Count == 0)
687-
cs2.Add(new(sc2.Name, sc2.Description, null, null));
688-
else
689-
cs2.Add(new(sc2.Name, sc2.Description, [.. sc2.Options], null));
690-
cgs2.Add(new(cmd.Name, cmd.Description, cs2, cmd.Type));
690+
cgwsgs.Add(new(cmd.Name, cmd.Description, cgs, cs2, cmd.Type, cmd.RawNameLocalizations, cmd.RawDescriptionLocalizations));
691691
}
692692

693693
if (cgwsgs.Count is not 0)
694694
groupTranslation.AddRange(cgwsgs.Select(cgwsg => JsonConvert.DeserializeObject<GroupTranslator>(JsonConvert.SerializeObject(cgwsg))!));
695-
if (cgs2.Count is not 0)
696-
groupTranslation.AddRange(cgs2.Select(cg2 => JsonConvert.DeserializeObject<GroupTranslator>(JsonConvert.SerializeObject(cg2))!));
697695
}
698696
}
699697

@@ -733,12 +731,20 @@ private async Task RegisterCommands(List<ApplicationCommandsModuleConfiguration>
733731
var cs = new List<Command>();
734732
foreach (var cmd in slashCommands.applicationCommands.Where(cmd => cmd.Type is ApplicationCommandType.ChatInput && (cmd.Options is null || !cmd.Options.Any(x => x.Type is ApplicationCommandOptionType.SubCommand or ApplicationCommandOptionType.SubCommandGroup))))
735733
if (cmd.Options == null || cmd.Options.Count == 0)
736-
cs.Add(new(cmd.Name, cmd.Description, null, ApplicationCommandType.ChatInput));
734+
cs.Add(new(cmd.Name, cmd.Description, null, ApplicationCommandType.ChatInput, cmd.RawNameLocalizations, cmd.RawDescriptionLocalizations));
737735
else
738-
cs.Add(new(cmd.Name, cmd.Description, [.. cmd.Options], ApplicationCommandType.ChatInput));
736+
cs.Add(new(cmd.Name, cmd.Description, [.. cmd.Options], ApplicationCommandType.ChatInput, cmd.RawNameLocalizations, cmd.RawDescriptionLocalizations));
739737

740738
if (cs.Count is not 0)
741-
translation.AddRange(cs.Select(c => JsonConvert.DeserializeObject<CommandTranslator>(JsonConvert.SerializeObject(c))!));
739+
//translation.AddRange(cs.Select(c => JsonConvert.DeserializeObject<CommandTranslator>(JsonConvert.SerializeObject(c))!));
740+
{
741+
foreach (var c in cs)
742+
{
743+
var json = JsonConvert.SerializeObject(c);
744+
var obj = JsonConvert.DeserializeObject<CommandTranslator>(json);
745+
translation.Add(obj!);
746+
}
747+
}
742748
}
743749
}
744750

@@ -804,7 +810,7 @@ private async Task RegisterCommands(List<ApplicationCommandsModuleConfiguration>
804810
{
805811
updateList = updateList.DistinctBy(x => x.Name).ToList();
806812
if (Configuration.GenerateTranslationFilesOnly)
807-
await this.CheckRegistrationStartup(translation, groupTranslation);
813+
await this.CheckRegistrationStartup(translation, groupTranslation, guildId);
808814
else
809815
try
810816
{
@@ -911,7 +917,7 @@ private async Task RegisterCommands(List<ApplicationCommandsModuleConfiguration>
911917
RegisteredCommands = GlobalCommandsInternal
912918
}).ConfigureAwait(false);
913919

914-
await this.CheckRegistrationStartup(translation, groupTranslation);
920+
await this.CheckRegistrationStartup(translation, groupTranslation, guildId);
915921
}
916922
catch (NullReferenceException ex)
917923
{
@@ -965,15 +971,16 @@ private async Task RegisterCommands(List<ApplicationCommandsModuleConfiguration>
965971
/// </summary>
966972
/// <param name="translation">The optional translations.</param>
967973
/// <param name="groupTranslation">The optional group translations.</param>
968-
private async Task CheckRegistrationStartup(List<CommandTranslator>? translation = null, List<GroupTranslator>? groupTranslation = null)
974+
/// <param name="guildId">The optional guild id.</param>
975+
private async Task CheckRegistrationStartup(List<CommandTranslator>? translation = null, List<GroupTranslator>? groupTranslation = null, ulong? guildId = null)
969976
{
970977
if (Configuration.GenerateTranslationFilesOnly)
971978
{
972979
try
973980
{
974981
if (translation is not null && translation.Count is not 0)
975982
{
976-
var fileName = $"translation_generator_export-shard{this.Client.ShardId}-SINGLE.json";
983+
var fileName = $"translation_generator_export-shard{this.Client.ShardId}-SINGLE-{(guildId.HasValue ? guildId.Value : "global")}.json";
977984
var fs = File.Create(fileName);
978985
var ms = new MemoryStream();
979986
var writer = new StreamWriter(ms);
@@ -991,7 +998,7 @@ private async Task CheckRegistrationStartup(List<CommandTranslator>? translation
991998

992999
if (groupTranslation is not null && groupTranslation.Count is not 0)
9931000
{
994-
var fileName = $"translation_generator_export-shard{this.Client.ShardId}-GROUP.json";
1001+
var fileName = $"translation_generator_export-shard{this.Client.ShardId}-GROUP-{(guildId.HasValue ? guildId.Value : "global")}.json";
9951002
var fs = File.Create(fileName);
9961003
var ms = new MemoryStream();
9971004
var writer = new StreamWriter(ms);
@@ -1030,6 +1037,8 @@ private async Task CheckStartupFinishAsync(ApplicationCommandsExtension sender,
10301037
GuildsWithoutScope = s_missingScopeGuildIdsGlobal
10311038
}).ConfigureAwait(false);
10321039
FinishFired = true;
1040+
if (Configuration.GenerateTranslationFilesOnly)
1041+
Environment.Exit(0);
10331042
}
10341043

10351044
args.Handled = false;
@@ -1081,7 +1090,11 @@ private Task InteractionHandler(DiscordClient client, InteractionCreateEventArgs
10811090
GuildLocale = e.Interaction.GuildLocale,
10821091
AppPermissions = e.Interaction.AppPermissions,
10831092
Entitlements = e.Interaction.Entitlements,
1084-
EntitlementSkuIds = e.Interaction.EntitlementSkuIds
1093+
EntitlementSkuIds = e.Interaction.EntitlementSkuIds,
1094+
UserId = e.Interaction.User.Id,
1095+
GuildId = e.Interaction.GuildId,
1096+
MemberId = e.Interaction.GuildId is not null ? e.Interaction.User.Id : null,
1097+
ChannelId = e.Interaction.ChannelId
10851098
};
10861099

10871100
try
@@ -1340,7 +1353,12 @@ private Task ContextMenuHandler(DiscordClient client, ContextMenuInteractionCrea
13401353
_ = Task.Run(async () =>
13411354
{
13421355
//Creates the context
1343-
var context = new ContextMenuContext
1356+
var context = new ContextMenuContext(e.Type switch
1357+
{
1358+
ApplicationCommandType.User => DisCatSharpCommandType.UserCommand,
1359+
ApplicationCommandType.Message => DisCatSharpCommandType.MessageCommand,
1360+
_ => throw new ArgumentOutOfRangeException(nameof(e.Type), "Unknown context menu type")
1361+
})
13441362
{
13451363
Interaction = e.Interaction,
13461364
Channel = e.Interaction.Channel,
@@ -1359,7 +1377,11 @@ private Task ContextMenuHandler(DiscordClient client, ContextMenuInteractionCrea
13591377
GuildLocale = e.Interaction.GuildLocale,
13601378
AppPermissions = e.Interaction.AppPermissions,
13611379
Entitlements = e.Interaction.Entitlements,
1362-
EntitlementSkuIds = e.Interaction.EntitlementSkuIds
1380+
EntitlementSkuIds = e.Interaction.EntitlementSkuIds,
1381+
UserId = e.Interaction.User.Id,
1382+
GuildId = e.Interaction.GuildId,
1383+
MemberId = e.Interaction.GuildId is not null ? e.Interaction.User.Id : null,
1384+
ChannelId = e.Interaction.ChannelId
13631385
};
13641386

13651387
try
Lines changed: 62 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,62 @@
11
using System;
2-
using System.Collections.Concurrent;
2+
using System.Collections.Generic;
3+
using System.Globalization;
34
using System.Threading.Tasks;
45

56
using DisCatSharp.ApplicationCommands.Context;
67
using DisCatSharp.ApplicationCommands.Entities;
7-
using DisCatSharp.ApplicationCommands.Enums;
8+
using DisCatSharp.Entities;
9+
using DisCatSharp.Entities.Core;
10+
using DisCatSharp.Enums;
11+
using DisCatSharp.Enums.Core;
12+
13+
using Sentry;
814

915
namespace DisCatSharp.ApplicationCommands.Attributes;
1016

1117
/// <summary>
1218
/// Defines a cooldown for this command. This allows you to define how many times can users execute a specific command
1319
/// </summary>
20+
/// <remarks>
21+
/// 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.
22+
/// </remarks>
23+
/// <param name="maxUses">Number of times the command can be used before triggering a cooldown.</param>
24+
/// <param name="resetAfter">Number of seconds after which the cooldown is reset.</param>
25+
/// <param name="bucketType">Type of cooldown bucket. This allows controlling whether the bucket will be cooled down per user, guild, member, channel, and/or globally.</param>
26+
/// <param name="cooldownResponderType">The responder type used to respond to cooldown ratelimit hits.</param>
1427
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
15-
public sealed class ContextMenuCooldownAttribute : ApplicationCommandCheckBaseAttribute, ICooldown<BaseContext, ContextMenuCooldownBucket>
28+
public sealed class ContextMenuCooldownAttribute(int maxUses, double resetAfter, CooldownBucketType bucketType, Type? cooldownResponderType = null) : ApplicationCommandCheckBaseAttribute, ICooldown<BaseContext, CooldownBucket>
1629
{
1730
/// <summary>
1831
/// Gets the maximum number of uses before this command triggers a cooldown for its bucket.
1932
/// </summary>
20-
public int MaxUses { get; }
33+
public int MaxUses { get; } = maxUses;
2134

2235
/// <summary>
2336
/// Gets the time after which the cooldown is reset.
2437
/// </summary>
25-
public TimeSpan Reset { get; }
38+
public TimeSpan Reset { get; } = TimeSpan.FromSeconds(resetAfter);
2639

2740
/// <summary>
2841
/// Gets the type of the cooldown bucket. This determines how cooldowns are applied.
2942
/// </summary>
30-
public CooldownBucketType BucketType { get; }
31-
32-
/// <summary>
33-
/// Gets the cooldown buckets for this command.
34-
/// </summary>
35-
internal readonly ConcurrentDictionary<string, ContextMenuCooldownBucket> Buckets;
43+
public CooldownBucketType BucketType { get; } = bucketType;
3644

3745
/// <summary>
38-
/// 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.
46+
/// Gets the responder type.
3947
/// </summary>
40-
/// <param name="maxUses">Number of times the command can be used before triggering a cooldown.</param>
41-
/// <param name="resetAfter">Number of seconds after which the cooldown is reset.</param>
42-
/// <param name="bucketType">Type of cooldown bucket. This allows controlling whether the bucket will be cooled down per user, guild, channel, or globally.</param>
43-
public ContextMenuCooldownAttribute(int maxUses, double resetAfter, CooldownBucketType bucketType)
44-
{
45-
this.MaxUses = maxUses;
46-
this.Reset = TimeSpan.FromSeconds(resetAfter);
47-
this.BucketType = bucketType;
48-
this.Buckets = new();
49-
}
48+
public Type? ResponderType { get; } = cooldownResponderType;
5049

5150
/// <summary>
5251
/// Gets a cooldown bucket for given command context.
5352
/// </summary>
5453
/// <param name="ctx">Command context to get cooldown bucket for.</param>
5554
/// <returns>Requested cooldown bucket, or null if one wasn't present.</returns>
56-
public ContextMenuCooldownBucket GetBucket(BaseContext ctx)
55+
public CooldownBucket GetBucket(BaseContext ctx)
5756
{
58-
var bid = this.GetBucketId(ctx, out _, out _, out _);
59-
this.Buckets.TryGetValue(bid, out var bucket);
60-
return bucket;
57+
var bid = this.GetBucketId(ctx, out _, out _, out _, out _);
58+
ctx.Client.CommandCooldownBuckets.TryGetValue(bid, out var bucket);
59+
return bucket!;
6160
}
6261

6362
/// <summary>
@@ -68,7 +67,7 @@ public ContextMenuCooldownBucket GetBucket(BaseContext ctx)
6867
public TimeSpan GetRemainingCooldown(BaseContext ctx)
6968
{
7069
var bucket = this.GetBucket(ctx);
71-
return bucket == null
70+
return bucket == null!
7271
? TimeSpan.Zero
7372
: bucket.RemainingUses > 0
7473
? TimeSpan.Zero
@@ -82,8 +81,9 @@ public TimeSpan GetRemainingCooldown(BaseContext ctx)
8281
/// <param name="userId">ID of the user with which this bucket is associated.</param>
8382
/// <param name="channelId">ID of the channel with which this bucket is associated.</param>
8483
/// <param name="guildId">ID of the guild with which this bucket is associated.</param>
84+
/// <param name="memberId">ID of the member with which this bucket is associated.</param>
8585
/// <returns>Calculated bucket ID.</returns>
86-
private string GetBucketId(BaseContext ctx, out ulong userId, out ulong channelId, out ulong guildId)
86+
private string GetBucketId(BaseContext ctx, out ulong userId, out ulong channelId, out ulong guildId, out ulong memberId)
8787
{
8888
userId = 0ul;
8989
if ((this.BucketType & CooldownBucketType.User) != 0)
@@ -92,14 +92,16 @@ private string GetBucketId(BaseContext ctx, out ulong userId, out ulong channelI
9292
channelId = 0ul;
9393
if ((this.BucketType & CooldownBucketType.Channel) != 0)
9494
channelId = ctx.Channel.Id;
95-
if ((this.BucketType & CooldownBucketType.Guild) != 0 && ctx.Guild == null)
96-
channelId = ctx.Channel.Id;
9795

9896
guildId = 0ul;
99-
if (ctx.Guild != null && (this.BucketType & CooldownBucketType.Guild) != 0)
97+
if (ctx.Guild is not null && (this.BucketType & CooldownBucketType.Guild) != 0)
10098
guildId = ctx.Guild.Id;
10199

102-
var bid = CooldownBucket.MakeId(userId, channelId, guildId);
100+
memberId = 0ul;
101+
if (ctx.Guild is not null && ctx.Member is not null && (this.BucketType & CooldownBucketType.Member) != 0)
102+
memberId = ctx.Member.Id;
103+
104+
var bid = CooldownBucket.MakeId(ctx.FullCommandName, ctx.Interaction.Data.Id.ToString(CultureInfo.InvariantCulture), userId, channelId, guildId, memberId);
103105
return bid;
104106
}
105107

@@ -109,29 +111,36 @@ private string GetBucketId(BaseContext ctx, out ulong userId, out ulong channelI
109111
/// <param name="ctx">The command context.</param>
110112
public override async Task<bool> ExecuteChecksAsync(BaseContext ctx)
111113
{
112-
var bid = this.GetBucketId(ctx, out var usr, out var chn, out var gld);
113-
if (!this.Buckets.TryGetValue(bid, out var bucket))
114-
{
115-
bucket = new(this.MaxUses, this.Reset, usr, chn, gld);
116-
this.Buckets.AddOrUpdate(bid, bucket, (k, v) => bucket);
117-
}
114+
var bid = this.GetBucketId(ctx, out var usr, out var chn, out var gld, out var mem);
115+
if (ctx.Client.CommandCooldownBuckets.TryGetValue(bid, out var bucket))
116+
return await this.RespondRatelimitHitAsync(ctx, await bucket.DecrementUseAsync(ctx), bucket);
117+
118+
bucket = new(this.MaxUses, this.Reset, ctx.FullCommandName, ctx.Interaction.Data.Id.ToString(CultureInfo.InvariantCulture), usr, chn, gld, mem);
119+
ctx.Client.CommandCooldownBuckets.AddOrUpdate(bid, bucket, (k, v) => bucket);
118120

119-
return await bucket.DecrementUseAsync().ConfigureAwait(false);
121+
return await this.RespondRatelimitHitAsync(ctx, await bucket.DecrementUseAsync(ctx), bucket);
120122
}
121-
}
122123

123-
/// <summary>
124-
/// Represents a cooldown bucket for commands.
125-
/// </summary>
126-
public sealed class ContextMenuCooldownBucket : CooldownBucket
127-
{
128-
internal ContextMenuCooldownBucket(int maxUses, TimeSpan resetAfter, ulong userId = 0, ulong channelId = 0, ulong guildId = 0)
129-
: base(maxUses, resetAfter, userId, channelId, guildId)
130-
{ }
124+
/// <inheritdoc/>
125+
public async Task<bool> RespondRatelimitHitAsync(BaseContext ctx, bool noHit, CooldownBucket bucket)
126+
{
127+
if (noHit)
128+
return true;
131129

132-
/// <summary>
133-
/// Returns a string representation of this command cooldown bucket.
134-
/// </summary>
135-
/// <returns>String representation of this command cooldown bucket.</returns>
136-
public override string ToString() => $"Context Menu Command bucket {this.BucketId}";
130+
if (this.ResponderType is null)
131+
{
132+
if (ApplicationCommandsExtension.Configuration.AutoDefer)
133+
await ctx.EditResponseAsync(new DiscordWebhookBuilder().WithContent($"Error: Ratelimit hit\nTry again {bucket.ResetsAt.Timestamp()}"));
134+
else
135+
await ctx.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().WithContent($"Error: Ratelimit hit\nTry again {bucket.ResetsAt.Timestamp()}").AsEphemeral());
136+
137+
return false;
138+
}
139+
140+
var providerMethod = this.ResponderType.GetMethod(nameof(ICooldownResponder.Responder));
141+
var providerInstance = Activator.CreateInstance(this.ResponderType);
142+
await ((Task)providerMethod.Invoke(providerInstance, [ctx])).ConfigureAwait(false);
143+
144+
return false;
145+
}
137146
}

0 commit comments

Comments
 (0)