diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs index c7a7cf741..831a5a54c 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + namespace Discord { /// @@ -31,7 +36,10 @@ namespace Discord /// public bool IsDefaultPermission { get; set; } = true; + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + private string _name; + private Dictionary _nameLocalizations; /// /// Build the current builder into a class. @@ -73,5 +81,50 @@ namespace Discord IsDefaultPermission = isDefaultPermission; return this; } + + public MessageCommandBuilder WithNameLocalizations(IDictionary nameLocalizations) + { + if (nameLocalizations is null) + throw new ArgumentNullException(nameof(nameLocalizations)); + + foreach (var (locale, name) in nameLocalizations) + { + if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandName(name); + } + + _nameLocalizations = new Dictionary(nameLocalizations); + return this; + } + + public MessageCommandBuilder AddNameLocalization(string locale, string name) + { + if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandName(name); + + _nameLocalizations ??= new(); + _nameLocalizations.Add(locale, name); + + return this; + } + + internal static void EnsureValidCommandName(string name) + { + Preconditions.NotNullOrEmpty(name, nameof(name)); + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, MaxNameLength, nameof(name)); + + // Discord updated the docs, this regex prevents special characters like @!$%(... etc, + // https://discord.com/developers/docs/interactions/slash-commands#applicationcommand + if (!Regex.IsMatch(name, @"^[\w-]{1,32}$")) + throw new ArgumentException("Command name cannot contain any special characters or whitespaces!", nameof(name)); + + if (name.Any(x => char.IsUpper(x))) + throw new FormatException("Name cannot contain any uppercase characters."); + } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs index bd1078be3..4ae0c18e3 100644 --- a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + namespace Discord { /// @@ -5,7 +10,7 @@ namespace Discord /// public class UserCommandBuilder { - /// + /// /// Returns the maximum length a commands name allowed by Discord. /// public const int MaxNameLength = 32; @@ -31,7 +36,10 @@ namespace Discord /// public bool IsDefaultPermission { get; set; } = true; + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + private string _name; + private Dictionary _nameLocalizations; /// /// Build the current builder into a class. @@ -71,5 +79,50 @@ namespace Discord IsDefaultPermission = isDefaultPermission; return this; } + + public UserCommandBuilder WithNameLocalizations(IDictionary nameLocalizations) + { + if (nameLocalizations is null) + throw new ArgumentNullException(nameof(nameLocalizations)); + + foreach (var (locale, name) in nameLocalizations) + { + if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandName(name); + } + + _nameLocalizations = new Dictionary(nameLocalizations); + return this; + } + + public UserCommandBuilder AddNameLocalization(string locale, string name) + { + if(!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandName(name); + + _nameLocalizations ??= new(); + _nameLocalizations.Add(locale, name); + + return this; + } + + internal static void EnsureValidCommandName(string name) + { + Preconditions.NotNullOrEmpty(name, nameof(name)); + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, MaxNameLength, nameof(name)); + + // Discord updated the docs, this regex prevents special characters like @!$%(... etc, + // https://discord.com/developers/docs/interactions/slash-commands#applicationcommand + if (!Regex.IsMatch(name, @"^[\w-]{1,32}$")) + throw new ArgumentException("Command name cannot contain any special characters or whitespaces!", nameof(name)); + + if (name.Any(x => char.IsUpper(x))) + throw new FormatException("Name cannot contain any uppercase characters."); + } } } diff --git a/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs b/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs index 350cd51d6..808e53117 100644 --- a/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs +++ b/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs @@ -1,4 +1,4 @@ -namespace System.Collections.Generic; +namespace System.Collections.Generic; public static class GenericCollectionExtensions { diff --git a/src/Discord.Net.Interactions/ILocalizationManager.cs b/src/Discord.Net.Interactions/ILocalizationManager.cs deleted file mode 100644 index e56ada476..000000000 --- a/src/Discord.Net.Interactions/ILocalizationManager.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Discord.Interactions -{ - public interface ILocalizationManager - { - Task> GetAllNamesAsync(IList key, LocalizationTarget destinationType); - Task> GetAllDescriptionsAsync(IList key, LocalizationTarget destinationType); - } -} diff --git a/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs new file mode 100644 index 000000000..38a34d2d0 --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + public interface ILocalizationManager + { + IDictionary GetAllNames(IList key, LocalizationTarget destinationType); + IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType); + } +} diff --git a/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs index e841af256..652a845ff 100644 --- a/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs +++ b/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs @@ -23,16 +23,16 @@ namespace Discord.Interactions _fileName = fileName; } - public Task> GetAllDescriptionsAsync(IList key, LocalizationTarget destinationType) => - GetValuesAsync(key, DescriptionIdentifier); + public IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType) => + GetValues(key, DescriptionIdentifier); - public Task> GetAllNamesAsync(IList key, LocalizationTarget destinationType) => - GetValuesAsync(key, NameIdentifier); + public IDictionary GetAllNames(IList key, LocalizationTarget destinationType) => + GetValues(key, NameIdentifier); private string[] GetAllFiles() => Directory.GetFiles(_basePath, $"{_fileName}.*.json", SearchOption.TopDirectoryOnly); - private async Task> GetValuesAsync(IList key, string identifier) + private IDictionary GetValues(IList key, string identifier) { var result = new Dictionary(); var files = GetAllFiles(); @@ -47,7 +47,7 @@ namespace Discord.Interactions using var sr = new StreamReader(file); using var jr = new JsonTextReader(sr); - var obj = await JObject.LoadAsync(jr); + var obj = JObject.Load(jr); var token = string.Join(".", key) + $".{identifier}"; var value = (string)obj.SelectToken(token); if (value is not null) diff --git a/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs index f15b0d203..4dcb763f3 100644 --- a/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs +++ b/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs @@ -25,11 +25,11 @@ namespace Discord.Interactions _supportedLocales = supportedLocales; } - public Task> GetAllDescriptionsAsync(IList key, LocalizationTarget destinationType) => - Task.FromResult(GetValues(key, DescriptionIdentifier)); + public IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType) => + GetValues(key, DescriptionIdentifier); - public Task> GetAllNamesAsync(IList key, LocalizationTarget destinationType) => - Task.FromResult(GetValues(key, NameIdentifier)); + public IDictionary GetAllNames(IList key, LocalizationTarget destinationType) => + GetValues(key, NameIdentifier); private IDictionary GetValues(IList key, string identifier) { diff --git a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs index 46f0f4a4a..2f23f1fe2 100644 --- a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs +++ b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; namespace Discord.Interactions @@ -9,6 +10,9 @@ namespace Discord.Interactions #region Parameters public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandParameterInfo parameterInfo) { + var localizationManager = parameterInfo.Command.Module.CommandService._localizationManager; + var parameterPath = parameterInfo.GetParameterPath(); + var props = new ApplicationCommandOptionProperties { Name = parameterInfo.Name, @@ -23,7 +27,9 @@ namespace Discord.Interactions ChannelTypes = parameterInfo.ChannelTypes?.ToList(), IsAutocomplete = parameterInfo.IsAutocomplete, MaxValue = parameterInfo.MaxValue, - MinValue = parameterInfo.MinValue + MinValue = parameterInfo.MinValue, + NameLocalizations = localizationManager?.GetAllNames(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary.Empty, + DescriptionLocalizations = localizationManager?.GetAllDescriptions(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary.Empty }; parameterInfo.TypeConverter.Write(props, parameterInfo); @@ -36,12 +42,17 @@ namespace Discord.Interactions public static SlashCommandProperties ToApplicationCommandProps(this SlashCommandInfo commandInfo) { + var commandPath = commandInfo.GetCommandPath(); + var localizationManager = commandInfo.Module.CommandService._localizationManager; + var props = new SlashCommandBuilder() { Name = commandInfo.Name, Description = commandInfo.Description, IsDefaultPermission = commandInfo.DefaultPermission, - }.Build(); + }.WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty) + .WithDescriptionLocalizations(localizationManager?.GetAllDescriptions(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty) + .Build(); if (commandInfo.Parameters.Count > SlashCommandBuilder.MaxOptionsCount) throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters"); @@ -51,23 +62,40 @@ namespace Discord.Interactions return props; } - public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandInfo commandInfo) => - new ApplicationCommandOptionProperties + public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this SlashCommandInfo commandInfo) + { + var localizationManager = commandInfo.Module.CommandService._localizationManager; + var commandPath = commandInfo.GetCommandPath(); + + return new ApplicationCommandOptionProperties { Name = commandInfo.Name, Description = commandInfo.Description, Type = ApplicationCommandOptionType.SubCommand, IsRequired = false, - Options = commandInfo.FlattenedParameters?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() + Options = commandInfo.FlattenedParameters?.Select(x => x.ToApplicationCommandOptionProps()) + ?.ToList(), + NameLocalizations = localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty, + DescriptionLocalizations = localizationManager?.GetAllDescriptions(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty }; + } public static ApplicationCommandProperties ToApplicationCommandProps(this ContextCommandInfo commandInfo) - => commandInfo.CommandType switch + { + var localizationManager = commandInfo.Module.CommandService._localizationManager; + var commandPath = commandInfo.GetCommandPath(); + + return commandInfo.CommandType switch { - ApplicationCommandType.Message => new MessageCommandBuilder { Name = commandInfo.Name, IsDefaultPermission = commandInfo.DefaultPermission}.Build(), - ApplicationCommandType.User => new UserCommandBuilder { Name = commandInfo.Name, IsDefaultPermission=commandInfo.DefaultPermission}.Build(), + ApplicationCommandType.Message => new MessageCommandBuilder { Name = commandInfo.Name, IsDefaultPermission = commandInfo.DefaultPermission} + .WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty) + .Build(), + ApplicationCommandType.User => new UserCommandBuilder { Name = commandInfo.Name, IsDefaultPermission=commandInfo.DefaultPermission} + .WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty) + .Build(), _ => throw new InvalidOperationException($"{commandInfo.CommandType} isn't a supported command type.") }; + } #endregion #region Modules @@ -108,12 +136,18 @@ namespace Discord.Interactions options.AddRange(moduleInfo.SubModules?.SelectMany(x => x.ParseSubModule(args, ignoreDontRegister))); + var localizationManager = moduleInfo.CommandService._localizationManager; + var modulePath = moduleInfo.GetModulePath(); + var props = new SlashCommandBuilder { Name = moduleInfo.SlashGroupName, Description = moduleInfo.Description, IsDefaultPermission = moduleInfo.DefaultPermission, - }.Build(); + } + .WithNameLocalizations(localizationManager?.GetAllNames(modulePath, LocalizationTarget.Group) ?? ImmutableDictionary.Empty) + .WithDescriptionLocalizations(localizationManager?.GetAllDescriptions(modulePath, LocalizationTarget.Group) ?? ImmutableDictionary.Empty) + .Build(); if (options.Count > SlashCommandBuilder.MaxOptionsCount) throw new InvalidOperationException($"Slash Commands cannot have more than {SlashCommandBuilder.MaxOptionsCount} command parameters"); @@ -151,7 +185,11 @@ namespace Discord.Interactions Name = moduleInfo.SlashGroupName, Description = moduleInfo.Description, Type = ApplicationCommandOptionType.SubCommandGroup, - Options = options + Options = options, + NameLocalizations = moduleInfo.CommandService._localizationManager?.GetAllNames(moduleInfo.GetModulePath(), LocalizationTarget.Group) + ?? ImmutableDictionary.Empty, + DescriptionLocalizations = moduleInfo.CommandService._localizationManager?.GetAllDescriptions(moduleInfo.GetModulePath(), LocalizationTarget.Group) + ?? ImmutableDictionary.Empty, } }; } @@ -166,17 +204,23 @@ namespace Discord.Interactions Name = command.Name, Description = command.Description, IsDefaultPermission = command.IsDefaultPermission, - Options = command.Options?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional>.Unspecified + Options = command.Options?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional>.Unspecified, + NameLocalizations = command.NameLocalizations?.ToImmutableDictionary(), + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary(), }, ApplicationCommandType.User => new UserCommandProperties { Name = command.Name, - IsDefaultPermission = command.IsDefaultPermission + IsDefaultPermission = command.IsDefaultPermission, + NameLocalizations = command.NameLocalizations?.ToImmutableDictionary(), + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() }, ApplicationCommandType.Message => new MessageCommandProperties { Name = command.Name, - IsDefaultPermission = command.IsDefaultPermission + IsDefaultPermission = command.IsDefaultPermission, + NameLocalizations = command.NameLocalizations?.ToImmutableDictionary(), + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() }, _ => throw new InvalidOperationException($"Cannot create command properties for command type {command.Type}"), }; @@ -194,7 +238,9 @@ namespace Discord.Interactions Name = x.Name, Value = x.Value }).ToList(), - Options = commandOption.Options?.Select(x => x.ToApplicationCommandOptionProps()).ToList() + Options = commandOption.Options?.Select(x => x.ToApplicationCommandOptionProps()).ToList(), + NameLocalizations = commandOption.NameLocalizations?.ToImmutableDictionary(), + DescriptionLocalizations = commandOption.DescriptionLocalizations?.ToImmutableDictionary() }; } }