diff --git a/src/Color-Chan.Discord.Commands/Attributes/SlashCommandPermissionAttribute.cs b/src/Color-Chan.Discord.Commands/Attributes/SlashCommandPermissionAttribute.cs index c168358f..7293a313 100644 --- a/src/Color-Chan.Discord.Commands/Attributes/SlashCommandPermissionAttribute.cs +++ b/src/Color-Chan.Discord.Commands/Attributes/SlashCommandPermissionAttribute.cs @@ -17,12 +17,14 @@ public class SlashCommandPermissionAttribute : Attribute /// /// The id of the role or user. /// Specifies the type that the ID belongs to. - public SlashCommandPermissionAttribute(ulong id, DiscordApplicationCommandPermissionsType type) + /// Whether to allow the user/role to use the command. + public SlashCommandPermissionAttribute(ulong id, DiscordApplicationCommandPermissionsType type, bool allow = true) { Id = id; Type = type; + Allow = allow; } - + /// /// The id of the role or user.. /// @@ -32,5 +34,10 @@ public SlashCommandPermissionAttribute(ulong id, DiscordApplicationCommandPermis /// Specifies the type that the ID belongs to. /// public DiscordApplicationCommandPermissionsType Type { get; init; } + + /// + /// The id of the role or user.. + /// + public bool Allow { get; init; } = true; } } \ No newline at end of file diff --git a/src/Color-Chan.Discord.Commands/Extensions/DiscordGuildApplicationCommandPermissionsExtensions.cs b/src/Color-Chan.Discord.Commands/Extensions/DiscordGuildApplicationCommandPermissionsExtensions.cs new file mode 100644 index 00000000..07421e6f --- /dev/null +++ b/src/Color-Chan.Discord.Commands/Extensions/DiscordGuildApplicationCommandPermissionsExtensions.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; +using Color_Chan.Discord.Core.Common.API.Params.Application; +using Color_Chan.Discord.Core.Common.Models.Guild; + +namespace Color_Chan.Discord.Commands.Extensions +{ + internal static class DiscordGuildApplicationCommandPermissionsExtensions + { + internal static bool ShouldUpdatePermissions(this List localCommandPerms, + IReadOnlyList existingPerms) + { + foreach (var localCommandPerm in localCommandPerms) + { + var existingCommandPerm = existingPerms.FirstOrDefault(x => x.CommandId.Equals(localCommandPerm.CommandId)); + + if (existingCommandPerm is null) + { + // New command perms found. + return true; + } + + // Found existing perm. + + if (ContainsNewOrUpdatedPerm(localCommandPerm, existingCommandPerm)) + { + return true; + } + } + + foreach (var existingPerm in existingPerms) + { + var localCommandPerm = localCommandPerms.FirstOrDefault(x => x.CommandId.Equals(existingPerm.CommandId)); + + if (localCommandPerm is null) + { + // Deleted command perms found. + return true; + } + } + + return false; + } + + private static bool ContainsNewOrUpdatedPerm(DiscordBatchEditApplicationCommandPermissions localCommandPerm, IDiscordGuildApplicationCommandPermissions existingCommandPerm) + { + if (localCommandPerm.Permissions.Count() != existingCommandPerm.Permissions.Count()) + { + return true; + } + + foreach (var localPerm in localCommandPerm.Permissions) + { + var existingPerm = existingCommandPerm.Permissions.FirstOrDefault(x => x.Id == localPerm.Id); + + if (existingPerm is null) + { + return true; + } + + if (localPerm.Type != existingPerm.Type) + { + return true; + } + + if (localPerm.Allow != existingPerm.Allow) + { + return true; + } + } + + foreach (var existingPerm in existingCommandPerm.Permissions) + { + var localPerm = localCommandPerm.Permissions.FirstOrDefault(x => x.Id == existingPerm.Id); + + if (localPerm is null) + { + return true; + } + + if (existingPerm.Type != localPerm.Type) + { + return true; + } + + if (existingPerm.Allow != localPerm.Allow) + { + return true; + } + } + + return false; + } + } +} \ No newline at end of file diff --git a/src/Color-Chan.Discord.Commands/Services/Builders/ISlashCommandGuildBuildService.cs b/src/Color-Chan.Discord.Commands/Services/Builders/ISlashCommandGuildBuildService.cs index 10b49666..83dd9478 100644 --- a/src/Color-Chan.Discord.Commands/Services/Builders/ISlashCommandGuildBuildService.cs +++ b/src/Color-Chan.Discord.Commands/Services/Builders/ISlashCommandGuildBuildService.cs @@ -68,10 +68,10 @@ public interface ISlashCommandGuildBuildService /// /// Builds the from the data. /// - /// The s containing the information needed for the . + /// A dictionary where the are ordered by the command ID. /// /// The converted . /// - IEnumerable BuildGuildPermissions(IEnumerable commandInfos); + IEnumerable BuildGuildPermissions(Dictionary> attributePairs); } } \ No newline at end of file diff --git a/src/Color-Chan.Discord.Commands/Services/Implementations/Builders/SlashCommandGuildBuildService.cs b/src/Color-Chan.Discord.Commands/Services/Implementations/Builders/SlashCommandGuildBuildService.cs index e18ff8e9..7aa79808 100644 --- a/src/Color-Chan.Discord.Commands/Services/Implementations/Builders/SlashCommandGuildBuildService.cs +++ b/src/Color-Chan.Discord.Commands/Services/Implementations/Builders/SlashCommandGuildBuildService.cs @@ -3,8 +3,8 @@ using System.Linq; using System.Reflection; using Color_Chan.Discord.Commands.Attributes; -using Color_Chan.Discord.Commands.Info; using Color_Chan.Discord.Commands.Services.Builders; +using Color_Chan.Discord.Core.Common.API.DataModels.Application; using Color_Chan.Discord.Core.Common.API.Params.Application; using Microsoft.Extensions.Logging; @@ -60,7 +60,7 @@ public IEnumerable GetCommandGuildPermissions(M if (!GetCommandGuilds(command).Any() && attributes.Any()) { - _logger.LogWarning("Skipping slash permission for {CommandName}, they can not be used with global commands", command.Name); + _logger.LogWarning("Skipping slash command permission for {CommandName}, they can not be used with global commands", command.Name); return new List(); } @@ -78,7 +78,7 @@ public IEnumerable GetCommandGuildPermissions(T if (!GetCommandGuilds(commandModule).Any() && attributes.Any()) { - _logger.LogWarning("Skipping slash permission for {ModuleName}, they can not be used with global commands", commandModule.Name); + _logger.LogWarning("Skipping slash command permission for {ModuleName}, they can not be used with global commands", commandModule.Name); return new List(); } @@ -86,9 +86,31 @@ public IEnumerable GetCommandGuildPermissions(T } /// - public IEnumerable BuildGuildPermissions(IEnumerable commandInfos) + public IEnumerable BuildGuildPermissions(Dictionary> attributePairs) { - throw new NotImplementedException(); + var permBatch = new List(); + + foreach (var (commandId, attributes) in attributePairs) + { + var perms = new List(); + foreach (var attribute in attributes) + { + perms.Add(new DiscordApplicationCommandPermissionsData + { + Allow = true, + Id = attribute.Id, + Type = attribute.Type + }); + } + + permBatch.Add(new DiscordBatchEditApplicationCommandPermissions + { + CommandId = commandId, + Permissions = perms + }); + } + + return permBatch; } } } \ No newline at end of file diff --git a/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandAutoSyncService.cs b/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandAutoSyncService.cs index a5f8f332..0b82c55a 100644 --- a/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandAutoSyncService.cs +++ b/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandAutoSyncService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Color_Chan.Discord.Commands.Attributes; using Color_Chan.Discord.Commands.Configurations; using Color_Chan.Discord.Commands.Exceptions; using Color_Chan.Discord.Commands.Extensions; @@ -9,6 +10,7 @@ using Color_Chan.Discord.Commands.Services.Builders; using Color_Chan.Discord.Core; using Color_Chan.Discord.Core.Common.API.Rest; +using Color_Chan.Discord.Core.Common.Models.Application; using Color_Chan.Discord.Core.Results; using Microsoft.Extensions.Logging; @@ -20,6 +22,7 @@ public class SlashCommandAutoSyncService : ISlashCommandAutoSyncService private const int MaxGlobalCommands = 100; private const int MaxGuildCommands = 100; private readonly ISlashCommandBuildService _commandBuildService; + private readonly ISlashCommandGuildBuildService _guildBuildService; private readonly DiscordTokens _discordTokens; private readonly ILogger _logger; private readonly IDiscordRestApplication _restApplication; @@ -36,10 +39,14 @@ public class SlashCommandAutoSyncService : ISlashCommandAutoSyncService /// The that will be used to build the slash /// commands parameters. /// + /// + /// The that will be used to build the guild command permissions. + /// public SlashCommandAutoSyncService(IDiscordRestApplication restApplication, DiscordTokens discordTokens, ILogger logger, - ISlashCommandBuildService commandBuildService) + ISlashCommandBuildService commandBuildService, ISlashCommandGuildBuildService guildBuildService) { _commandBuildService = commandBuildService; + _guildBuildService = guildBuildService; _restApplication = restApplication; _discordTokens = discordTokens; _logger = logger; @@ -132,8 +139,10 @@ private async Task UpdateGuildCommandsAsync(IReadOnlyCollection(); + // Build the slash commands. - var guildCommandInfos = GetGuildCommandInfos(slashCommandInfos, guildId); + var guildCommandInfos = GetGuildCommandInfos(slashCommandInfos, guildId).ToList(); var guildCommands = _commandBuildService.BuildSlashCommandsParams(guildCommandInfos).ToList(); if (guildCommands.Count > MaxGuildCommands) throw new UpdateSlashCommandException($"A guild can not have more then {MaxGuildCommands} slash commands."); @@ -153,6 +162,8 @@ private async Task UpdateGuildCommandsAsync(IReadOnlyCollection UpdateGuildCommandsAsync(IReadOnlyCollection UpdateGuildCommandsAsync(IReadOnlyCollection x.Name).Contains(existingCommand.Name)) + if (guildCommands.Select(x => x.Name).Contains(existingCommand.Name)) { - // Delete old guild slash command. - var result = await _restApplication.DeleteGuildApplicationCommandAsync(_discordTokens.ApplicationId, guildId, existingCommand.Id).ConfigureAwait(false); - if (!result.IsSuccessful) return Result.FromError(existingCommands.ErrorResult ?? new ErrorResult("Failed to delete existing guild slash commands.")); + remainingCommands.Add(existingCommand); + continue; + } + + // Delete old guild slash command. + var result = await _restApplication.DeleteGuildApplicationCommandAsync(_discordTokens.ApplicationId, guildId, existingCommand.Id).ConfigureAwait(false); + if (!result.IsSuccessful) return Result.FromError(existingCommands.ErrorResult ?? new ErrorResult("Failed to delete existing guild slash commands.")); + + _logger.LogInformation("Deleted old guild slash command {CommandName} {Id}", existingCommand.Name, existingCommand.Id.ToString()); + } - _logger.LogInformation("Deleted old guild slash command {CommandName} {Id}", existingCommand.Name, existingCommand.Id.ToString()); + // Link local command perms with their command ID. + var localCommandPerms = new Dictionary>(); + foreach (var remainingCommand in remainingCommands) + { + var commandInfo = guildCommandInfos.FirstOrDefault(x => x.CommandName == remainingCommand.Name); + if (commandInfo is null) continue; + + localCommandPerms.Add(remainingCommand.Id, commandInfo.Permissions ?? new List()); + } + + // Get the existing command perms. + var existingCommandPerms = await _restApplication.GetGuildApplicationCommandPermissions(_discordTokens.ApplicationId, guildId).ConfigureAwait(false); + if (!existingCommandPerms.IsSuccessful) + { + return Result.FromError(existingCommands.ErrorResult ?? new ErrorResult($"Failed to get existing guild command permissions for guild {guildId.ToString()}")); + } + + // Only update the perms if needed. + var batchPerms = _guildBuildService.BuildGuildPermissions(localCommandPerms).ToList(); + if (batchPerms.ShouldUpdatePermissions(existingCommandPerms.Entity!)) + { + var batchPermResult = await _restApplication.BatchEditApplicationCommandPermissions(_discordTokens.ApplicationId, guildId, batchPerms).ConfigureAwait(false); + + if (!batchPermResult.IsSuccessful) + { + return Result.FromError(existingCommands.ErrorResult ?? new ErrorResult($"Failed batch edit guild command permissions for guild {guildId.ToString()}")); } + + _logger.LogInformation("Updated command permissions for guild {GuildId}", guildId.ToString()); } + + _logger.LogInformation("Finished syncing guild slash commands for guild {Id}", guildId.ToString()); } diff --git a/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandService.cs b/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandService.cs index f9c49c0d..0103e944 100644 --- a/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandService.cs +++ b/src/Color-Chan.Discord.Commands/Services/Implementations/SlashCommandService.cs @@ -56,17 +56,21 @@ public SlashCommandService(ILogger logger, ISlashCommandBui /// public async Task AddInteractionCommandsAsync(Assembly assembly) { - _logger.LogDebug("Loading interaction commands"); + _logger.LogDebug("Registering slash commands..."); // Build all commands in a specific assembly. var commandInfos = _slashCommandBuildService.BuildSlashCommandInfos(assembly); foreach (var (key, commandInfo) in commandInfos) { - if (!_slashCommands.TryAdd(key, commandInfo)) - throw new Exception($"Failed to register {commandInfo.CommandName}"); + if (_slashCommands.TryAdd(key, commandInfo)) continue; + + // The command already existed. + var registeringException = new Exception($"Failed to register {commandInfo.CommandName}"); + _logger.LogError(registeringException, "Can not register multiple commands with the same name"); + throw registeringException; } - _logger.LogInformation("Finished adding {Count} commands to the command registry", _slashCommands.Count.ToString()); + _logger.LogInformation("Registered {Count} slash commands to the command registry", _slashCommands.Count.ToString()); // Default config if no config was set. _configurations ??= new SlashCommandConfiguration(); diff --git a/src/Color-Chan.Discord.Core/Common/API/Params/Application/DiscordBatchEditApplicationCommandPermissions.cs b/src/Color-Chan.Discord.Core/Common/API/Params/Application/DiscordBatchEditApplicationCommandPermissions.cs index 8601d53c..a1679f78 100644 --- a/src/Color-Chan.Discord.Core/Common/API/Params/Application/DiscordBatchEditApplicationCommandPermissions.cs +++ b/src/Color-Chan.Discord.Core/Common/API/Params/Application/DiscordBatchEditApplicationCommandPermissions.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -using Color_Chan.Discord.Core.Common.Models.Application; +using Color_Chan.Discord.Core.Common.API.DataModels.Application; namespace Color_Chan.Discord.Core.Common.API.Params.Application { @@ -16,6 +16,6 @@ public class DiscordBatchEditApplicationCommandPermissions /// The permissions for the command in the guild. /// [JsonPropertyName("permissions")] - public IEnumerable Permissions { get; set; } = new List(); + public IEnumerable Permissions { get; set; } = new List(); } } \ No newline at end of file diff --git a/tests/Color-Chan.Discord.Commands.Tests/Services/Implementations/SlashCommandRequirementServiceTests.cs b/tests/Color-Chan.Discord.Commands.Tests/Services/Implementations/SlashCommandRequirementServiceTests.cs index b2e129bf..f3112a2e 100644 --- a/tests/Color-Chan.Discord.Commands.Tests/Services/Implementations/SlashCommandRequirementServiceTests.cs +++ b/tests/Color-Chan.Discord.Commands.Tests/Services/Implementations/SlashCommandRequirementServiceTests.cs @@ -30,7 +30,7 @@ public async Task ExecuteSlashCommandRequirementsAsync_should_get_one_error(int var module = typeof(ValidMockCommandModule1).GetTypeInfo(); var method = module.GetMethods()[methodIndex]; var requirementsAttributes = method.GetCustomAttributes(); - var commandInfo = new SlashCommandInfo("test", "desc", method, module) + var commandInfo = new SlashCommandInfo("test", "desc", false, method, module) { Requirements = requirementsAttributes };