diff --git a/src/Discord.Net.Commands/AssemblyInfo.cs b/src/Discord.Net.Commands/AssemblyInfo.cs new file mode 100644 index 0000000..bbbaca3 --- /dev/null +++ b/src/Discord.Net.Commands/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Discord.Net.Tests")] +[assembly: InternalsVisibleTo("Discord.Net.Tests.Unit")] diff --git a/src/Discord.Net.Commands/Attributes/AliasAttribute.cs b/src/Discord.Net.Commands/Attributes/AliasAttribute.cs new file mode 100644 index 0000000..c4b78f5 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/AliasAttribute.cs @@ -0,0 +1,41 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Marks the aliases for a command. + /// + /// + /// This attribute allows a command to have one or multiple aliases. In other words, the base command can have + /// multiple aliases when triggering the command itself, giving the end-user more freedom of choices when giving + /// hot-words to trigger the desired command. See the example for a better illustration. + /// + /// + /// In the following example, the command can be triggered with the base name, "stats", or either "stat" or + /// "info". + /// + /// [Command("stats")] + /// [Alias("stat", "info")] + /// public Task GetStatsAsync(IUser user) + /// { + /// // ...pull stats + /// } + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class AliasAttribute : Attribute + { + /// + /// Gets the aliases which have been defined for the command. + /// + public string[] Aliases { get; } + + /// + /// Creates a new with the given aliases. + /// + public AliasAttribute(params string[] aliases) + { + Aliases = aliases; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/CommandAttribute.cs b/src/Discord.Net.Commands/Attributes/CommandAttribute.cs new file mode 100644 index 0000000..dc680ed --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/CommandAttribute.cs @@ -0,0 +1,75 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Marks the execution information for a command. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class CommandAttribute : Attribute + { + /// + /// Gets the text that has been set to be recognized as a command. + /// + public string Text { get; } + /// + /// Specifies the of the command. This affects how the command is executed. + /// + public RunMode RunMode { get; set; } = RunMode.Default; + public bool? IgnoreExtraArgs { get; } + + /// + /// Attaches a summary to your command. + /// + /// + /// overrides the value of this property if present. + /// + public string Summary { get; set; } + + /// + /// Marks the aliases for a command. + /// + /// + /// extends the base value of this if present. + /// + public string[] Aliases { get; set; } + + /// + /// Attaches remarks to your commands. + /// + /// + /// overrides the value of this property if present. + /// + public string Remarks { get; set; } + + /// + public CommandAttribute() + { + Text = null; + } + + /// + /// Initializes a new attribute with the specified name. + /// + /// The name of the command. + public CommandAttribute(string text) + { + Text = text; + } + + public CommandAttribute(string text, bool ignoreExtraArgs) + { + Text = text; + IgnoreExtraArgs = ignoreExtraArgs; + } + + public CommandAttribute(string text, bool ignoreExtraArgs, string summary = default, string[] aliases = default, string remarks = default) + { + Text = text; + IgnoreExtraArgs = ignoreExtraArgs; + Summary = summary; + Aliases = aliases; + Remarks = remarks; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/DontAutoLoadAttribute.cs b/src/Discord.Net.Commands/Attributes/DontAutoLoadAttribute.cs new file mode 100644 index 0000000..7dbe1a4 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/DontAutoLoadAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Prevents the marked module from being loaded automatically. + /// + /// + /// This attribute tells to ignore the marked module from being loaded + /// automatically (e.g. the method). If a non-public module marked + /// with this attribute is attempted to be loaded manually, the loading process will also fail. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class DontAutoLoadAttribute : Attribute + { + } +} diff --git a/src/Discord.Net.Commands/Attributes/DontInjectAttribute.cs b/src/Discord.Net.Commands/Attributes/DontInjectAttribute.cs new file mode 100644 index 0000000..72ca92f --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/DontInjectAttribute.cs @@ -0,0 +1,31 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Prevents the marked property from being injected into a module. + /// + /// + /// This attribute prevents the marked member from being injected into its parent module. Useful when you have a + /// public property that you do not wish to invoke the library's dependency injection service. + /// + /// + /// In the following example, DatabaseService will not be automatically injected into the module and will + /// not throw an error message if the dependency fails to be resolved. + /// + /// public class MyModule : ModuleBase + /// { + /// [DontInject] + /// public DatabaseService DatabaseService; + /// public MyModule() + /// { + /// DatabaseService = DatabaseFactory.Generate(); + /// } + /// } + /// + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public class DontInjectAttribute : Attribute + { + } +} diff --git a/src/Discord.Net.Commands/Attributes/GroupAttribute.cs b/src/Discord.Net.Commands/Attributes/GroupAttribute.cs new file mode 100644 index 0000000..e1e38cf --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/GroupAttribute.cs @@ -0,0 +1,30 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Marks the module as a command group. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class GroupAttribute : Attribute + { + /// + /// Gets the prefix set for the module. + /// + public string Prefix { get; } + + /// + public GroupAttribute() + { + Prefix = null; + } + /// + /// Initializes a new with the provided prefix. + /// + /// The prefix of the module group. + public GroupAttribute(string prefix) + { + Prefix = prefix; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/NameAttribute.cs b/src/Discord.Net.Commands/Attributes/NameAttribute.cs new file mode 100644 index 0000000..a6e1f2e --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/NameAttribute.cs @@ -0,0 +1,26 @@ +using System; + +namespace Discord.Commands +{ + // Override public name of command/module + /// + /// Marks the public name of a command, module, or parameter. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class NameAttribute : Attribute + { + /// + /// Gets the name of the command. + /// + public string Text { get; } + + /// + /// Marks the public name of a command, module, or parameter with the provided name. + /// + /// The public name of the object. + public NameAttribute(string text) + { + Text = text; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/NamedArgumentTypeAttribute.cs b/src/Discord.Net.Commands/Attributes/NamedArgumentTypeAttribute.cs new file mode 100644 index 0000000..e857172 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/NamedArgumentTypeAttribute.cs @@ -0,0 +1,11 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Instructs the command system to treat command parameters of this type + /// as a collection of named arguments matching to its properties. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class NamedArgumentTypeAttribute : Attribute { } +} diff --git a/src/Discord.Net.Commands/Attributes/OverrideTypeReaderAttribute.cs b/src/Discord.Net.Commands/Attributes/OverrideTypeReaderAttribute.cs new file mode 100644 index 0000000..92d043c --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/OverrideTypeReaderAttribute.cs @@ -0,0 +1,50 @@ +using System; +using System.Reflection; + +namespace Discord.Commands +{ + /// + /// Marks the to be read by the specified . + /// + /// + /// This attribute will override the to be used when parsing for the + /// desired type in the command. This is useful when one wishes to use a particular + /// without affecting other commands that are using the same target + /// type. + /// + /// If the given type reader does not inherit from , an + /// will be thrown. + /// + /// + /// + /// In this example, the will be read by a custom + /// , FriendlyTimeSpanTypeReader, instead of the + /// shipped by Discord.Net. + /// + /// [Command("time")] + /// public Task GetTimeAsync([OverrideTypeReader(typeof(FriendlyTimeSpanTypeReader))]TimeSpan time) + /// => ReplyAsync(time); + /// + /// + [AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public sealed class OverrideTypeReaderAttribute : Attribute + { + private static readonly TypeInfo TypeReaderTypeInfo = typeof(TypeReader).GetTypeInfo(); + + /// + /// Gets the specified of the parameter. + /// + public Type TypeReader { get; } + + /// + /// The to be used with the parameter. + /// The given does not inherit from . + public OverrideTypeReaderAttribute(Type overridenTypeReader) + { + if (!TypeReaderTypeInfo.IsAssignableFrom(overridenTypeReader.GetTypeInfo())) + throw new ArgumentException($"{nameof(overridenTypeReader)} must inherit from {nameof(TypeReader)}."); + + TypeReader = overridenTypeReader; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/ParameterPreconditionAttribute.cs b/src/Discord.Net.Commands/Attributes/ParameterPreconditionAttribute.cs new file mode 100644 index 0000000..8ee46f9 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/ParameterPreconditionAttribute.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Requires the parameter to pass the specified precondition before execution can begin. + /// + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] + public abstract class ParameterPreconditionAttribute : Attribute + { + /// + /// Checks whether the condition is met before execution of the command. + /// + /// The context of the command. + /// The parameter of the command being checked against. + /// The raw value of the parameter. + /// The service collection used for dependency injection. + public abstract Task CheckPermissionsAsync(ICommandContext context, ParameterInfo parameter, object value, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Commands/Attributes/PreconditionAttribute.cs b/src/Discord.Net.Commands/Attributes/PreconditionAttribute.cs new file mode 100644 index 0000000..8abd4d3 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/PreconditionAttribute.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Requires the module or class to pass the specified precondition before execution can begin. + /// + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public abstract class PreconditionAttribute : Attribute + { + /// + /// Specifies a group that this precondition belongs to. + /// + /// + /// of the same group require only one of the preconditions to pass in order to + /// be successful (A || B). Specifying = or not at all will + /// require *all* preconditions to pass, just like normal (A && B). + /// + public string Group { get; set; } = null; + + /// + /// When overridden in a derived class, uses the supplied string + /// as the error message if the precondition doesn't pass. + /// Setting this for a class that doesn't override + /// this property is a no-op. + /// + public virtual string ErrorMessage { get { return null; } set { } } + + /// + /// Checks if the has the sufficient permission to be executed. + /// + /// The context of the command. + /// The command being executed. + /// The service collection used for dependency injection. + public abstract Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Commands/Attributes/Preconditions/RequireBotPermissionAttribute.cs b/src/Discord.Net.Commands/Attributes/Preconditions/RequireBotPermissionAttribute.cs new file mode 100644 index 0000000..5b3b5bd --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/Preconditions/RequireBotPermissionAttribute.cs @@ -0,0 +1,86 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Requires the bot to have a specific permission in the channel a command is invoked in. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireBotPermissionAttribute : PreconditionAttribute + { + /// + /// Gets the specified of the precondition. + /// + public GuildPermission? GuildPermission { get; } + /// + /// Gets the specified of the precondition. + /// + public ChannelPermission? ChannelPermission { get; } + /// + public override string ErrorMessage { get; set; } + /// + /// Gets or sets the error message if the precondition + /// fails due to being run outside of a Guild channel. + /// + public string NotAGuildErrorMessage { get; set; } + + /// + /// Requires the bot account to have a specific . + /// + /// + /// This precondition will always fail if the command is being invoked in a . + /// + /// + /// The that the bot must have. Multiple permissions can be specified + /// by ORing the permissions together. + /// + public RequireBotPermissionAttribute(GuildPermission permission) + { + GuildPermission = permission; + ChannelPermission = null; + } + /// + /// Requires that the bot account to have a specific . + /// + /// + /// The that the bot must have. Multiple permissions can be + /// specified by ORing the permissions together. + /// + public RequireBotPermissionAttribute(ChannelPermission permission) + { + ChannelPermission = permission; + GuildPermission = null; + } + + /// + public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + IGuildUser guildUser = null; + if (context.Guild != null) + guildUser = await context.Guild.GetCurrentUserAsync().ConfigureAwait(false); + + if (GuildPermission.HasValue) + { + if (guildUser == null) + return PreconditionResult.FromError(NotAGuildErrorMessage ?? "Command must be used in a guild channel."); + if (!guildUser.GuildPermissions.Has(GuildPermission.Value)) + return PreconditionResult.FromError(ErrorMessage ?? $"Bot requires guild permission {GuildPermission.Value}."); + } + + if (ChannelPermission.HasValue) + { + ChannelPermissions perms; + if (context.Channel is IGuildChannel guildChannel) + perms = guildUser.GetPermissions(guildChannel); + else + perms = ChannelPermissions.All(context.Channel); + + if (!perms.Has(ChannelPermission.Value)) + return PreconditionResult.FromError(ErrorMessage ?? $"Bot requires channel permission {ChannelPermission.Value}."); + } + + return PreconditionResult.FromSuccess(); + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/Preconditions/RequireContextAttribute.cs b/src/Discord.Net.Commands/Attributes/Preconditions/RequireContextAttribute.cs new file mode 100644 index 0000000..a27469c --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/Preconditions/RequireContextAttribute.cs @@ -0,0 +1,74 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Defines the type of command context (i.e. where the command is being executed). + /// + [Flags] + public enum ContextType + { + /// + /// Specifies the command to be executed within a guild. + /// + Guild = 0x01, + /// + /// Specifies the command to be executed within a DM. + /// + DM = 0x02, + /// + /// Specifies the command to be executed within a group. + /// + Group = 0x04 + } + + /// + /// Requires the command to be invoked in a specified context (e.g. in guild, DM). + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireContextAttribute : PreconditionAttribute + { + /// + /// Gets the context required to execute the command. + /// + public ContextType Contexts { get; } + /// + public override string ErrorMessage { get; set; } + + /// Requires the command to be invoked in the specified context. + /// The type of context the command can be invoked in. Multiple contexts can be specified by ORing the contexts together. + /// + /// + /// [Command("secret")] + /// [RequireContext(ContextType.DM | ContextType.Group)] + /// public Task PrivateOnlyAsync() + /// { + /// return ReplyAsync("shh, this command is a secret"); + /// } + /// + /// + public RequireContextAttribute(ContextType contexts) + { + Contexts = contexts; + } + + /// + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + bool isValid = false; + + if ((Contexts & ContextType.Guild) != 0) + isValid = context.Channel is IGuildChannel; + if ((Contexts & ContextType.DM) != 0) + isValid = isValid || context.Channel is IDMChannel; + if ((Contexts & ContextType.Group) != 0) + isValid = isValid || context.Channel is IGroupChannel; + + if (isValid) + return Task.FromResult(PreconditionResult.FromSuccess()); + else + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"Invalid context for command; accepted contexts: {Contexts}.")); + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/Preconditions/RequireNsfwAttribute.cs b/src/Discord.Net.Commands/Attributes/Preconditions/RequireNsfwAttribute.cs new file mode 100644 index 0000000..2a9647c --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/Preconditions/RequireNsfwAttribute.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Requires the command to be invoked in a channel marked NSFW. + /// + /// + /// The precondition will restrict the access of the command or module to be accessed within a guild channel + /// that has been marked as mature or NSFW. If the channel is not of type or the + /// channel is not marked as NSFW, the precondition will fail with an erroneous . + /// + /// + /// The following example restricts the command too-cool to an NSFW-enabled channel only. + /// + /// public class DankModule : ModuleBase + /// { + /// [Command("cool")] + /// public Task CoolAsync() + /// => ReplyAsync("I'm cool for everyone."); + /// + /// [RequireNsfw] + /// [Command("too-cool")] + /// public Task TooCoolAsync() + /// => ReplyAsync("You can only see this if you're cool enough."); + /// } + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireNsfwAttribute : PreconditionAttribute + { + /// + public override string ErrorMessage { get; set; } + + /// + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + if (context.Channel is ITextChannel text && text.IsNsfw) + return Task.FromResult(PreconditionResult.FromSuccess()); + else + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? "This command may only be invoked in an NSFW channel.")); + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/Preconditions/RequireOwnerAttribute.cs b/src/Discord.Net.Commands/Attributes/Preconditions/RequireOwnerAttribute.cs new file mode 100644 index 0000000..c08e1e9 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/Preconditions/RequireOwnerAttribute.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Requires the command to be invoked by the owner of the bot. + /// + /// + /// This precondition will restrict the access of the command or module to the owner of the Discord application. + /// If the precondition fails to be met, an erroneous will be returned with the + /// message "Command can only be run by the owner of the bot." + /// + /// This precondition will only work if the account has a of + /// ;otherwise, this precondition will always fail. + /// + /// + /// + /// The following example restricts the command to a set of sensitive commands that only the owner of the bot + /// application should be able to access. + /// + /// [RequireOwner] + /// [Group("admin")] + /// public class AdminModule : ModuleBase + /// { + /// [Command("exit")] + /// public async Task ExitAsync() + /// { + /// Environment.Exit(0); + /// } + /// } + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireOwnerAttribute : PreconditionAttribute + { + /// + public override string ErrorMessage { get; set; } + + /// + public override async Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + switch (context.Client.TokenType) + { + case TokenType.Bot: + var application = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + if (context.User.Id != application.Owner.Id) + return PreconditionResult.FromError(ErrorMessage ?? "Command can only be run by the owner of the bot."); + return PreconditionResult.FromSuccess(); + default: + return PreconditionResult.FromError($"{nameof(RequireOwnerAttribute)} is not supported by this {nameof(TokenType)}."); + } + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/Preconditions/RequireUserPermissionAttribute.cs b/src/Discord.Net.Commands/Attributes/Preconditions/RequireUserPermissionAttribute.cs new file mode 100644 index 0000000..8e3091f --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/Preconditions/RequireUserPermissionAttribute.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Requires the user invoking the command to have a specified permission. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireUserPermissionAttribute : PreconditionAttribute + { + /// + /// Gets the specified of the precondition. + /// + public GuildPermission? GuildPermission { get; } + /// + /// Gets the specified of the precondition. + /// + public ChannelPermission? ChannelPermission { get; } + /// + public override string ErrorMessage { get; set; } + /// + /// Gets or sets the error message if the precondition + /// fails due to being run outside of a Guild channel. + /// + public string NotAGuildErrorMessage { get; set; } + + /// + /// Requires that the user invoking the command to have a specific . + /// + /// + /// This precondition will always fail if the command is being invoked in a . + /// + /// + /// The that the user must have. Multiple permissions can be + /// specified by ORing the permissions together. + /// + public RequireUserPermissionAttribute(GuildPermission permission) + { + GuildPermission = permission; + ChannelPermission = null; + } + /// + /// Requires that the user invoking the command to have a specific . + /// + /// + /// The that the user must have. Multiple permissions can be + /// specified by ORing the permissions together. + /// + public RequireUserPermissionAttribute(ChannelPermission permission) + { + ChannelPermission = permission; + GuildPermission = null; + } + + /// + public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) + { + var guildUser = context.User as IGuildUser; + + if (GuildPermission.HasValue) + { + if (guildUser == null) + return Task.FromResult(PreconditionResult.FromError(NotAGuildErrorMessage ?? "Command must be used in a guild channel.")); + if (!guildUser.GuildPermissions.Has(GuildPermission.Value)) + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"User requires guild permission {GuildPermission.Value}.")); + } + + if (ChannelPermission.HasValue) + { + ChannelPermissions perms; + if (context.Channel is IGuildChannel guildChannel) + perms = guildUser.GetPermissions(guildChannel); + else + perms = ChannelPermissions.All(context.Channel); + + if (!perms.Has(ChannelPermission.Value)) + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"User requires channel permission {ChannelPermission.Value}.")); + } + + return Task.FromResult(PreconditionResult.FromSuccess()); + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/PriorityAttribute.cs b/src/Discord.Net.Commands/Attributes/PriorityAttribute.cs new file mode 100644 index 0000000..75ffd25 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/PriorityAttribute.cs @@ -0,0 +1,24 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Sets priority of commands. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class PriorityAttribute : Attribute + { + /// + /// Gets the priority which has been set for the command. + /// + public int Priority { get; } + + /// + /// Initializes a new attribute with the given priority. + /// + public PriorityAttribute(int priority) + { + Priority = priority; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/RemainderAttribute.cs b/src/Discord.Net.Commands/Attributes/RemainderAttribute.cs new file mode 100644 index 0000000..33e07f0 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/RemainderAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Marks the input to not be parsed by the parser. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class RemainderAttribute : Attribute + { + } +} diff --git a/src/Discord.Net.Commands/Attributes/RemarksAttribute.cs b/src/Discord.Net.Commands/Attributes/RemarksAttribute.cs new file mode 100644 index 0000000..2fbe2bf --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/RemarksAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace Discord.Commands +{ + // Extension of the Cosmetic Summary, for Groups, Commands, and Parameters + /// + /// Attaches remarks to your commands. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class RemarksAttribute : Attribute + { + public string Text { get; } + + public RemarksAttribute(string text) + { + Text = text; + } + } +} diff --git a/src/Discord.Net.Commands/Attributes/SummaryAttribute.cs b/src/Discord.Net.Commands/Attributes/SummaryAttribute.cs new file mode 100644 index 0000000..57e9b02 --- /dev/null +++ b/src/Discord.Net.Commands/Attributes/SummaryAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace Discord.Commands +{ + // Cosmetic Summary, for Groups and Commands + /// + /// Attaches a summary to your command. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class SummaryAttribute : Attribute + { + public string Text { get; } + + public SummaryAttribute(string text) + { + Text = text; + } + } +} diff --git a/src/Discord.Net.Commands/Builders/CommandBuilder.cs b/src/Discord.Net.Commands/Builders/CommandBuilder.cs new file mode 100644 index 0000000..80c71c2 --- /dev/null +++ b/src/Discord.Net.Commands/Builders/CommandBuilder.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands.Builders +{ + public class CommandBuilder + { + #region CommandBuilder + private readonly List _preconditions; + private readonly List _parameters; + private readonly List _attributes; + private readonly List _aliases; + + public ModuleBuilder Module { get; } + internal Func Callback { get; set; } + + public string Name { get; set; } + public string Summary { get; set; } + public string Remarks { get; set; } + public string PrimaryAlias { get; set; } + public RunMode RunMode { get; set; } + public int Priority { get; set; } + public bool IgnoreExtraArgs { get; set; } + + public IReadOnlyList Preconditions => _preconditions; + public IReadOnlyList Parameters => _parameters; + public IReadOnlyList Attributes => _attributes; + public IReadOnlyList Aliases => _aliases; + #endregion + + #region Automatic + internal CommandBuilder(ModuleBuilder module) + { + Module = module; + + _preconditions = new List(); + _parameters = new List(); + _attributes = new List(); + _aliases = new List(); + } + #endregion + + #region User-defined + internal CommandBuilder(ModuleBuilder module, string primaryAlias, Func callback) + : this(module) + { + Discord.Preconditions.NotNull(primaryAlias, nameof(primaryAlias)); + Discord.Preconditions.NotNull(callback, nameof(callback)); + + Callback = callback; + PrimaryAlias = primaryAlias; + _aliases.Add(primaryAlias); + } + + public CommandBuilder WithName(string name) + { + Name = name; + return this; + } + public CommandBuilder WithSummary(string summary) + { + Summary = summary; + return this; + } + public CommandBuilder WithRemarks(string remarks) + { + Remarks = remarks; + return this; + } + public CommandBuilder WithRunMode(RunMode runMode) + { + RunMode = runMode; + return this; + } + public CommandBuilder WithPriority(int priority) + { + Priority = priority; + return this; + } + + public CommandBuilder AddAliases(params string[] aliases) + { + for (int i = 0; i < aliases.Length; i++) + { + string alias = aliases[i] ?? ""; + if (!_aliases.Contains(alias)) + _aliases.Add(alias); + } + return this; + } + public CommandBuilder AddAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return this; + } + public CommandBuilder AddPrecondition(PreconditionAttribute precondition) + { + _preconditions.Add(precondition); + return this; + } + public CommandBuilder AddParameter(string name, Action createFunc) + { + var param = new ParameterBuilder(this, name, typeof(T)); + createFunc(param); + _parameters.Add(param); + return this; + } + public CommandBuilder AddParameter(string name, Type type, Action createFunc) + { + var param = new ParameterBuilder(this, name, type); + createFunc(param); + _parameters.Add(param); + return this; + } + internal CommandBuilder AddParameter(Action createFunc) + { + var param = new ParameterBuilder(this); + createFunc(param); + _parameters.Add(param); + return this; + } + + /// Only the last parameter in a command may have the Remainder or Multiple flag. + internal CommandInfo Build(ModuleInfo info, CommandService service) + { + //Default name to primary alias + if (Name == null) + Name = PrimaryAlias; + + if (_parameters.Count > 0) + { + var lastParam = _parameters[_parameters.Count - 1]; + + var firstMultipleParam = _parameters.FirstOrDefault(x => x.IsMultiple); + if ((firstMultipleParam != null) && (firstMultipleParam != lastParam)) + throw new InvalidOperationException($"Only the last parameter in a command may have the Multiple flag. Parameter: {firstMultipleParam.Name} in {PrimaryAlias}"); + + var firstRemainderParam = _parameters.FirstOrDefault(x => x.IsRemainder); + if ((firstRemainderParam != null) && (firstRemainderParam != lastParam)) + throw new InvalidOperationException($"Only the last parameter in a command may have the Remainder flag. Parameter: {firstRemainderParam.Name} in {PrimaryAlias}"); + } + + return new CommandInfo(this, info, service); + } + #endregion + } +} diff --git a/src/Discord.Net.Commands/Builders/ModuleBuilder.cs b/src/Discord.Net.Commands/Builders/ModuleBuilder.cs new file mode 100644 index 0000000..e8e15b1 --- /dev/null +++ b/src/Discord.Net.Commands/Builders/ModuleBuilder.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Commands.Builders +{ + public class ModuleBuilder + { + #region ModuleBuilder + private string _group; + private readonly List _commands; + private readonly List _submodules; + private readonly List _preconditions; + private readonly List _attributes; + private readonly List _aliases; + + public CommandService Service { get; } + public ModuleBuilder Parent { get; } + public string Name { get; set; } + public string Summary { get; set; } + public string Remarks { get; set; } + public string Group + { + get => _group; + set + { + _aliases.Remove(_group); + _group = value; + AddAliases(value); + } + } + + public IReadOnlyList Commands => _commands; + public IReadOnlyList Modules => _submodules; + public IReadOnlyList Preconditions => _preconditions; + public IReadOnlyList Attributes => _attributes; + public IReadOnlyList Aliases => _aliases; + + internal TypeInfo TypeInfo { get; set; } + #endregion + + #region Automatic + internal ModuleBuilder(CommandService service, ModuleBuilder parent) + { + Service = service; + Parent = parent; + + _commands = new List(); + _submodules = new List(); + _preconditions = new List(); + _attributes = new List(); + _aliases = new List(); + } + #endregion + + #region User-defined + internal ModuleBuilder(CommandService service, ModuleBuilder parent, string primaryAlias) + : this(service, parent) + { + Discord.Preconditions.NotNull(primaryAlias, nameof(primaryAlias)); + + _aliases = new List { primaryAlias }; + } + + public ModuleBuilder WithName(string name) + { + Name = name; + return this; + } + public ModuleBuilder WithSummary(string summary) + { + Summary = summary; + return this; + } + public ModuleBuilder WithRemarks(string remarks) + { + Remarks = remarks; + return this; + } + + public ModuleBuilder AddAliases(params string[] aliases) + { + for (int i = 0; i < aliases.Length; i++) + { + string alias = aliases[i] ?? ""; + if (!_aliases.Contains(alias)) + _aliases.Add(alias); + } + return this; + } + public ModuleBuilder AddAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return this; + } + public ModuleBuilder AddPrecondition(PreconditionAttribute precondition) + { + _preconditions.Add(precondition); + return this; + } + public ModuleBuilder AddCommand(string primaryAlias, Func callback, Action createFunc) + { + var builder = new CommandBuilder(this, primaryAlias, callback); + createFunc(builder); + _commands.Add(builder); + return this; + } + internal ModuleBuilder AddCommand(Action createFunc) + { + var builder = new CommandBuilder(this); + createFunc(builder); + _commands.Add(builder); + return this; + } + public ModuleBuilder AddModule(string primaryAlias, Action createFunc) + { + var builder = new ModuleBuilder(Service, this, primaryAlias); + createFunc(builder); + _submodules.Add(builder); + return this; + } + internal ModuleBuilder AddModule(Action createFunc) + { + var builder = new ModuleBuilder(Service, this); + createFunc(builder); + _submodules.Add(builder); + return this; + } + + private ModuleInfo BuildImpl(CommandService service, IServiceProvider services, ModuleInfo parent = null) + { + //Default name to first alias + if (Name == null) + Name = _aliases[0]; + + if (TypeInfo != null && !TypeInfo.IsAbstract) + { + var moduleInstance = ReflectionUtils.CreateObject(TypeInfo, service, services); + moduleInstance.OnModuleBuilding(service, this); + } + + return new ModuleInfo(this, service, services, parent); + } + + public ModuleInfo Build(CommandService service, IServiceProvider services) => BuildImpl(service, services); + + internal ModuleInfo Build(CommandService service, IServiceProvider services, ModuleInfo parent) => BuildImpl(service, services, parent); + #endregion + } +} diff --git a/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs b/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs new file mode 100644 index 0000000..14a40d1 --- /dev/null +++ b/src/Discord.Net.Commands/Builders/ModuleClassBuilder.cs @@ -0,0 +1,319 @@ +using Discord.Commands.Builders; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal static class ModuleClassBuilder + { + private static readonly TypeInfo ModuleTypeInfo = typeof(IModuleBase).GetTypeInfo(); + + public static async Task> SearchAsync(Assembly assembly, CommandService service) + { + bool IsLoadableModule(TypeInfo info) + { + return info.DeclaredMethods.Any(x => x.GetCustomAttribute() != null) && + info.GetCustomAttribute() == null; + } + + var result = new List(); + + foreach (var typeInfo in assembly.DefinedTypes) + { + if (typeInfo.IsPublic || typeInfo.IsNestedPublic) + { + if (IsValidModuleDefinition(typeInfo) && + !typeInfo.IsDefined(typeof(DontAutoLoadAttribute))) + { + result.Add(typeInfo); + } + } + else if (IsLoadableModule(typeInfo)) + { + await service._cmdLogger.WarningAsync($"Class {typeInfo.FullName} is not public and cannot be loaded. To suppress this message, mark the class with {nameof(DontAutoLoadAttribute)}.").ConfigureAwait(false); + } + } + + return result; + } + + + public static Task> BuildAsync(CommandService service, IServiceProvider services, params TypeInfo[] validTypes) => BuildAsync(validTypes, service, services); + public static async Task> BuildAsync(IEnumerable validTypes, CommandService service, IServiceProvider services) + { + /*if (!validTypes.Any()) + throw new InvalidOperationException("Could not find any valid modules from the given selection");*/ + + var topLevelGroups = validTypes.Where(x => x.DeclaringType == null || !IsValidModuleDefinition(x.DeclaringType.GetTypeInfo())); + + var builtTypes = new List(); + + var result = new Dictionary(); + + foreach (var typeInfo in topLevelGroups) + { + // TODO: This shouldn't be the case; may be safe to remove? + if (result.ContainsKey(typeInfo.AsType())) + continue; + + var module = new ModuleBuilder(service, null); + + BuildModule(module, typeInfo, service, services); + BuildSubTypes(module, typeInfo.DeclaredNestedTypes, builtTypes, service, services); + builtTypes.Add(typeInfo); + + result[typeInfo.AsType()] = module.Build(service, services); + } + + await service._cmdLogger.DebugAsync($"Successfully built {builtTypes.Count} modules.").ConfigureAwait(false); + + return result; + } + + private static void BuildSubTypes(ModuleBuilder builder, IEnumerable subTypes, List builtTypes, CommandService service, IServiceProvider services) + { + foreach (var typeInfo in subTypes) + { + if (!IsValidModuleDefinition(typeInfo)) + continue; + + if (builtTypes.Contains(typeInfo)) + continue; + + builder.AddModule((module) => + { + BuildModule(module, typeInfo, service, services); + BuildSubTypes(module, typeInfo.DeclaredNestedTypes, builtTypes, service, services); + }); + + builtTypes.Add(typeInfo); + } + } + + private static void BuildModule(ModuleBuilder builder, TypeInfo typeInfo, CommandService service, IServiceProvider services) + { + var attributes = typeInfo.GetCustomAttributes(); + builder.TypeInfo = typeInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case NameAttribute name: + builder.Name = name.Text; + break; + case SummaryAttribute summary: + builder.Summary = summary.Text; + break; + case RemarksAttribute remarks: + builder.Remarks = remarks.Text; + break; + case AliasAttribute alias: + builder.AddAliases(alias.Aliases); + break; + case GroupAttribute group: + builder.Name ??= group.Prefix; + builder.Group = group.Prefix; + break; + case PreconditionAttribute precondition: + builder.AddPrecondition(precondition); + break; + default: + builder.AddAttributes(attribute); + break; + } + } + + //Check for unspecified info + if (builder.Aliases.Count == 0) + builder.AddAliases(""); + if (builder.Name == null) + builder.Name = typeInfo.Name; + + // Get all methods (including from inherited members), that are valid commands + var validCommands = typeInfo.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Where(IsValidCommandDefinition); + + foreach (var method in validCommands) + { + builder.AddCommand((command) => + { + BuildCommand(command, typeInfo, method, service, services); + }); + } + } + + private static void BuildCommand(CommandBuilder builder, TypeInfo typeInfo, MethodInfo method, CommandService service, IServiceProvider serviceprovider) + { + var attributes = method.GetCustomAttributes(); + + foreach (var attribute in attributes) + { + switch (attribute) + { + case CommandAttribute command: + builder.Summary ??= command.Summary; + builder.Remarks ??= command.Remarks; + builder.AddAliases(command.Aliases ?? Array.Empty()); + builder.AddAliases(command.Text); + builder.RunMode = command.RunMode; + builder.Name ??= command.Text; + builder.IgnoreExtraArgs = command.IgnoreExtraArgs ?? service._ignoreExtraArgs; + break; + case NameAttribute name: + builder.Name = name.Text; + break; + case PriorityAttribute priority: + builder.Priority = priority.Priority; + break; + case SummaryAttribute summary: + builder.Summary = summary.Text; + break; + case RemarksAttribute remarks: + builder.Remarks = remarks.Text; + break; + case AliasAttribute alias: + builder.AddAliases(alias.Aliases); + break; + case PreconditionAttribute precondition: + builder.AddPrecondition(precondition); + break; + default: + builder.AddAttributes(attribute); + break; + } + } + + if (builder.Name == null) + builder.Name = method.Name; + + var parameters = method.GetParameters(); + int pos = 0, count = parameters.Length; + foreach (var paramInfo in parameters) + { + builder.AddParameter((parameter) => + { + BuildParameter(parameter, paramInfo, pos++, count, service, serviceprovider); + }); + } + + var createInstance = ReflectionUtils.CreateBuilder(typeInfo, service); + + async Task ExecuteCallback(ICommandContext context, object[] args, IServiceProvider services, CommandInfo cmd) + { + var instance = createInstance(services); + instance.SetContext(context); + + try + { + await instance.BeforeExecuteAsync(cmd).ConfigureAwait(false); + instance.BeforeExecute(cmd); + + var task = method.Invoke(instance, args) as Task ?? Task.Delay(0); + if (task is Task resultTask) + { + return await resultTask.ConfigureAwait(false); + } + else + { + await task.ConfigureAwait(false); + return ExecuteResult.FromSuccess(); + } + } + finally + { + await instance.AfterExecuteAsync(cmd).ConfigureAwait(false); + instance.AfterExecute(cmd); + (instance as IDisposable)?.Dispose(); + } + } + + builder.Callback = ExecuteCallback; + } + + private static void BuildParameter(ParameterBuilder builder, System.Reflection.ParameterInfo paramInfo, int position, int count, CommandService service, IServiceProvider services) + { + var attributes = paramInfo.GetCustomAttributes(); + var paramType = paramInfo.ParameterType; + + builder.Name = paramInfo.Name; + + builder.IsOptional = paramInfo.IsOptional; + builder.DefaultValue = paramInfo.HasDefaultValue ? paramInfo.DefaultValue : null; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case SummaryAttribute summary: + builder.Summary = summary.Text; + break; + case OverrideTypeReaderAttribute typeReader: + builder.TypeReader = GetTypeReader(service, paramType, typeReader.TypeReader, services); + break; + case ParamArrayAttribute _: + builder.IsMultiple = true; + paramType = paramType.GetElementType(); + break; + case ParameterPreconditionAttribute precon: + builder.AddPrecondition(precon); + break; + case NameAttribute name: + builder.Name = name.Text; + break; + case RemainderAttribute _: + if (position != count - 1) + throw new InvalidOperationException($"Remainder parameters must be the last parameter in a command. Parameter: {paramInfo.Name} in {paramInfo.Member.DeclaringType.Name}.{paramInfo.Member.Name}"); + + builder.IsRemainder = true; + break; + default: + builder.AddAttributes(attribute); + break; + } + } + + builder.ParameterType = paramType; + + if (builder.TypeReader == null) + { + builder.TypeReader = service.GetDefaultTypeReader(paramType) + ?? service.GetTypeReaders(paramType)?.FirstOrDefault().Value; + } + } + + internal static TypeReader GetTypeReader(CommandService service, Type paramType, Type typeReaderType, IServiceProvider services) + { + var readers = service.GetTypeReaders(paramType); + TypeReader reader = null; + if (readers != null) + { + if (readers.TryGetValue(typeReaderType, out reader)) + return reader; + } + + //We don't have a cached type reader, create one + reader = ReflectionUtils.CreateObject(typeReaderType.GetTypeInfo(), service, services); + service.AddTypeReader(paramType, reader, false); + + return reader; + } + + private static bool IsValidModuleDefinition(TypeInfo typeInfo) + { + return ModuleTypeInfo.IsAssignableFrom(typeInfo) && + !typeInfo.IsAbstract && + !typeInfo.ContainsGenericParameters; + } + + private static bool IsValidCommandDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(CommandAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + } +} diff --git a/src/Discord.Net.Commands/Builders/ParameterBuilder.cs b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs new file mode 100644 index 0000000..61cd01f --- /dev/null +++ b/src/Discord.Net.Commands/Builders/ParameterBuilder.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Discord.Commands.Builders +{ + public class ParameterBuilder + { + #region ParameterBuilder + private readonly List _preconditions; + private readonly List _attributes; + + public CommandBuilder Command { get; } + public string Name { get; internal set; } + public Type ParameterType { get; internal set; } + + public TypeReader TypeReader { get; set; } + public bool IsOptional { get; set; } + public bool IsRemainder { get; set; } + public bool IsMultiple { get; set; } + public object DefaultValue { get; set; } + public string Summary { get; set; } + + public IReadOnlyList Preconditions => _preconditions; + public IReadOnlyList Attributes => _attributes; + #endregion + + #region Automatic + internal ParameterBuilder(CommandBuilder command) + { + _preconditions = new List(); + _attributes = new List(); + + Command = command; + } + #endregion + + #region User-defined + internal ParameterBuilder(CommandBuilder command, string name, Type type) + : this(command) + { + Discord.Preconditions.NotNull(name, nameof(name)); + + Name = name; + SetType(type); + } + + internal void SetType(Type type) + { + TypeReader = GetReader(type); + + if (type.GetTypeInfo().IsValueType) + DefaultValue = Activator.CreateInstance(type); + else if (type.IsArray) + DefaultValue = Array.CreateInstance(type.GetElementType(), 0); + ParameterType = type; + } + + private TypeReader GetReader(Type type) + { + var commands = Command.Module.Service; + if (type.GetTypeInfo().GetCustomAttribute() != null) + { + IsRemainder = true; + var reader = commands.GetTypeReaders(type)?.FirstOrDefault().Value; + if (reader == null) + { + Type readerType; + try + { + readerType = typeof(NamedArgumentTypeReader<>).MakeGenericType(new[] { type }); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException($"Parameter type '{type.Name}' for command '{Command.Name}' must be a class with a public parameterless constructor to use as a NamedArgumentType.", ex); + } + + reader = (TypeReader)Activator.CreateInstance(readerType, new[] { commands }); + commands.AddTypeReader(type, reader); + } + + return reader; + } + + + var readers = commands.GetTypeReaders(type); + if (readers != null) + return readers.FirstOrDefault().Value; + else + return commands.GetDefaultTypeReader(type); + } + + public ParameterBuilder WithSummary(string summary) + { + Summary = summary; + return this; + } + public ParameterBuilder WithDefault(object defaultValue) + { + DefaultValue = defaultValue; + return this; + } + public ParameterBuilder WithIsOptional(bool isOptional) + { + IsOptional = isOptional; + return this; + } + public ParameterBuilder WithIsRemainder(bool isRemainder) + { + IsRemainder = isRemainder; + return this; + } + public ParameterBuilder WithIsMultiple(bool isMultiple) + { + IsMultiple = isMultiple; + return this; + } + + public ParameterBuilder AddAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return this; + } + public ParameterBuilder AddPrecondition(ParameterPreconditionAttribute precondition) + { + _preconditions.Add(precondition); + return this; + } + + internal ParameterInfo Build(CommandInfo info) + { + if ((TypeReader ??= GetReader(ParameterType)) == null) + throw new InvalidOperationException($"No type reader found for type {ParameterType.Name}, one must be specified"); + + return new ParameterInfo(this, info, Command.Module.Service); + } + #endregion + } +} diff --git a/src/Discord.Net.Commands/CommandContext.cs b/src/Discord.Net.Commands/CommandContext.cs new file mode 100644 index 0000000..393cdf9 --- /dev/null +++ b/src/Discord.Net.Commands/CommandContext.cs @@ -0,0 +1,34 @@ +namespace Discord.Commands +{ + /// The context of a command which may contain the client, user, guild, channel, and message. + public class CommandContext : ICommandContext + { + /// + public IDiscordClient Client { get; } + /// + public IGuild Guild { get; } + /// + public IMessageChannel Channel { get; } + /// + public IUser User { get; } + /// + public IUserMessage Message { get; } + + /// Indicates whether the channel that the command is executed in is a private channel. + public bool IsPrivate => Channel is IPrivateChannel; + + /// + /// Initializes a new class with the provided client and message. + /// + /// The underlying client. + /// The underlying message. + public CommandContext(IDiscordClient client, IUserMessage msg) + { + Client = client; + Guild = (msg.Channel as IGuildChannel)?.Guild; + Channel = msg.Channel; + User = msg.Author; + Message = msg; + } + } +} diff --git a/src/Discord.Net.Commands/CommandError.cs b/src/Discord.Net.Commands/CommandError.cs new file mode 100644 index 0000000..b487d8a --- /dev/null +++ b/src/Discord.Net.Commands/CommandError.cs @@ -0,0 +1,51 @@ +namespace Discord.Commands +{ + /// Defines the type of error a command can throw. + public enum CommandError + { + //Search + /// + /// Thrown when the command is unknown. + /// + UnknownCommand = 1, + + //Parse + /// + /// Thrown when the command fails to be parsed. + /// + ParseFailed, + /// + /// Thrown when the input text has too few or too many arguments. + /// + BadArgCount, + + //Parse (Type Reader) + //CastFailed, + /// + /// Thrown when the object cannot be found by the . + /// + ObjectNotFound, + /// + /// Thrown when more than one object is matched by . + /// + MultipleMatches, + + //Preconditions + /// + /// Thrown when the command fails to meet a 's conditions. + /// + UnmetPrecondition, + + //Execute + /// + /// Thrown when an exception occurs mid-command execution. + /// + Exception, + + //Runtime + /// + /// Thrown when the command is not successfully executed on runtime. + /// + Unsuccessful + } +} diff --git a/src/Discord.Net.Commands/CommandException.cs b/src/Discord.Net.Commands/CommandException.cs new file mode 100644 index 0000000..6c5ab0a --- /dev/null +++ b/src/Discord.Net.Commands/CommandException.cs @@ -0,0 +1,30 @@ +using System; + +namespace Discord.Commands +{ + /// + /// The exception that is thrown if another exception occurs during a command execution. + /// + public class CommandException : Exception + { + /// Gets the command that caused the exception. + public CommandInfo Command { get; } + /// Gets the command context of the exception. + public ICommandContext Context { get; } + + /// + /// Initializes a new instance of the class using a + /// information, a context, and the exception that + /// interrupted the execution. + /// + /// The command information. + /// The context of the command. + /// The exception that interrupted the command execution. + public CommandException(CommandInfo command, ICommandContext context, Exception ex) + : base($"Error occurred executing {command.GetLogText(context)}.", ex) + { + Command = command; + Context = context; + } + } +} diff --git a/src/Discord.Net.Commands/CommandMatch.cs b/src/Discord.Net.Commands/CommandMatch.cs new file mode 100644 index 0000000..c15a332 --- /dev/null +++ b/src/Discord.Net.Commands/CommandMatch.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + public struct CommandMatch + { + /// The command that matches the search result. + public CommandInfo Command { get; } + /// The alias of the command. + public string Alias { get; } + + public CommandMatch(CommandInfo command, string alias) + { + Command = command; + Alias = alias; + } + + public Task CheckPreconditionsAsync(ICommandContext context, IServiceProvider services = null) + => Command.CheckPreconditionsAsync(context, services); + public Task ParseAsync(ICommandContext context, SearchResult searchResult, PreconditionResult preconditionResult = null, IServiceProvider services = null) + => Command.ParseAsync(context, Alias.Length, searchResult, preconditionResult, services); + public Task ExecuteAsync(ICommandContext context, IEnumerable argList, IEnumerable paramList, IServiceProvider services) + => Command.ExecuteAsync(context, argList, paramList, services); + public Task ExecuteAsync(ICommandContext context, ParseResult parseResult, IServiceProvider services) + => Command.ExecuteAsync(context, parseResult, services); + } +} diff --git a/src/Discord.Net.Commands/CommandParser.cs b/src/Discord.Net.Commands/CommandParser.cs new file mode 100644 index 0000000..88698cd --- /dev/null +++ b/src/Discord.Net.Commands/CommandParser.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal static class CommandParser + { + private enum ParserPart + { + None, + Parameter, + QuotedParameter + } + public static async Task ParseArgsAsync(CommandInfo command, ICommandContext context, bool ignoreExtraArgs, IServiceProvider services, string input, int startPos, IReadOnlyDictionary aliasMap) + { + ParameterInfo curParam = null; + StringBuilder argBuilder = new StringBuilder(input.Length); + int endPos = input.Length; + var curPart = ParserPart.None; + int lastArgEndPos = int.MinValue; + var argList = ImmutableArray.CreateBuilder(); + var paramList = ImmutableArray.CreateBuilder(); + bool isEscaping = false; + char c, matchQuote = '\0'; + + // local helper functions + bool IsOpenQuote(IReadOnlyDictionary dict, char ch) + { + // return if the key is contained in the dictionary if it is populated + if (dict.Count != 0) + return dict.ContainsKey(ch); + // or otherwise if it is the default double quote + return c == '\"'; + } + + char GetMatch(IReadOnlyDictionary dict, char ch) + { + // get the corresponding value for the key, if it exists + // and if the dictionary is populated + if (dict.Count != 0 && dict.TryGetValue(c, out var value)) + return value; + // or get the default pair of the default double quote + return '\"'; + } + + for (int curPos = startPos; curPos <= endPos; curPos++) + { + if (curPos < endPos) + c = input[curPos]; + else + c = '\0'; + + //If we're processing an remainder parameter, ignore all other logic + if (curParam != null && curParam.IsRemainder && curPos != endPos) + { + argBuilder.Append(c); + continue; + } + + //If this character is escaped, skip it + if (isEscaping) + { + if (curPos != endPos) + { + // if this character matches the quotation mark of the end of the string + // means that it should be escaped + // but if is not, then there is no reason to escape it then + if (c != matchQuote) + { + // if no reason to escape the next character, then re-add \ to the arg + argBuilder.Append('\\'); + } + + argBuilder.Append(c); + isEscaping = false; + continue; + } + } + //Are we escaping the next character? + if (c == '\\' && (curParam == null || !curParam.IsRemainder)) + { + isEscaping = true; + continue; + } + + //If we're not currently processing one, are we starting the next argument yet? + if (curPart == ParserPart.None) + { + if (char.IsWhiteSpace(c) || curPos == endPos) + continue; //Skip whitespace between arguments + else if (curPos == lastArgEndPos) + return ParseResult.FromError(CommandError.ParseFailed, "There must be at least one character of whitespace between arguments."); + else + { + if (curParam == null) + curParam = command.Parameters.Count > argList.Count ? command.Parameters[argList.Count] : null; + + if (curParam != null && curParam.IsRemainder) + { + argBuilder.Append(c); + continue; + } + + if (IsOpenQuote(aliasMap, c)) + { + curPart = ParserPart.QuotedParameter; + matchQuote = GetMatch(aliasMap, c); + continue; + } + curPart = ParserPart.Parameter; + } + } + + //Has this parameter ended yet? + string argString = null; + if (curPart == ParserPart.Parameter) + { + if (curPos == endPos || char.IsWhiteSpace(c)) + { + argString = argBuilder.ToString(); + lastArgEndPos = curPos; + } + else + argBuilder.Append(c); + } + else if (curPart == ParserPart.QuotedParameter) + { + if (c == matchQuote) + { + argString = argBuilder.ToString(); //Remove quotes + lastArgEndPos = curPos + 1; + } + else + argBuilder.Append(c); + } + + if (argString != null) + { + if (curParam == null) + { + if (command.IgnoreExtraArgs) + break; + else + return ParseResult.FromError(CommandError.BadArgCount, "The input text has too many parameters."); + } + + var typeReaderResult = await curParam.ParseAsync(context, argString, services).ConfigureAwait(false); + if (!typeReaderResult.IsSuccess && typeReaderResult.Error != CommandError.MultipleMatches) + return ParseResult.FromError(typeReaderResult, curParam); + + if (curParam.IsMultiple) + { + paramList.Add(typeReaderResult); + + curPart = ParserPart.None; + } + else + { + argList.Add(typeReaderResult); + + curParam = null; + curPart = ParserPart.None; + } + argBuilder.Clear(); + } + } + + if (curParam != null && curParam.IsRemainder) + { + var typeReaderResult = await curParam.ParseAsync(context, argBuilder.ToString(), services).ConfigureAwait(false); + if (!typeReaderResult.IsSuccess) + return ParseResult.FromError(typeReaderResult, curParam); + argList.Add(typeReaderResult); + } + + if (isEscaping) + return ParseResult.FromError(CommandError.ParseFailed, "Input text may not end on an incomplete escape."); + if (curPart == ParserPart.QuotedParameter) + return ParseResult.FromError(CommandError.ParseFailed, "A quoted parameter is incomplete."); + + //Add missing optionals + for (int i = argList.Count; i < command.Parameters.Count; i++) + { + var param = command.Parameters[i]; + if (param.IsMultiple) + continue; + if (!param.IsOptional) + return ParseResult.FromError(CommandError.BadArgCount, "The input text has too few parameters."); + argList.Add(TypeReaderResult.FromSuccess(param.DefaultValue)); + } + + return ParseResult.FromSuccess(argList.ToImmutable(), paramList.ToImmutable()); + } + } +} diff --git a/src/Discord.Net.Commands/CommandService.cs b/src/Discord.Net.Commands/CommandService.cs new file mode 100644 index 0000000..cb4bea5 --- /dev/null +++ b/src/Discord.Net.Commands/CommandService.cs @@ -0,0 +1,722 @@ +using Discord.Commands.Builders; +using Discord.Logging; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Provides a framework for building Discord commands. + /// + /// + /// + /// The service provides a framework for building Discord commands both dynamically via runtime builders or + /// statically via compile-time modules. To create a command module at compile-time, see + /// (most common); otherwise, see . + /// + /// + /// This service also provides several events for monitoring command usages; such as + /// for any command-related log events, and + /// for information about commands that have + /// been successfully executed. + /// + /// + public class CommandService : IDisposable + { + #region CommandService + /// + /// Occurs when a command-related information is received. + /// + public event Func Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } } + internal readonly AsyncEvent> _logEvent = new AsyncEvent>(); + + /// + /// Occurs when a command is executed. + /// + /// + /// This event is fired when a command has been executed, successfully or not. When a command fails to + /// execute during parsing or precondition stage, the CommandInfo may not be returned. + /// + public event Func, ICommandContext, IResult, Task> CommandExecuted { add { _commandExecutedEvent.Add(value); } remove { _commandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent, ICommandContext, IResult, Task>> _commandExecutedEvent = new AsyncEvent, ICommandContext, IResult, Task>>(); + + private readonly SemaphoreSlim _moduleLock; + private readonly ConcurrentDictionary _typedModuleDefs; + private readonly ConcurrentDictionary> _typeReaders; + private readonly ConcurrentDictionary _defaultTypeReaders; + private readonly ImmutableList<(Type EntityType, Type TypeReaderType)> _entityTypeReaders; + private readonly HashSet _moduleDefs; + private readonly CommandMap _map; + + internal readonly bool _caseSensitive, _throwOnError, _ignoreExtraArgs; + internal readonly char _separatorChar; + internal readonly RunMode _defaultRunMode; + internal readonly Logger _cmdLogger; + internal readonly LogManager _logManager; + internal readonly IReadOnlyDictionary _quotationMarkAliasMap; + + internal bool _isDisposed; + + /// + /// Represents all modules loaded within . + /// + public IEnumerable Modules => _moduleDefs.Select(x => x); + + /// + /// Represents all commands loaded within . + /// + public IEnumerable Commands => _moduleDefs.SelectMany(x => x.Commands); + + /// + /// Represents all loaded within . + /// + public ILookup TypeReaders => _typeReaders.SelectMany(x => x.Value.Select(y => new { y.Key, y.Value })).ToLookup(x => x.Key, x => x.Value); + + /// + /// Initializes a new class. + /// + public CommandService() : this(new CommandServiceConfig()) { } + + /// + /// Initializes a new class with the provided configuration. + /// + /// The configuration class. + /// + /// The cannot be set to . + /// + public CommandService(CommandServiceConfig config) + { + _caseSensitive = config.CaseSensitiveCommands; + _throwOnError = config.ThrowOnError; + _ignoreExtraArgs = config.IgnoreExtraArgs; + _separatorChar = config.SeparatorChar; + _defaultRunMode = config.DefaultRunMode; + _quotationMarkAliasMap = (config.QuotationMarkAliasMap ?? new Dictionary()).ToImmutableDictionary(); + if (_defaultRunMode == RunMode.Default) + throw new InvalidOperationException("The default run mode cannot be set to Default."); + + _logManager = new LogManager(config.LogLevel); + _logManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + _cmdLogger = _logManager.CreateLogger("Command"); + + _moduleLock = new SemaphoreSlim(1, 1); + _typedModuleDefs = new ConcurrentDictionary(); + _moduleDefs = new HashSet(); + _map = new CommandMap(this); + _typeReaders = new ConcurrentDictionary>(); + + _defaultTypeReaders = new ConcurrentDictionary(); + foreach (var type in PrimitiveParsers.SupportedTypes) + { + _defaultTypeReaders[type] = PrimitiveTypeReader.Create(type); + _defaultTypeReaders[typeof(Nullable<>).MakeGenericType(type)] = NullableTypeReader.Create(type, _defaultTypeReaders[type]); + } + + var tsreader = new TimeSpanTypeReader(); + _defaultTypeReaders[typeof(TimeSpan)] = tsreader; + _defaultTypeReaders[typeof(TimeSpan?)] = NullableTypeReader.Create(typeof(TimeSpan), tsreader); + + _defaultTypeReaders[typeof(string)] = + new PrimitiveTypeReader((string x, out string y) => { y = x; return true; }, 0); + + var entityTypeReaders = ImmutableList.CreateBuilder<(Type, Type)>(); + entityTypeReaders.Add((typeof(IMessage), typeof(MessageTypeReader<>))); + entityTypeReaders.Add((typeof(IChannel), typeof(ChannelTypeReader<>))); + entityTypeReaders.Add((typeof(IRole), typeof(RoleTypeReader<>))); + entityTypeReaders.Add((typeof(IUser), typeof(UserTypeReader<>))); + _entityTypeReaders = entityTypeReaders.ToImmutable(); + } + #endregion + + #region Modules + public async Task CreateModuleAsync(string primaryAlias, Action buildFunc) + { + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + var builder = new ModuleBuilder(this, null, primaryAlias); + buildFunc(builder); + + var module = builder.Build(this, null); + + return LoadModuleInternal(module); + } + finally + { + _moduleLock.Release(); + } + } + + /// + /// Add a command module from a . + /// + /// + /// The following example registers the module MyModule to commandService. + /// + /// await commandService.AddModuleAsync<MyModule>(serviceProvider); + /// + /// + /// The type of module. + /// The for your dependency injection solution if using one; otherwise, pass . + /// This module has already been added. + /// + /// The fails to be built; an invalid type may have been provided. + /// + /// + /// A task that represents the asynchronous operation for adding the module. The task result contains the + /// built module. + /// + public Task AddModuleAsync(IServiceProvider services) => AddModuleAsync(typeof(T), services); + + /// + /// Adds a command module from a . + /// + /// The type of module. + /// The for your dependency injection solution if using one; otherwise, pass . + /// This module has already been added. + /// + /// The fails to be built; an invalid type may have been provided. + /// + /// + /// A task that represents the asynchronous operation for adding the module. The task result contains the + /// built module. + /// + public async Task AddModuleAsync(Type type, IServiceProvider services) + { + services ??= EmptyServiceProvider.Instance; + + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + var typeInfo = type.GetTypeInfo(); + + if (_typedModuleDefs.ContainsKey(type)) + throw new ArgumentException("This module has already been added."); + + var module = (await ModuleClassBuilder.BuildAsync(this, services, typeInfo).ConfigureAwait(false)).FirstOrDefault(); + + if (module.Value == default(ModuleInfo)) + throw new InvalidOperationException($"Could not build the module {type.FullName}, did you pass an invalid type?"); + + _typedModuleDefs[module.Key] = module.Value; + + return LoadModuleInternal(module.Value); + } + finally + { + _moduleLock.Release(); + } + } + /// + /// Add command modules from an . + /// + /// The containing command modules. + /// The for your dependency injection solution if using one; otherwise, pass . + /// + /// A task that represents the asynchronous operation for adding the command modules. The task result + /// contains an enumerable collection of modules added. + /// + public async Task> AddModulesAsync(Assembly assembly, IServiceProvider services) + { + services ??= EmptyServiceProvider.Instance; + + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + var types = await ModuleClassBuilder.SearchAsync(assembly, this).ConfigureAwait(false); + var moduleDefs = await ModuleClassBuilder.BuildAsync(types, this, services).ConfigureAwait(false); + + foreach (var info in moduleDefs) + { + _typedModuleDefs[info.Key] = info.Value; + LoadModuleInternal(info.Value); + } + + return moduleDefs.Select(x => x.Value).ToImmutableArray(); + } + finally + { + _moduleLock.Release(); + } + } + private ModuleInfo LoadModuleInternal(ModuleInfo module) + { + _moduleDefs.Add(module); + + foreach (var command in module.Commands) + _map.AddCommand(command); + + foreach (var submodule in module.Submodules) + LoadModuleInternal(submodule); + + return module; + } + /// + /// Removes the command module. + /// + /// The to be removed from the service. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the is successfully removed. + /// + public async Task RemoveModuleAsync(ModuleInfo module) + { + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + var typeModulePair = _typedModuleDefs.FirstOrDefault(x => x.Value.Equals(module)); + + if (!typeModulePair.Equals(default(KeyValuePair))) + _typedModuleDefs.TryRemove(typeModulePair.Key, out var _); + + return RemoveModuleInternal(module); + } + finally + { + _moduleLock.Release(); + } + } + /// + /// Removes the command module. + /// + /// The of the module. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the module is successfully removed. + /// + public Task RemoveModuleAsync() => RemoveModuleAsync(typeof(T)); + /// + /// Removes the command module. + /// + /// The of the module. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the module is successfully removed. + /// + public async Task RemoveModuleAsync(Type type) + { + await _moduleLock.WaitAsync().ConfigureAwait(false); + try + { + if (!_typedModuleDefs.TryRemove(type, out var module)) + return false; + + return RemoveModuleInternal(module); + } + finally + { + _moduleLock.Release(); + } + } + private bool RemoveModuleInternal(ModuleInfo module) + { + if (!_moduleDefs.Remove(module)) + return false; + + foreach (var cmd in module.Commands) + _map.RemoveCommand(cmd); + + foreach (var submodule in module.Submodules) + { + RemoveModuleInternal(submodule); + } + + return true; + } + #endregion + + #region Type Readers + /// + /// Adds a custom to this for the supplied object + /// type. + /// If is a , a nullable will + /// also be added. + /// If a default exists for , a warning will be logged + /// and the default will be replaced. + /// + /// The object type to be read by the . + /// An instance of the to be added. + public void AddTypeReader(TypeReader reader) + => AddTypeReader(typeof(T), reader); + /// + /// Adds a custom to this for the supplied object + /// type. + /// If is a , a nullable for the + /// value type will also be added. + /// If a default exists for , a warning will be logged and + /// the default will be replaced. + /// + /// A instance for the type to be read. + /// An instance of the to be added. + public void AddTypeReader(Type type, TypeReader reader) + { + if (_defaultTypeReaders.ContainsKey(type)) + _ = _cmdLogger.WarningAsync($"The default TypeReader for {type.FullName} was replaced by {reader.GetType().FullName}." + + "To suppress this message, use AddTypeReader(reader, true)."); + AddTypeReader(type, reader, true); + } + /// + /// Adds a custom to this for the supplied object + /// type. + /// If is a , a nullable will + /// also be added. + /// + /// The object type to be read by the . + /// An instance of the to be added. + /// + /// Defines whether the should replace the default one for + /// if it exists. + /// + public void AddTypeReader(TypeReader reader, bool replaceDefault) + => AddTypeReader(typeof(T), reader, replaceDefault); + /// + /// Adds a custom to this for the supplied object + /// type. + /// If is a , a nullable for the + /// value type will also be added. + /// + /// A instance for the type to be read. + /// An instance of the to be added. + /// + /// Defines whether the should replace the default one for if + /// it exists. + /// + public void AddTypeReader(Type type, TypeReader reader, bool replaceDefault) + { + if (replaceDefault && HasDefaultTypeReader(type)) + { + _defaultTypeReaders.AddOrUpdate(type, reader, (k, v) => reader); + if (type.GetTypeInfo().IsValueType) + { + var nullableType = typeof(Nullable<>).MakeGenericType(type); + var nullableReader = NullableTypeReader.Create(type, reader); + _defaultTypeReaders.AddOrUpdate(nullableType, nullableReader, (k, v) => nullableReader); + } + } + else + { + var readers = _typeReaders.GetOrAdd(type, x => new ConcurrentDictionary()); + readers[reader.GetType()] = reader; + + if (type.GetTypeInfo().IsValueType) + AddNullableTypeReader(type, reader); + } + } + + /// + /// Removes a type reader from the list of type readers. + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the readers from. + /// if the default readers for should be removed; otherwise . + /// The removed collection of type readers. + /// if the remove operation was successful; otherwise . + public bool TryRemoveTypeReader(Type type, bool isDefaultTypeReader, out IDictionary readers) + { + readers = new Dictionary(); + + if (isDefaultTypeReader) + { + var isSuccess = _defaultTypeReaders.TryRemove(type, out var result); + if (isSuccess) + readers.Add(result?.GetType(), result); + + return isSuccess; + } + else + { + var isSuccess = _typeReaders.TryRemove(type, out var result); + + if (isSuccess) + readers = result; + + return isSuccess; + } + } + + internal bool HasDefaultTypeReader(Type type) + { + if (_defaultTypeReaders.ContainsKey(type)) + return true; + + var typeInfo = type.GetTypeInfo(); + if (typeInfo.IsEnum) + return true; + return _entityTypeReaders.Any(x => type == x.EntityType || typeInfo.ImplementedInterfaces.Contains(x.EntityType)); + } + internal void AddNullableTypeReader(Type valueType, TypeReader valueTypeReader) + { + var readers = _typeReaders.GetOrAdd(typeof(Nullable<>).MakeGenericType(valueType), x => new ConcurrentDictionary()); + var nullableReader = NullableTypeReader.Create(valueType, valueTypeReader); + readers[nullableReader.GetType()] = nullableReader; + } + internal IDictionary GetTypeReaders(Type type) + { + if (_typeReaders.TryGetValue(type, out var definedTypeReaders)) + return definedTypeReaders; + return null; + } + internal TypeReader GetDefaultTypeReader(Type type) + { + if (_defaultTypeReaders.TryGetValue(type, out var reader)) + return reader; + var typeInfo = type.GetTypeInfo(); + + //Is this an enum? + if (typeInfo.IsEnum) + { + reader = EnumTypeReader.GetReader(type); + _defaultTypeReaders[type] = reader; + return reader; + } + var underlyingType = Nullable.GetUnderlyingType(type); + if (underlyingType != null && underlyingType.IsEnum) + { + reader = NullableTypeReader.Create(underlyingType, EnumTypeReader.GetReader(underlyingType)); + _defaultTypeReaders[type] = reader; + return reader; + } + + //Is this an entity? + for (int i = 0; i < _entityTypeReaders.Count; i++) + { + if (type == _entityTypeReaders[i].EntityType || typeInfo.ImplementedInterfaces.Contains(_entityTypeReaders[i].EntityType)) + { + reader = Activator.CreateInstance(_entityTypeReaders[i].TypeReaderType.MakeGenericType(type)) as TypeReader; + _defaultTypeReaders[type] = reader; + return reader; + } + } + return null; + } + #endregion + + #region Execution + /// + /// Searches for the command. + /// + /// The context of the command. + /// The position of which the command starts at. + /// The result containing the matching commands. + public SearchResult Search(ICommandContext context, int argPos) + => Search(context.Message.Content.Substring(argPos)); + /// + /// Searches for the command. + /// + /// The context of the command. + /// The command string. + /// The result containing the matching commands. + public SearchResult Search(ICommandContext context, string input) + => Search(input); + public SearchResult Search(string input) + { + string searchInput = _caseSensitive ? input : input.ToLowerInvariant(); + var matches = _map.GetCommands(searchInput).OrderByDescending(x => x.Command.Priority).ToImmutableArray(); + + if (matches.Length > 0) + return SearchResult.FromSuccess(input, matches); + else + return SearchResult.FromError(CommandError.UnknownCommand, "Unknown command."); + } + + /// + /// Executes the command. + /// + /// The context of the command. + /// The position of which the command starts at. + /// The service to be used in the command's dependency injection. + /// The handling mode when multiple command matches are found. + /// + /// A task that represents the asynchronous execution operation. The task result contains the result of the + /// command execution. + /// + public Task ExecuteAsync(ICommandContext context, int argPos, IServiceProvider services, MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + => ExecuteAsync(context, context.Message.Content.Substring(argPos), services, multiMatchHandling); + /// + /// Executes the command. + /// + /// The context of the command. + /// The command string. + /// The service to be used in the command's dependency injection. + /// The handling mode when multiple command matches are found. + /// + /// A task that represents the asynchronous execution operation. The task result contains the result of the + /// command execution. + /// + public async Task ExecuteAsync(ICommandContext context, string input, IServiceProvider services, MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + { + services ??= EmptyServiceProvider.Instance; + + var searchResult = Search(input); + + var validationResult = await ValidateAndGetBestMatch(searchResult, context, services, multiMatchHandling); + + if (validationResult is SearchResult result) + { + await _commandExecutedEvent.InvokeAsync(Optional.Create(), context, result).ConfigureAwait(false); + return result; + } + + if (validationResult is MatchResult matchResult) + { + return await HandleCommandPipeline(matchResult, context, services); + } + + return validationResult; + } + + private async Task HandleCommandPipeline(MatchResult matchResult, ICommandContext context, IServiceProvider services) + { + if (!matchResult.IsSuccess) + return matchResult; + + if (matchResult.Pipeline is ParseResult parseResult) + { + if (!parseResult.IsSuccess) + { + await _commandExecutedEvent.InvokeAsync(matchResult.Match.Value.Command, context, parseResult); + return parseResult; + } + + var executeResult = await matchResult.Match.Value.ExecuteAsync(context, parseResult, services); + + if (!executeResult.IsSuccess && !(executeResult is RuntimeResult || executeResult is ExecuteResult)) // successful results raise the event in CommandInfo#ExecuteInternalAsync (have to raise it there b/c deferred execution) + await _commandExecutedEvent.InvokeAsync(matchResult.Match.Value.Command, context, executeResult); + return executeResult; + } + + if (matchResult.Pipeline is PreconditionResult preconditionResult) + { + await _commandExecutedEvent.InvokeAsync(matchResult.Match.Value.Command, context, preconditionResult).ConfigureAwait(false); + return preconditionResult; + } + + return matchResult; + } + + // Calculates the 'score' of a command given a parse result + float CalculateScore(CommandMatch match, ParseResult parseResult) + { + float argValuesScore = 0, paramValuesScore = 0; + + if (match.Command.Parameters.Count > 0) + { + var argValuesSum = parseResult.ArgValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; + var paramValuesSum = parseResult.ParamValues?.Sum(x => x.Values.OrderByDescending(y => y.Score).FirstOrDefault().Score) ?? 0; + + argValuesScore = argValuesSum / match.Command.Parameters.Count; + paramValuesScore = paramValuesSum / match.Command.Parameters.Count; + } + + var totalArgsScore = (argValuesScore + paramValuesScore) / 2; + return match.Command.Priority + totalArgsScore * 0.99f; + } + + /// + /// Validates and gets the best from a specified + /// + /// The SearchResult. + /// The context of the command. + /// The service provider to be used on the command's dependency injection. + /// The handling mode when multiple command matches are found. + /// A task that represents the asynchronous validation operation. The task result contains the result of the + /// command validation as a or a if no matches were found. + public async Task ValidateAndGetBestMatch(SearchResult matches, ICommandContext context, IServiceProvider provider, MultiMatchHandling multiMatchHandling = MultiMatchHandling.Exception) + { + if (!matches.IsSuccess) + return matches; + + var commands = matches.Commands; + var preconditionResults = new Dictionary(); + + foreach (var command in commands) + { + preconditionResults[command] = await command.CheckPreconditionsAsync(context, provider); + } + + var successfulPreconditions = preconditionResults + .Where(x => x.Value.IsSuccess) + .ToArray(); + + if (successfulPreconditions.Length == 0) + { + //All preconditions failed, return the one from the highest priority command + var bestCandidate = preconditionResults + .OrderByDescending(x => x.Key.Command.Priority) + .FirstOrDefault(x => !x.Value.IsSuccess); + return MatchResult.FromSuccess(bestCandidate.Key, bestCandidate.Value); + } + + var parseResults = new Dictionary(); + + foreach (var pair in successfulPreconditions) + { + var parseResult = await pair.Key.ParseAsync(context, matches, pair.Value, provider).ConfigureAwait(false); + + if (parseResult.Error == CommandError.MultipleMatches) + { + IReadOnlyList argList, paramList; + switch (multiMatchHandling) + { + case MultiMatchHandling.Best: + argList = parseResult.ArgValues.Select(x => x.Values.OrderByDescending(y => y.Score).First()).ToImmutableArray(); + paramList = parseResult.ParamValues.Select(x => x.Values.OrderByDescending(y => y.Score).First()).ToImmutableArray(); + parseResult = ParseResult.FromSuccess(argList, paramList); + break; + } + } + + parseResults[pair.Key] = parseResult; + } + + var weightedParseResults = parseResults + .OrderByDescending(x => CalculateScore(x.Key, x.Value)); + + var successfulParses = weightedParseResults + .Where(x => x.Value.IsSuccess) + .ToArray(); + + if (successfulParses.Length == 0) + { + var bestMatch = parseResults + .FirstOrDefault(x => !x.Value.IsSuccess); + + return MatchResult.FromSuccess(bestMatch.Key, bestMatch.Value); + } + + var chosenOverload = successfulParses[0]; + + return MatchResult.FromSuccess(chosenOverload.Key, chosenOverload.Value); + } + #endregion + + #region Dispose + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _moduleLock?.Dispose(); + } + + _isDisposed = true; + } + } + + void IDisposable.Dispose() + { + Dispose(true); + } + #endregion + } +} diff --git a/src/Discord.Net.Commands/CommandServiceConfig.cs b/src/Discord.Net.Commands/CommandServiceConfig.cs new file mode 100644 index 0000000..cbb5b8f --- /dev/null +++ b/src/Discord.Net.Commands/CommandServiceConfig.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace Discord.Commands +{ + /// + /// Represents a configuration class for . + /// + public class CommandServiceConfig + { + /// + /// Gets or sets the default commands should have, if one is not specified on the + /// Command attribute or builder. + /// + public RunMode DefaultRunMode { get; set; } = RunMode.Sync; + + /// + /// Gets or sets the that separates an argument with another. + /// + public char SeparatorChar { get; set; } = ' '; + + /// + /// Gets or sets whether commands should be case-sensitive. + /// + public bool CaseSensitiveCommands { get; set; } = false; + + /// + /// Gets or sets the minimum log level severity that will be sent to the event. + /// + public LogSeverity LogLevel { get; set; } = LogSeverity.Info; + + /// + /// Gets or sets whether commands should push exceptions up to the caller. + /// + public bool ThrowOnError { get; set; } = true; + + /// + /// Collection of aliases for matching pairs of string delimiters. + /// The dictionary stores the opening delimiter as a key, and the matching closing delimiter as the value. + /// If no value is supplied will be used, which contains + /// many regional equivalents. + /// Only values that are specified in this map will be used as string delimiters, so if " is removed then + /// it won't be used. + /// If this map is set to null or empty, the default delimiter of " will be used. + /// + /// + /// + /// QuotationMarkAliasMap = new Dictionary<char, char>() + /// { + /// {'\"', '\"' }, + /// {'“', '”' }, + /// {'「', '」' }, + /// } + /// + /// + public Dictionary QuotationMarkAliasMap { get; set; } = QuotationAliasUtils.GetDefaultAliasMap; + + /// + /// Gets or sets a value that indicates whether extra parameters should be ignored. + /// + public bool IgnoreExtraArgs { get; set; } = false; + } +} diff --git a/src/Discord.Net.Commands/Discord.Net.Commands.csproj b/src/Discord.Net.Commands/Discord.Net.Commands.csproj index 74abf5c..633fcb3 100644 --- a/src/Discord.Net.Commands/Discord.Net.Commands.csproj +++ b/src/Discord.Net.Commands/Discord.Net.Commands.csproj @@ -1,10 +1,20 @@ - + + - Exe - net6.0 - enable - enable + Discord.Net.Commands + Discord.Commands + A Discord.Net extension adding support for bot commands. + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + 5 + True + false + false - + + + + + + diff --git a/src/Discord.Net.Commands/EmptyServiceProvider.cs b/src/Discord.Net.Commands/EmptyServiceProvider.cs new file mode 100644 index 0000000..f9cda2a --- /dev/null +++ b/src/Discord.Net.Commands/EmptyServiceProvider.cs @@ -0,0 +1,11 @@ +using System; + +namespace Discord.Commands +{ + internal class EmptyServiceProvider : IServiceProvider + { + public static readonly EmptyServiceProvider Instance = new EmptyServiceProvider(); + + public object GetService(Type serviceType) => null; + } +} diff --git a/src/Discord.Net.Commands/Extensions/CommandServiceExtensions.cs b/src/Discord.Net.Commands/Extensions/CommandServiceExtensions.cs new file mode 100644 index 0000000..4c2262f --- /dev/null +++ b/src/Discord.Net.Commands/Extensions/CommandServiceExtensions.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Provides extension methods for the class. + /// + public static class CommandServiceExtensions + { + /// + /// Returns commands that can be executed under the current context. + /// + /// The set of commands to be checked against. + /// The current command context. + /// The service provider used for dependency injection upon precondition check. + /// + /// A read-only collection of commands that can be executed under the current context. + /// + public static async Task> GetExecutableCommandsAsync(this ICollection commands, ICommandContext context, IServiceProvider provider) + { + var executableCommands = new List(); + + var tasks = commands.Select(async c => + { + var result = await c.CheckPreconditionsAsync(context, provider).ConfigureAwait(false); + return new { Command = c, PreconditionResult = result }; + }); + + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + foreach (var result in results) + { + if (result.PreconditionResult.IsSuccess) + executableCommands.Add(result.Command); + } + + return executableCommands; + } + /// + /// Returns commands that can be executed under the current context. + /// + /// The desired command service class to check against. + /// The current command context. + /// The service provider used for dependency injection upon precondition check. + /// + /// A read-only collection of commands that can be executed under the current context. + /// + public static Task> GetExecutableCommandsAsync(this CommandService commandService, ICommandContext context, IServiceProvider provider) + => GetExecutableCommandsAsync(commandService.Commands.ToArray(), context, provider); + /// + /// Returns commands that can be executed under the current context. + /// + /// The module to be checked against. + /// The current command context. + /// The service provider used for dependency injection upon precondition check. + /// + /// A read-only collection of commands that can be executed under the current context. + /// + public static async Task> GetExecutableCommandsAsync(this ModuleInfo module, ICommandContext context, IServiceProvider provider) + { + var executableCommands = new List(); + + executableCommands.AddRange(await module.Commands.ToArray().GetExecutableCommandsAsync(context, provider).ConfigureAwait(false)); + + var tasks = module.Submodules.Select(async s => await s.GetExecutableCommandsAsync(context, provider).ConfigureAwait(false)); + var results = await Task.WhenAll(tasks).ConfigureAwait(false); + + executableCommands.AddRange(results.SelectMany(c => c)); + + return executableCommands; + } + } +} diff --git a/src/Discord.Net.Commands/Extensions/IEnumerableExtensions.cs b/src/Discord.Net.Commands/Extensions/IEnumerableExtensions.cs new file mode 100644 index 0000000..4950310 --- /dev/null +++ b/src/Discord.Net.Commands/Extensions/IEnumerableExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Commands +{ + public static class IEnumerableExtensions + { + public static IEnumerable Permutate( + this IEnumerable set, + IEnumerable others, + Func func) + { + foreach (TFirst elem in set) + { + foreach (TSecond elem2 in others) + { + yield return func(elem, elem2); + } + } + } + } +} diff --git a/src/Discord.Net.Commands/Extensions/MessageExtensions.cs b/src/Discord.Net.Commands/Extensions/MessageExtensions.cs new file mode 100644 index 0000000..46a5440 --- /dev/null +++ b/src/Discord.Net.Commands/Extensions/MessageExtensions.cs @@ -0,0 +1,67 @@ +using System; + +namespace Discord.Commands +{ + /// + /// Provides extension methods for that relates to commands. + /// + public static class MessageExtensions + { + /// + /// Gets whether the message starts with the provided character. + /// + /// The message to check against. + /// The char prefix. + /// References where the command starts. + /// + /// if the message begins with the char ; otherwise . + /// + public static bool HasCharPrefix(this IUserMessage msg, char c, ref int argPos) + { + var text = msg.Content; + if (!string.IsNullOrEmpty(text) && text[0] == c) + { + argPos = 1; + return true; + } + return false; + } + /// + /// Gets whether the message starts with the provided string. + /// + public static bool HasStringPrefix(this IUserMessage msg, string str, ref int argPos, StringComparison comparisonType = StringComparison.Ordinal) + { + var text = msg.Content; + if (!string.IsNullOrEmpty(text) && text.StartsWith(str, comparisonType)) + { + argPos = str.Length; + return true; + } + return false; + } + /// + /// Gets whether the message starts with the user's mention string. + /// + public static bool HasMentionPrefix(this IUserMessage msg, IUser user, ref int argPos) + { + var text = msg.Content; + if (string.IsNullOrEmpty(text) || text.Length <= 3 || text[0] != '<' || text[1] != '@') + return false; + + int endPos = text.IndexOf('>'); + if (endPos == -1) + return false; + if (text.Length < endPos + 2 || text[endPos + 1] != ' ') + return false; //Must end in "> " + + if (!MentionUtils.TryParseUser(text.Substring(0, endPos + 1), out ulong userId)) + return false; + if (userId == user.Id) + { + argPos = endPos + 2; + return true; + } + return false; + } + } +} diff --git a/src/Discord.Net.Commands/IModuleBase.cs b/src/Discord.Net.Commands/IModuleBase.cs new file mode 100644 index 0000000..e736298 --- /dev/null +++ b/src/Discord.Net.Commands/IModuleBase.cs @@ -0,0 +1,48 @@ +using Discord.Commands.Builders; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Represents a generic module base. + /// + public interface IModuleBase + { + /// + /// Sets the context of this module base. + /// + /// The context to set. + void SetContext(ICommandContext context); + + /// + /// Executed asynchronously before a command is run in this module base. + /// + /// The command that's about to run. + Task BeforeExecuteAsync(CommandInfo command); + + /// + /// Executed before a command is run in this module base. + /// + /// The command that's about to run. + void BeforeExecute(CommandInfo command); + + /// + /// Executed asynchronously after a command is run in this module base. + /// + /// The command that's about to run. + Task AfterExecuteAsync(CommandInfo command); + + /// + /// Executed after a command is ran in this module base. + /// + /// The command that ran. + void AfterExecute(CommandInfo command); + + /// + /// Executed when this module is building. + /// + /// The command service that is building this module. + /// The builder constructing this module. + void OnModuleBuilding(CommandService commandService, ModuleBuilder builder); + } +} diff --git a/src/Discord.Net.Commands/Info/CommandInfo.cs b/src/Discord.Net.Commands/Info/CommandInfo.cs new file mode 100644 index 0000000..5866b0f --- /dev/null +++ b/src/Discord.Net.Commands/Info/CommandInfo.cs @@ -0,0 +1,339 @@ +using Discord.Commands.Builders; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Provides the information of a command. + /// + /// + /// This object contains the information of a command. This can include the module of the command, various + /// descriptions regarding the command, and its . + /// + [DebuggerDisplay("{Name,nq}")] + public class CommandInfo + { + private static readonly System.Reflection.MethodInfo _convertParamsMethod = typeof(CommandInfo).GetTypeInfo().GetDeclaredMethod(nameof(ConvertParamsList)); + private static readonly ConcurrentDictionary, object>> _arrayConverters = new ConcurrentDictionary, object>>(); + + private readonly CommandService _commandService; + private readonly Func _action; + + /// + /// Gets the module that the command belongs in. + /// + public ModuleInfo Module { get; } + /// + /// Gets the name of the command. If none is set, the first alias is used. + /// + public string Name { get; } + /// + /// Gets the summary of the command. + /// + /// + /// This field returns the summary of the command. and can be + /// useful in help commands and various implementation that fetches details of the command for the user. + /// + public string Summary { get; } + /// + /// Gets the remarks of the command. + /// + /// + /// This field returns the summary of the command. and can be + /// useful in help commands and various implementation that fetches details of the command for the user. + /// + public string Remarks { get; } + /// + /// Gets the priority of the command. This is used when there are multiple overloads of the command. + /// + public int Priority { get; } + /// + /// Indicates whether the command accepts a [] for its + /// parameter. + /// + public bool HasVarArgs { get; } + /// + /// Indicates whether extra arguments should be ignored for this command. + /// + public bool IgnoreExtraArgs { get; } + /// + /// Gets the that is being used for the command. + /// + public RunMode RunMode { get; } + + /// + /// Gets a list of aliases defined by the of the command. + /// + public IReadOnlyList Aliases { get; } + /// + /// Gets a list of information about the parameters of the command. + /// + public IReadOnlyList Parameters { get; } + /// + /// Gets a list of preconditions defined by the of the command. + /// + public IReadOnlyList Preconditions { get; } + /// + /// Gets a list of attributes of the command. + /// + public IReadOnlyList Attributes { get; } + + internal CommandInfo(CommandBuilder builder, ModuleInfo module, CommandService service) + { + Module = module; + + Name = builder.Name; + Summary = builder.Summary; + Remarks = builder.Remarks; + + RunMode = (builder.RunMode == RunMode.Default ? service._defaultRunMode : builder.RunMode); + Priority = builder.Priority; + + Aliases = module.Aliases + .Permutate(builder.Aliases, (first, second) => + { + if (first == "") + return second; + else if (second == "") + return first; + else + return first + service._separatorChar + second; + }) + .Select(x => service._caseSensitive ? x : x.ToLowerInvariant()) + .ToImmutableArray(); + + Preconditions = builder.Preconditions.ToImmutableArray(); + Attributes = builder.Attributes.ToImmutableArray(); + + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + HasVarArgs = builder.Parameters.Count > 0 && builder.Parameters[builder.Parameters.Count - 1].IsMultiple; + IgnoreExtraArgs = builder.IgnoreExtraArgs; + + _action = builder.Callback; + _commandService = service; + } + + public async Task CheckPreconditionsAsync(ICommandContext context, IServiceProvider services = null) + { + services ??= EmptyServiceProvider.Instance; + + async Task CheckGroups(IEnumerable preconditions, string type) + { + foreach (IGrouping preconditionGroup in preconditions.GroupBy(p => p.Group, StringComparer.Ordinal)) + { + if (preconditionGroup.Key == null) + { + foreach (PreconditionAttribute precondition in preconditionGroup) + { + var result = await precondition.CheckPermissionsAsync(context, this, services).ConfigureAwait(false); + if (!result.IsSuccess) + return result; + } + } + else + { + var results = new List(); + foreach (PreconditionAttribute precondition in preconditionGroup) + results.Add(await precondition.CheckPermissionsAsync(context, this, services).ConfigureAwait(false)); + + if (!results.Any(p => p.IsSuccess)) + return PreconditionGroupResult.FromError($"{type} precondition group {preconditionGroup.Key} failed.", results); + } + } + return PreconditionGroupResult.FromSuccess(); + } + + var moduleResult = await CheckGroups(Module.Preconditions, "Module").ConfigureAwait(false); + if (!moduleResult.IsSuccess) + return moduleResult; + + var commandResult = await CheckGroups(Preconditions, "Command").ConfigureAwait(false); + if (!commandResult.IsSuccess) + return commandResult; + + return PreconditionResult.FromSuccess(); + } + + public Task ParseAsync(ICommandContext context, int startIndex, SearchResult searchResult, PreconditionResult preconditionResult = null, IServiceProvider services = null) + { + services ??= EmptyServiceProvider.Instance; + + if (!searchResult.IsSuccess) + return Task.FromResult(ParseResult.FromError(searchResult)); + if (preconditionResult != null && !preconditionResult.IsSuccess) + return Task.FromResult(ParseResult.FromError(preconditionResult)); + + string input = searchResult.Text.Substring(startIndex); + + return CommandParser.ParseArgsAsync(this, context, _commandService._ignoreExtraArgs, services, input, 0, _commandService._quotationMarkAliasMap); + } + + public Task ExecuteAsync(ICommandContext context, ParseResult parseResult, IServiceProvider services) + { + if (!parseResult.IsSuccess) + return Task.FromResult((IResult)ExecuteResult.FromError(parseResult)); + + var argList = new object[parseResult.ArgValues.Count]; + for (int i = 0; i < parseResult.ArgValues.Count; i++) + { + if (!parseResult.ArgValues[i].IsSuccess) + return Task.FromResult((IResult)ExecuteResult.FromError(parseResult.ArgValues[i])); + argList[i] = parseResult.ArgValues[i].Values.First().Value; + } + + var paramList = new object[parseResult.ParamValues.Count]; + for (int i = 0; i < parseResult.ParamValues.Count; i++) + { + if (!parseResult.ParamValues[i].IsSuccess) + return Task.FromResult((IResult)ExecuteResult.FromError(parseResult.ParamValues[i])); + paramList[i] = parseResult.ParamValues[i].Values.First().Value; + } + + return ExecuteAsync(context, argList, paramList, services); + } + public async Task ExecuteAsync(ICommandContext context, IEnumerable argList, IEnumerable paramList, IServiceProvider services) + { + services ??= EmptyServiceProvider.Instance; + + try + { + object[] args = GenerateArgs(argList, paramList); + + for (int position = 0; position < Parameters.Count; position++) + { + var parameter = Parameters[position]; + object argument = args[position]; + var result = await parameter.CheckPreconditionsAsync(context, argument, services).ConfigureAwait(false); + if (!result.IsSuccess) + { + await Module.Service._commandExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + return ExecuteResult.FromError(result); + } + } + + switch (RunMode) + { + case RunMode.Sync: //Always sync + return await ExecuteInternalAsync(context, args, services).ConfigureAwait(false); + case RunMode.Async: //Always async + var t2 = Task.Run(async () => + { + await ExecuteInternalAsync(context, args, services).ConfigureAwait(false); + }); + break; + } + return ExecuteResult.FromSuccess(); + } + catch (Exception ex) + { + return ExecuteResult.FromError(ex); + } + } + + private async Task ExecuteInternalAsync(ICommandContext context, object[] args, IServiceProvider services) + { + await Module.Service._cmdLogger.DebugAsync($"Executing {GetLogText(context)}").ConfigureAwait(false); + try + { + var task = _action(context, args, services, this); + if (task is Task resultTask) + { + var result = await resultTask.ConfigureAwait(false); + await Module.Service._commandExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + if (result is RuntimeResult execResult) + return execResult; + } + else if (task is Task execTask) + { + var result = await execTask.ConfigureAwait(false); + await Module.Service._commandExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + return result; + } + else + { + await task.ConfigureAwait(false); + var result = ExecuteResult.FromSuccess(); + await Module.Service._commandExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + } + + var executeResult = ExecuteResult.FromSuccess(); + return executeResult; + } + catch (Exception ex) + { + var originalEx = ex; + while (ex is TargetInvocationException) //Happens with void-returning commands + ex = ex.InnerException; + + var wrappedEx = new CommandException(this, context, ex); + await Module.Service._cmdLogger.ErrorAsync(wrappedEx).ConfigureAwait(false); + + var result = ExecuteResult.FromError(ex); + await Module.Service._commandExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + + if (Module.Service._throwOnError) + { + if (ex == originalEx) + throw; + else + ExceptionDispatchInfo.Capture(ex).Throw(); + } + + return result; + } + finally + { + await Module.Service._cmdLogger.VerboseAsync($"Executed {GetLogText(context)}").ConfigureAwait(false); + } + } + + private object[] GenerateArgs(IEnumerable argList, IEnumerable paramsList) + { + int argCount = Parameters.Count; + var array = new object[Parameters.Count]; + if (HasVarArgs) + argCount--; + + int i = 0; + foreach (object arg in argList) + { + if (i == argCount) + throw new InvalidOperationException("Command was invoked with too many parameters."); + array[i++] = arg; + } + if (i < argCount) + throw new InvalidOperationException("Command was invoked with too few parameters."); + + if (HasVarArgs) + { + var func = _arrayConverters.GetOrAdd(Parameters[Parameters.Count - 1].Type, t => + { + var method = _convertParamsMethod.MakeGenericMethod(t); + return (Func, object>)method.CreateDelegate(typeof(Func, object>)); + }); + array[i] = func(paramsList); + } + + return array; + } + + private static T[] ConvertParamsList(IEnumerable paramsList) + => paramsList.Cast().ToArray(); + + internal string GetLogText(ICommandContext context) + { + if (context.Guild != null) + return $"\"{Name}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"\"{Name}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Commands/Info/ModuleInfo.cs b/src/Discord.Net.Commands/Info/ModuleInfo.cs new file mode 100644 index 0000000..68d9c73 --- /dev/null +++ b/src/Discord.Net.Commands/Info/ModuleInfo.cs @@ -0,0 +1,147 @@ +using Discord.Commands.Builders; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Commands +{ + /// + /// Provides the information of a module. + /// + public class ModuleInfo + { + /// + /// Gets the command service associated with this module. + /// + public CommandService Service { get; } + /// + /// Gets the name of this module. + /// + public string Name { get; } + /// + /// Gets the summary of this module. + /// + public string Summary { get; } + /// + /// Gets the remarks of this module. + /// + public string Remarks { get; } + /// + /// Gets the group name (main prefix) of this module. + /// + public string Group { get; } + + /// + /// Gets a read-only list of aliases associated with this module. + /// + public IReadOnlyList Aliases { get; } + /// + /// Gets a read-only list of commands associated with this module. + /// + public IReadOnlyList Commands { get; } + /// + /// Gets a read-only list of preconditions that apply to this module. + /// + public IReadOnlyList Preconditions { get; } + /// + /// Gets a read-only list of attributes that apply to this module. + /// + public IReadOnlyList Attributes { get; } + /// + /// Gets a read-only list of submodules associated with this module. + /// + public IReadOnlyList Submodules { get; } + /// + /// Gets the parent module of this submodule if applicable. + /// + public ModuleInfo Parent { get; } + /// + /// Gets a value that indicates whether this module is a submodule or not. + /// + public bool IsSubmodule => Parent != null; + + internal ModuleInfo(ModuleBuilder builder, CommandService service, IServiceProvider services, ModuleInfo parent = null) + { + Service = service; + + Name = builder.Name; + Summary = builder.Summary; + Remarks = builder.Remarks; + Group = builder.Group; + Parent = parent; + + Aliases = BuildAliases(builder, service).ToImmutableArray(); + Commands = builder.Commands.Select(x => x.Build(this, service)).ToImmutableArray(); + Preconditions = BuildPreconditions(builder).ToImmutableArray(); + Attributes = BuildAttributes(builder).ToImmutableArray(); + + Submodules = BuildSubmodules(builder, service, services).ToImmutableArray(); + } + + private static IEnumerable BuildAliases(ModuleBuilder builder, CommandService service) + { + var result = builder.Aliases.ToList(); + var builderQueue = new Queue(); + + var parent = builder; + while ((parent = parent.Parent) != null) + builderQueue.Enqueue(parent); + + while (builderQueue.Count > 0) + { + var level = builderQueue.Dequeue(); + // permute in reverse because we want to *prefix* our aliases + result = level.Aliases.Permutate(result, (first, second) => + { + if (first == "") + return second; + else if (second == "") + return first; + else + return first + service._separatorChar + second; + }).ToList(); + } + + return result; + } + + private List BuildSubmodules(ModuleBuilder parent, CommandService service, IServiceProvider services) + { + var result = new List(); + + foreach (var submodule in parent.Modules) + result.Add(submodule.Build(service, services, this)); + + return result; + } + + private static List BuildPreconditions(ModuleBuilder builder) + { + var result = new List(); + + ModuleBuilder parent = builder; + while (parent != null) + { + result.AddRange(parent.Preconditions); + parent = parent.Parent; + } + + return result; + } + + private static List BuildAttributes(ModuleBuilder builder) + { + var result = new List(); + + ModuleBuilder parent = builder; + while (parent != null) + { + result.AddRange(parent.Attributes); + parent = parent.Parent; + } + + return result; + } + } +} diff --git a/src/Discord.Net.Commands/Info/ParameterInfo.cs b/src/Discord.Net.Commands/Info/ParameterInfo.cs new file mode 100644 index 0000000..85cb641 --- /dev/null +++ b/src/Discord.Net.Commands/Info/ParameterInfo.cs @@ -0,0 +1,99 @@ +using Discord.Commands.Builders; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Provides the information of a parameter. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class ParameterInfo + { + private readonly TypeReader _reader; + + /// + /// Gets the command that associates with this parameter. + /// + public CommandInfo Command { get; } + /// + /// Gets the name of this parameter. + /// + public string Name { get; } + /// + /// Gets the summary of this parameter. + /// + public string Summary { get; } + /// + /// Gets a value that indicates whether this parameter is optional or not. + /// + public bool IsOptional { get; } + /// + /// Gets a value that indicates whether this parameter is a remainder parameter or not. + /// + public bool IsRemainder { get; } + public bool IsMultiple { get; } + /// + /// Gets the type of the parameter. + /// + public Type Type { get; } + /// + /// Gets the default value for this optional parameter if applicable. + /// + public object DefaultValue { get; } + + /// + /// Gets a read-only list of precondition that apply to this parameter. + /// + public IReadOnlyList Preconditions { get; } + /// + /// Gets a read-only list of attributes that apply to this parameter. + /// + public IReadOnlyList Attributes { get; } + + internal ParameterInfo(ParameterBuilder builder, CommandInfo command, CommandService service) + { + Command = command; + + Name = builder.Name; + Summary = builder.Summary; + IsOptional = builder.IsOptional; + IsRemainder = builder.IsRemainder; + IsMultiple = builder.IsMultiple; + + Type = builder.ParameterType; + DefaultValue = builder.DefaultValue; + + Preconditions = builder.Preconditions.ToImmutableArray(); + Attributes = builder.Attributes.ToImmutableArray(); + + _reader = builder.TypeReader; + } + + public async Task CheckPreconditionsAsync(ICommandContext context, object arg, IServiceProvider services = null) + { + services ??= EmptyServiceProvider.Instance; + + foreach (var precondition in Preconditions) + { + var result = await precondition.CheckPermissionsAsync(context, this, arg, services).ConfigureAwait(false); + if (!result.IsSuccess) + return result; + } + + return PreconditionResult.FromSuccess(); + } + + public Task ParseAsync(ICommandContext context, string input, IServiceProvider services = null) + { + services ??= EmptyServiceProvider.Instance; + return _reader.ReadAsync(context, input, services); + } + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name}{(IsOptional ? " (Optional)" : "")}{(IsRemainder ? " (Remainder)" : "")}"; + } +} diff --git a/src/Discord.Net.Commands/Map/CommandMap.cs b/src/Discord.Net.Commands/Map/CommandMap.cs new file mode 100644 index 0000000..141ec6f --- /dev/null +++ b/src/Discord.Net.Commands/Map/CommandMap.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace Discord.Commands +{ + internal class CommandMap + { + private readonly CommandService _service; + private readonly CommandMapNode _root; + private static readonly string[] BlankAliases = { "" }; + + public CommandMap(CommandService service) + { + _service = service; + _root = new CommandMapNode(""); + } + + public void AddCommand(CommandInfo command) + { + foreach (string text in command.Aliases) + _root.AddCommand(_service, text, 0, command); + } + public void RemoveCommand(CommandInfo command) + { + foreach (string text in command.Aliases) + _root.RemoveCommand(_service, text, 0, command); + } + + public IEnumerable GetCommands(string text) + { + return _root.GetCommands(_service, text, 0, text != ""); + } + } +} diff --git a/src/Discord.Net.Commands/Map/CommandMapNode.cs b/src/Discord.Net.Commands/Map/CommandMapNode.cs new file mode 100644 index 0000000..16f469c --- /dev/null +++ b/src/Discord.Net.Commands/Map/CommandMapNode.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Commands +{ + internal class CommandMapNode + { + private static readonly char[] WhitespaceChars = { ' ', '\r', '\n' }; + + private readonly ConcurrentDictionary _nodes; + private readonly string _name; + private readonly object _lockObj = new object(); + private ImmutableArray _commands; + + public bool IsEmpty => _commands.Length == 0 && _nodes.Count == 0; + + public CommandMapNode(string name) + { + _name = name; + _nodes = new ConcurrentDictionary(); + _commands = ImmutableArray.Create(); + } + + /// Cannot add commands to the root node. + public void AddCommand(CommandService service, string text, int index, CommandInfo command) + { + int nextSegment = NextSegment(text, index, service._separatorChar); + string name; + + lock (_lockObj) + { + if (text == "") + { + if (_name == "") + throw new InvalidOperationException("Cannot add commands to the root node."); + _commands = _commands.Add(command); + } + else + { + if (nextSegment == -1) + name = text.Substring(index); + else + name = text.Substring(index, nextSegment - index); + + string fullName = _name == "" ? name : _name + service._separatorChar + name; + var nextNode = _nodes.GetOrAdd(name, x => new CommandMapNode(fullName)); + nextNode.AddCommand(service, nextSegment == -1 ? "" : text, nextSegment + 1, command); + } + } + } + public void RemoveCommand(CommandService service, string text, int index, CommandInfo command) + { + int nextSegment = NextSegment(text, index, service._separatorChar); + + lock (_lockObj) + { + if (text == "") + _commands = _commands.Remove(command); + else + { + string name; + if (nextSegment == -1) + name = text.Substring(index); + else + name = text.Substring(index, nextSegment - index); + + if (_nodes.TryGetValue(name, out var nextNode)) + { + nextNode.RemoveCommand(service, nextSegment == -1 ? "" : text, nextSegment + 1, command); + if (nextNode.IsEmpty) + _nodes.TryRemove(name, out nextNode); + } + } + } + } + + public IEnumerable GetCommands(CommandService service, string text, int index, bool visitChildren = true) + { + var commands = _commands; + for (int i = 0; i < commands.Length; i++) + yield return new CommandMatch(_commands[i], _name); + + if (visitChildren) + { + string name; + CommandMapNode nextNode; + + //Search for next segment + int nextSegment = NextSegment(text, index, service._separatorChar); + if (nextSegment == -1) + name = text.Substring(index); + else + name = text.Substring(index, nextSegment - index); + if (_nodes.TryGetValue(name, out nextNode)) + { + foreach (var cmd in nextNode.GetCommands(service, nextSegment == -1 ? "" : text, nextSegment + 1, true)) + yield return cmd; + } + + //Check if this is the last command segment before args + nextSegment = NextSegment(text, index, WhitespaceChars, service._separatorChar); + if (nextSegment != -1) + { + name = text.Substring(index, nextSegment - index); + if (_nodes.TryGetValue(name, out nextNode)) + { + foreach (var cmd in nextNode.GetCommands(service, nextSegment == -1 ? "" : text, nextSegment + 1, false)) + yield return cmd; + } + } + } + } + + private static int NextSegment(string text, int startIndex, char separator) + { + return text.IndexOf(separator, startIndex); + } + private static int NextSegment(string text, int startIndex, char[] separators, char except) + { + int lowest = int.MaxValue; + for (int i = 0; i < separators.Length; i++) + { + if (separators[i] != except) + { + int index = text.IndexOf(separators[i], startIndex); + if (index != -1 && index < lowest) + lowest = index; + } + } + return (lowest != int.MaxValue) ? lowest : -1; + } + } +} diff --git a/src/Discord.Net.Commands/ModuleBase.cs b/src/Discord.Net.Commands/ModuleBase.cs new file mode 100644 index 0000000..fded148 --- /dev/null +++ b/src/Discord.Net.Commands/ModuleBase.cs @@ -0,0 +1,98 @@ +using Discord.Commands.Builders; +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Provides a base class for a command module to inherit from. + /// + public abstract class ModuleBase : ModuleBase { } + + /// + /// Provides a base class for a command module to inherit from. + /// + /// A class that implements . + public abstract class ModuleBase : IModuleBase + where T : class, ICommandContext + { + #region ModuleBase + /// + /// The underlying context of the command. + /// + /// + /// + public T Context { get; private set; } + + /// + /// Sends a message to the source channel. + /// + /// + /// Contents of the message; optional only if is specified. + /// + /// Specifies if Discord should read this aloud using text-to-speech. + /// An embed to be displayed alongside the . + /// + /// Specifies if notifications are sent for mentioned users and roles in the . + /// If , all mentioned roles and users will be notified. + /// + /// The request options for this request. + /// The message references to be included. Used to reply to specific messages. + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// Message flags combined as a bitfield. + protected virtual Task ReplyAsync(string message = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, + AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => Context.Channel.SendMessageAsync(message, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags); + + /// + /// The method to execute asynchronously before executing the command. + /// + /// The of the command to be executed. + protected virtual Task BeforeExecuteAsync(CommandInfo command) => Task.CompletedTask; + /// + /// The method to execute before executing the command. + /// + /// The of the command to be executed. + protected virtual void BeforeExecute(CommandInfo command) + { + } + /// + /// The method to execute asynchronously after executing the command. + /// + /// The of the command to be executed. + protected virtual Task AfterExecuteAsync(CommandInfo command) => Task.CompletedTask; + /// + /// The method to execute after executing the command. + /// + /// The of the command to be executed. + protected virtual void AfterExecute(CommandInfo command) + { + } + + /// + /// The method to execute when building the module. + /// + /// The used to create the module. + /// The builder used to build the module. + protected virtual void OnModuleBuilding(CommandService commandService, ModuleBuilder builder) + { + } + #endregion + + #region IModuleBase + void IModuleBase.SetContext(ICommandContext context) + { + var newValue = context as T; + Context = newValue ?? throw new InvalidOperationException($"Invalid context type. Expected {typeof(T).Name}, got {context.GetType().Name}."); + } + Task IModuleBase.BeforeExecuteAsync(CommandInfo command) => BeforeExecuteAsync(command); + void IModuleBase.BeforeExecute(CommandInfo command) => BeforeExecute(command); + Task IModuleBase.AfterExecuteAsync(CommandInfo command) => AfterExecuteAsync(command); + void IModuleBase.AfterExecute(CommandInfo command) => AfterExecute(command); + void IModuleBase.OnModuleBuilding(CommandService commandService, ModuleBuilder builder) => OnModuleBuilding(commandService, builder); + #endregion + } +} diff --git a/src/Discord.Net.Commands/MultiMatchHandling.cs b/src/Discord.Net.Commands/MultiMatchHandling.cs new file mode 100644 index 0000000..319e58e --- /dev/null +++ b/src/Discord.Net.Commands/MultiMatchHandling.cs @@ -0,0 +1,13 @@ +namespace Discord.Commands +{ + /// + /// Specifies the behavior when multiple matches are found during the command parsing stage. + /// + public enum MultiMatchHandling + { + /// Indicates that when multiple results are found, an exception should be thrown. + Exception, + /// Indicates that when multiple results are found, the best result should be chosen. + Best + } +} diff --git a/src/Discord.Net.Commands/PrimitiveParsers.cs b/src/Discord.Net.Commands/PrimitiveParsers.cs new file mode 100644 index 0000000..e9b6aac --- /dev/null +++ b/src/Discord.Net.Commands/PrimitiveParsers.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Commands +{ + internal delegate bool TryParseDelegate(string str, out T value); + + internal static class PrimitiveParsers + { + private static readonly Lazy> Parsers = new Lazy>(CreateParsers); + + public static IEnumerable SupportedTypes = Parsers.Value.Keys; + + static IReadOnlyDictionary CreateParsers() + { + var parserBuilder = ImmutableDictionary.CreateBuilder(); + parserBuilder[typeof(bool)] = (TryParseDelegate)bool.TryParse; + parserBuilder[typeof(sbyte)] = (TryParseDelegate)sbyte.TryParse; + parserBuilder[typeof(byte)] = (TryParseDelegate)byte.TryParse; + parserBuilder[typeof(short)] = (TryParseDelegate)short.TryParse; + parserBuilder[typeof(ushort)] = (TryParseDelegate)ushort.TryParse; + parserBuilder[typeof(int)] = (TryParseDelegate)int.TryParse; + parserBuilder[typeof(uint)] = (TryParseDelegate)uint.TryParse; + parserBuilder[typeof(long)] = (TryParseDelegate)long.TryParse; + parserBuilder[typeof(ulong)] = (TryParseDelegate)ulong.TryParse; + parserBuilder[typeof(float)] = (TryParseDelegate)float.TryParse; + parserBuilder[typeof(double)] = (TryParseDelegate)double.TryParse; + parserBuilder[typeof(decimal)] = (TryParseDelegate)decimal.TryParse; + parserBuilder[typeof(DateTime)] = (TryParseDelegate)DateTime.TryParse; + parserBuilder[typeof(DateTimeOffset)] = (TryParseDelegate)DateTimeOffset.TryParse; + //parserBuilder[typeof(TimeSpan)] = (TryParseDelegate)TimeSpan.TryParse; + parserBuilder[typeof(char)] = (TryParseDelegate)char.TryParse; + return parserBuilder.ToImmutable(); + } + + public static TryParseDelegate Get() => (TryParseDelegate)Parsers.Value[typeof(T)]; + public static Delegate Get(Type type) => Parsers.Value[type]; + } +} diff --git a/src/Discord.Net.Commands/Program.cs b/src/Discord.Net.Commands/Program.cs deleted file mode 100644 index 3751555..0000000 --- a/src/Discord.Net.Commands/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/src/Discord.Net.Commands/Readers/ChannelTypeReader.cs b/src/Discord.Net.Commands/Readers/ChannelTypeReader.cs new file mode 100644 index 0000000..cdbc59c --- /dev/null +++ b/src/Discord.Net.Commands/Readers/ChannelTypeReader.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// A for parsing objects implementing . + /// + /// + /// This is shipped with Discord.Net and is used by default to parse any + /// implemented object within a command. The TypeReader will attempt to first parse the + /// input by mention, then the snowflake identifier, then by name; the highest candidate will be chosen as the + /// final output; otherwise, an erroneous is returned. + /// + /// The type to be checked; must implement . + public class ChannelTypeReader : TypeReader + where T : class, IChannel + { + /// + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (context.Guild != null) + { + var results = new Dictionary(); + var channels = await context.Guild.GetChannelsAsync(CacheMode.CacheOnly).ConfigureAwait(false); + + //By Mention (1.0) + if (MentionUtils.TryParseChannel(input, out ulong id)) + AddResult(results, await context.Guild.GetChannelAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 1.00f); + + //By Id (0.9) + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id)) + AddResult(results, await context.Guild.GetChannelAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 0.90f); + + //By Name (0.7-0.8) + foreach (var channel in channels.Where(x => string.Equals(input, x.Name, StringComparison.OrdinalIgnoreCase))) + AddResult(results, channel as T, channel.Name == input ? 0.80f : 0.70f); + + if (results.Count > 0) + return TypeReaderResult.FromSuccess(results.Values.ToReadOnlyCollection()); + } + + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Channel not found."); + } + + private void AddResult(Dictionary results, T channel, float score) + { + if (channel != null && !results.ContainsKey(channel.Id)) + results.Add(channel.Id, new TypeReaderValue(channel, score)); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/EnumTypeReader.cs b/src/Discord.Net.Commands/Readers/EnumTypeReader.cs new file mode 100644 index 0000000..ba84375 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/EnumTypeReader.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal static class EnumTypeReader + { + public static TypeReader GetReader(Type type) + { + Type baseType = Enum.GetUnderlyingType(type); + var constructor = typeof(EnumTypeReader<>).MakeGenericType(baseType).GetTypeInfo().DeclaredConstructors.First(); + return (TypeReader)constructor.Invoke(new object[] { type, PrimitiveParsers.Get(baseType) }); + } + } + + internal class EnumTypeReader : TypeReader + { + private readonly IReadOnlyDictionary _enumsByName; + private readonly IReadOnlyDictionary _enumsByValue; + private readonly Type _enumType; + private readonly TryParseDelegate _tryParse; + + public EnumTypeReader(Type type, TryParseDelegate parser) + { + _enumType = type; + _tryParse = parser; + + var byNameBuilder = ImmutableDictionary.CreateBuilder(); + var byValueBuilder = ImmutableDictionary.CreateBuilder(); + + foreach (var v in Enum.GetNames(_enumType)) + { + var parsedValue = Enum.Parse(_enumType, v); + byNameBuilder.Add(v.ToLower(), parsedValue); + if (!byValueBuilder.ContainsKey((T)parsedValue)) + byValueBuilder.Add((T)parsedValue, parsedValue); + } + + _enumsByName = byNameBuilder.ToImmutable(); + _enumsByValue = byValueBuilder.ToImmutable(); + } + + /// + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + object enumValue; + + if (_tryParse(input, out T baseValue)) + { + if (_enumsByValue.TryGetValue(baseValue, out enumValue)) + return Task.FromResult(TypeReaderResult.FromSuccess(enumValue)); + else + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, $"Value is not a {_enumType.Name}.")); + } + else + { + if (_enumsByName.TryGetValue(input.ToLower(), out enumValue)) + return Task.FromResult(TypeReaderResult.FromSuccess(enumValue)); + else + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, $"Value is not a {_enumType.Name}.")); + } + } + } +} diff --git a/src/Discord.Net.Commands/Readers/MessageTypeReader.cs b/src/Discord.Net.Commands/Readers/MessageTypeReader.cs new file mode 100644 index 0000000..acec2f1 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/MessageTypeReader.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// A for parsing objects implementing . + /// + /// The type to be checked; must implement . + public class MessageTypeReader : TypeReader + where T : class, IMessage + { + /// + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + //By Id (1.0) + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out ulong id)) + { + if (await context.Channel.GetMessageAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) is T msg) + return TypeReaderResult.FromSuccess(msg); + } + + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "Message not found."); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs b/src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs new file mode 100644 index 0000000..65e8e27 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/NamedArgumentTypeReader.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal sealed class NamedArgumentTypeReader : TypeReader + where T : class, new() + { + private static readonly IReadOnlyDictionary _tProps = typeof(T).GetTypeInfo().DeclaredProperties + .Where(p => p.SetMethod != null && p.SetMethod.IsPublic && !p.SetMethod.IsStatic) + .ToImmutableDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); + + private readonly CommandService _commands; + + public NamedArgumentTypeReader(CommandService commands) + { + _commands = commands; + } + + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + var result = new T(); + var state = ReadState.LookingForParameter; + int beginRead = 0, currentRead = 0; + + while (state != ReadState.End) + { + try + { + var prop = Read(out var arg); + var propVal = await ReadArgumentAsync(prop, arg).ConfigureAwait(false); + if (propVal != null) + prop.SetMethod.Invoke(result, new[] { propVal }); + else + return TypeReaderResult.FromError(CommandError.ParseFailed, $"Could not parse the argument for the parameter '{prop.Name}' as type '{prop.PropertyType}'."); + } + catch (Exception ex) + { + return TypeReaderResult.FromError(ex); + } + } + + return TypeReaderResult.FromSuccess(result); + + PropertyInfo Read(out string arg) + { + string currentParam = null; + char match = '\0'; + + for (; currentRead < input.Length; currentRead++) + { + var currentChar = input[currentRead]; + switch (state) + { + case ReadState.LookingForParameter: + if (Char.IsWhiteSpace(currentChar)) + continue; + else + { + beginRead = currentRead; + state = ReadState.InParameter; + } + break; + case ReadState.InParameter: + if (currentChar != ':') + continue; + else + { + currentParam = input.Substring(beginRead, currentRead - beginRead); + state = ReadState.LookingForArgument; + } + break; + case ReadState.LookingForArgument: + if (Char.IsWhiteSpace(currentChar)) + continue; + else + { + beginRead = currentRead; + state = (QuotationAliasUtils.GetDefaultAliasMap.TryGetValue(currentChar, out match)) + ? ReadState.InQuotedArgument + : ReadState.InArgument; + } + break; + case ReadState.InArgument: + if (!Char.IsWhiteSpace(currentChar)) + continue; + else + return GetPropAndValue(out arg); + case ReadState.InQuotedArgument: + if (currentChar != match) + continue; + else + return GetPropAndValue(out arg); + } + } + + if (currentParam == null) + throw new InvalidOperationException("No parameter name was read."); + + return GetPropAndValue(out arg); + + PropertyInfo GetPropAndValue(out string argv) + { + bool quoted = state == ReadState.InQuotedArgument; + state = (currentRead == (quoted ? input.Length - 1 : input.Length)) + ? ReadState.End + : ReadState.LookingForParameter; + + if (quoted) + { + argv = input.Substring(beginRead + 1, currentRead - beginRead - 1).Trim(); + currentRead++; + } + else + argv = input.Substring(beginRead, currentRead - beginRead); + + return _tProps[currentParam]; + } + } + + async Task ReadArgumentAsync(PropertyInfo prop, string arg) + { + var elemType = prop.PropertyType; + bool isCollection = false; + if (elemType.GetTypeInfo().IsGenericType && elemType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + elemType = prop.PropertyType.GenericTypeArguments[0]; + isCollection = true; + } + + var overridden = prop.GetCustomAttribute(); + var reader = (overridden != null) + ? ModuleClassBuilder.GetTypeReader(_commands, elemType, overridden.TypeReader, services) + : (_commands.GetDefaultTypeReader(elemType) + ?? _commands.GetTypeReaders(elemType).FirstOrDefault().Value); + + if (reader != null) + { + if (isCollection) + { + var method = _readMultipleMethod.MakeGenericMethod(elemType); + var task = (Task)method.Invoke(null, new object[] { reader, context, arg.Split(','), services }); + return await task.ConfigureAwait(false); + } + else + return await ReadSingle(reader, context, arg, services).ConfigureAwait(false); + } + return null; + } + } + + private static async Task ReadSingle(TypeReader reader, ICommandContext context, string arg, IServiceProvider services) + { + var readResult = await reader.ReadAsync(context, arg, services).ConfigureAwait(false); + return (readResult.IsSuccess) + ? readResult.BestMatch + : null; + } + private static async Task ReadMultiple(TypeReader reader, ICommandContext context, IEnumerable args, IServiceProvider services) + { + var objs = new List(); + foreach (var arg in args) + { + var read = await ReadSingle(reader, context, arg.Trim(), services).ConfigureAwait(false); + if (read != null) + objs.Add((TObj)read); + } + return objs.ToImmutableArray(); + } + private static readonly MethodInfo _readMultipleMethod = typeof(NamedArgumentTypeReader) + .GetTypeInfo() + .DeclaredMethods + .Single(m => m.IsPrivate && m.IsStatic && m.Name == nameof(ReadMultiple)); + + private enum ReadState + { + LookingForParameter, + InParameter, + LookingForArgument, + InArgument, + InQuotedArgument, + End + } + } +} diff --git a/src/Discord.Net.Commands/Readers/NullableTypeReader.cs b/src/Discord.Net.Commands/Readers/NullableTypeReader.cs new file mode 100644 index 0000000..bae3150 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/NullableTypeReader.cs @@ -0,0 +1,35 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal static class NullableTypeReader + { + public static TypeReader Create(Type type, TypeReader reader) + { + var constructor = typeof(NullableTypeReader<>).MakeGenericType(type).GetTypeInfo().DeclaredConstructors.First(); + return (TypeReader)constructor.Invoke(new object[] { reader }); + } + } + + internal class NullableTypeReader : TypeReader + where T : struct + { + private readonly TypeReader _baseTypeReader; + + public NullableTypeReader(TypeReader baseTypeReader) + { + _baseTypeReader = baseTypeReader; + } + + /// + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (string.Equals(input, "null", StringComparison.OrdinalIgnoreCase) || string.Equals(input, "nothing", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(TypeReaderResult.FromSuccess(new T?())); + return _baseTypeReader.ReadAsync(context, input, services); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/PrimitiveTypeReader.cs b/src/Discord.Net.Commands/Readers/PrimitiveTypeReader.cs new file mode 100644 index 0000000..cb74139 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/PrimitiveTypeReader.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal static class PrimitiveTypeReader + { + public static TypeReader Create(Type type) + { + type = typeof(PrimitiveTypeReader<>).MakeGenericType(type); + return Activator.CreateInstance(type) as TypeReader; + } + } + + internal class PrimitiveTypeReader : TypeReader + { + private readonly TryParseDelegate _tryParse; + private readonly float _score; + + /// must be within the range [0, 1]. + public PrimitiveTypeReader() + : this(PrimitiveParsers.Get(), 1) + { } + + /// must be within the range [0, 1]. + public PrimitiveTypeReader(TryParseDelegate tryParse, float score) + { + if (score < 0 || score > 1) + throw new ArgumentOutOfRangeException(nameof(score), score, "Scores must be within the range [0, 1]."); + + _tryParse = tryParse; + _score = score; + } + + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (_tryParse(input, out T value)) + return Task.FromResult(TypeReaderResult.FromSuccess(new TypeReaderValue(value, _score))); + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, $"Failed to parse {typeof(T).Name}.")); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/RoleTypeReader.cs b/src/Discord.Net.Commands/Readers/RoleTypeReader.cs new file mode 100644 index 0000000..ebaa366 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/RoleTypeReader.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// A for parsing objects implementing . + /// + /// The type to be checked; must implement . + public class RoleTypeReader : TypeReader + where T : class, IRole + { + /// + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (context.Guild != null) + { + var results = new Dictionary(); + var roles = context.Guild.Roles; + + //By Mention (1.0) + if (MentionUtils.TryParseRole(input, out var id)) + AddResult(results, context.Guild.GetRole(id) as T, 1.00f); + + //By Id (0.9) + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id)) + AddResult(results, context.Guild.GetRole(id) as T, 0.90f); + + //By Name (0.7-0.8) + foreach (var role in roles.Where(x => string.Equals(input, x.Name, StringComparison.OrdinalIgnoreCase))) + AddResult(results, role as T, role.Name == input ? 0.80f : 0.70f); + + if (results.Count > 0) + return Task.FromResult(TypeReaderResult.FromSuccess(results.Values.ToReadOnlyCollection())); + } + return Task.FromResult(TypeReaderResult.FromError(CommandError.ObjectNotFound, "Role not found.")); + } + + private void AddResult(Dictionary results, T role, float score) + { + if (role != null && !results.ContainsKey(role.Id)) + results.Add(role.Id, new TypeReaderValue(role, score)); + } + } +} diff --git a/src/Discord.Net.Commands/Readers/TimeSpanTypeReader.cs b/src/Discord.Net.Commands/Readers/TimeSpanTypeReader.cs new file mode 100644 index 0000000..5448553 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/TimeSpanTypeReader.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + internal class TimeSpanTypeReader : TypeReader + { + /// + /// TimeSpan try parse formats. + /// + private static readonly string[] Formats = + { + "%d'd'%h'h'%m'm'%s's'", // 4d3h2m1s + "%d'd'%h'h'%m'm'", // 4d3h2m + "%d'd'%h'h'%s's'", // 4d3h 1s + "%d'd'%h'h'", // 4d3h + "%d'd'%m'm'%s's'", // 4d 2m1s + "%d'd'%m'm'", // 4d 2m + "%d'd'%s's'", // 4d 1s + "%d'd'", // 4d + "%h'h'%m'm'%s's'", // 3h2m1s + "%h'h'%m'm'", // 3h2m + "%h'h'%s's'", // 3h 1s + "%h'h'", // 3h + "%m'm'%s's'", // 2m1s + "%m'm'", // 2m + "%s's'", // 1s + }; + + /// + public override Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + if (string.IsNullOrEmpty(input)) + throw new ArgumentException(message: $"{nameof(input)} must not be null or empty.", paramName: nameof(input)); + + var isNegative = input[0] == '-'; // Char for CultureInfo.InvariantCulture.NumberFormat.NegativeSign + if (isNegative) + { + input = input.Substring(1); + } + + if (TimeSpan.TryParseExact(input.ToLowerInvariant(), Formats, CultureInfo.InvariantCulture, out var timeSpan)) + { + return isNegative + ? Task.FromResult(TypeReaderResult.FromSuccess(-timeSpan)) + : Task.FromResult(TypeReaderResult.FromSuccess(timeSpan)); + } + else + { + return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Failed to parse TimeSpan")); + } + } + } +} diff --git a/src/Discord.Net.Commands/Readers/TypeReader.cs b/src/Discord.Net.Commands/Readers/TypeReader.cs new file mode 100644 index 0000000..af78099 --- /dev/null +++ b/src/Discord.Net.Commands/Readers/TypeReader.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// Defines a reader class that parses user input into a specified type. + /// + public abstract class TypeReader + { + /// + /// Attempts to parse the into the desired type. + /// + /// The context of the command. + /// The raw input of the command. + /// The service collection used for dependency injection. + /// + /// A task that represents the asynchronous parsing operation. The task result contains the parsing result. + /// + public abstract Task ReadAsync(ICommandContext context, string input, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Commands/Readers/UserTypeReader.cs b/src/Discord.Net.Commands/Readers/UserTypeReader.cs new file mode 100644 index 0000000..741aa6f --- /dev/null +++ b/src/Discord.Net.Commands/Readers/UserTypeReader.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Commands +{ + /// + /// A for parsing objects implementing . + /// + /// The type to be checked; must implement . + public class UserTypeReader : TypeReader + where T : class, IUser + { + /// + public override async Task ReadAsync(ICommandContext context, string input, IServiceProvider services) + { + var results = new Dictionary(); + IAsyncEnumerable channelUsers = context.Channel.GetUsersAsync(CacheMode.CacheOnly).Flatten(); // it's better + IReadOnlyCollection guildUsers = ImmutableArray.Create(); + + if (context.Guild != null) + guildUsers = await context.Guild.GetUsersAsync(CacheMode.CacheOnly).ConfigureAwait(false); + + //By Mention (1.0) + if (MentionUtils.TryParseUser(input, out var id)) + { + if (context.Guild != null) + AddResult(results, await context.Guild.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 1.00f); + else + AddResult(results, await context.Channel.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 1.00f); + } + + //By Id (0.9) + if (ulong.TryParse(input, NumberStyles.None, CultureInfo.InvariantCulture, out id)) + { + if (context.Guild != null) + AddResult(results, await context.Guild.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 0.90f); + else + AddResult(results, await context.Channel.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T, 0.90f); + } + + //By Username + Discriminator (0.7-0.85) + int index = input.LastIndexOf('#'); + if (index >= 0) + { + string username = input.Substring(0, index); + if (ushort.TryParse(input.Substring(index + 1), out ushort discriminator)) + { + var channelUser = await channelUsers.FirstOrDefaultAsync(x => x.DiscriminatorValue == discriminator && + string.Equals(username, x.Username, StringComparison.OrdinalIgnoreCase)).ConfigureAwait(false); + AddResult(results, channelUser as T, channelUser?.Username == username ? 0.85f : 0.75f); + + var guildUser = guildUsers.FirstOrDefault(x => x.DiscriminatorValue == discriminator && + string.Equals(username, x.Username, StringComparison.OrdinalIgnoreCase)); + AddResult(results, guildUser as T, guildUser?.Username == username ? 0.80f : 0.70f); + } + } + + //By global (display) name + { + await channelUsers + .Where(x => string.Equals(input, x.GlobalName, StringComparison.OrdinalIgnoreCase)) + .ForEachAsync(channelUser => AddResult(results, channelUser as T, channelUser.GlobalName == input ? 0.65f : 0.55f)) + .ConfigureAwait(false); + + foreach (var guildUser in guildUsers.Where(x => string.Equals(input, x.GlobalName, StringComparison.OrdinalIgnoreCase))) + AddResult(results, guildUser as T, guildUser.GlobalName == input ? 0.60f : 0.50f); + } + + //By Username (0.5-0.6) + { + await channelUsers + .Where(x => string.Equals(input, x.Username, StringComparison.OrdinalIgnoreCase)) + .ForEachAsync(channelUser => AddResult(results, channelUser as T, channelUser.Username == input ? 0.65f : 0.55f)) + .ConfigureAwait(false); + + foreach (var guildUser in guildUsers.Where(x => string.Equals(input, x.Username, StringComparison.OrdinalIgnoreCase))) + AddResult(results, guildUser as T, guildUser.Username == input ? 0.60f : 0.50f); + } + + //By Nickname (0.5-0.6) + { + await channelUsers + .Where(x => string.Equals(input, (x as IGuildUser)?.Nickname, StringComparison.OrdinalIgnoreCase)) + .ForEachAsync(channelUser => AddResult(results, channelUser as T, (channelUser as IGuildUser).Nickname == input ? 0.65f : 0.55f)) + .ConfigureAwait(false); + + foreach (var guildUser in guildUsers.Where(x => string.Equals(input, x.Nickname, StringComparison.OrdinalIgnoreCase))) + AddResult(results, guildUser as T, guildUser.Nickname == input ? 0.60f : 0.50f); + } + + if (results.Count > 0) + return TypeReaderResult.FromSuccess(results.Values.ToImmutableArray()); + return TypeReaderResult.FromError(CommandError.ObjectNotFound, "User not found."); + } + + private void AddResult(Dictionary results, T user, float score) + { + if (user != null && !results.ContainsKey(user.Id)) + results.Add(user.Id, new TypeReaderValue(user, score)); + } + } +} diff --git a/src/Discord.Net.Commands/Results/ExecuteResult.cs b/src/Discord.Net.Commands/Results/ExecuteResult.cs new file mode 100644 index 0000000..ba12ced --- /dev/null +++ b/src/Discord.Net.Commands/Results/ExecuteResult.cs @@ -0,0 +1,85 @@ +using System; +using System.Diagnostics; + +namespace Discord.Commands +{ + /// + /// Contains information of the command's overall execution result. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct ExecuteResult : IResult + { + /// + /// Gets the exception that may have occurred during the command execution. + /// + public Exception Exception { get; } + + /// + public CommandError? Error { get; } + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private ExecuteResult(Exception exception, CommandError? error, string errorReason) + { + Exception = exception; + Error = error; + ErrorReason = errorReason; + } + + /// + /// Initializes a new with no error, indicating a successful execution. + /// + /// + /// A that does not contain any errors. + /// + public static ExecuteResult FromSuccess() + => new ExecuteResult(null, null, null); + /// + /// Initializes a new with a specified and its + /// reason, indicating an unsuccessful execution. + /// + /// The type of error. + /// The reason behind the error. + /// + /// A that contains a and reason. + /// + public static ExecuteResult FromError(CommandError error, string reason) + => new ExecuteResult(null, error, reason); + /// + /// Initializes a new with a specified exception, indicating an unsuccessful + /// execution. + /// + /// The exception that caused the command execution to fail. + /// + /// A that contains the exception that caused the unsuccessful execution, along + /// with a of type Exception as well as the exception message as the + /// reason. + /// + public static ExecuteResult FromError(Exception ex) + => new ExecuteResult(ex, CommandError.Exception, ex.Message); + /// + /// Initializes a new with a specified result; this may or may not be an + /// successful execution depending on the and + /// specified. + /// + /// The result to inherit from. + /// + /// A that inherits the error type and reason. + /// + public static ExecuteResult FromError(IResult result) + => new ExecuteResult(null, result.Error, result.ErrorReason); + + /// + /// Gets a string that indicates the execution result. + /// + /// + /// Success if is ; otherwise ": + /// ". + /// + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/IResult.cs b/src/Discord.Net.Commands/Results/IResult.cs new file mode 100644 index 0000000..65e944b --- /dev/null +++ b/src/Discord.Net.Commands/Results/IResult.cs @@ -0,0 +1,31 @@ +namespace Discord.Commands +{ + /// + /// Contains information of the result related to a command. + /// + public interface IResult + { + /// + /// Describes the error type that may have occurred during the operation. + /// + /// + /// A indicating the type of error that may have occurred during the operation; + /// if the operation was successful. + /// + CommandError? Error { get; } + /// + /// Describes the reason for the error. + /// + /// + /// A string containing the error reason. + /// + string ErrorReason { get; } + /// + /// Indicates whether the operation was successful or not. + /// + /// + /// if the result is positive; otherwise . + /// + bool IsSuccess { get; } + } +} diff --git a/src/Discord.Net.Commands/Results/MatchResult.cs b/src/Discord.Net.Commands/Results/MatchResult.cs new file mode 100644 index 0000000..56d6138 --- /dev/null +++ b/src/Discord.Net.Commands/Results/MatchResult.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class MatchResult : IResult + { + /// + /// Gets the command that may have matched during the command execution. + /// + public CommandMatch? Match { get; } + + /// + /// Gets on which pipeline stage the command may have matched or failed. + /// + public IResult Pipeline { get; } + + /// + public CommandError? Error { get; } + /// + public string ErrorReason { get; } + /// + public bool IsSuccess => !Error.HasValue; + + private MatchResult(CommandMatch? match, IResult pipeline, CommandError? error, string errorReason) + { + Match = match; + Error = error; + Pipeline = pipeline; + ErrorReason = errorReason; + } + + public static MatchResult FromSuccess(CommandMatch match, IResult pipeline) + => new MatchResult(match, pipeline, null, null); + public static MatchResult FromError(CommandError error, string reason) + => new MatchResult(null, null, error, reason); + public static MatchResult FromError(Exception ex) + => FromError(CommandError.Exception, ex.Message); + public static MatchResult FromError(IResult result) + => new MatchResult(null, null, result.Error, result.ErrorReason); + public static MatchResult FromError(IResult pipeline, CommandError error, string reason) + => new MatchResult(null, pipeline, error, reason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + + } +} diff --git a/src/Discord.Net.Commands/Results/ParseResult.cs b/src/Discord.Net.Commands/Results/ParseResult.cs new file mode 100644 index 0000000..d4662c1 --- /dev/null +++ b/src/Discord.Net.Commands/Results/ParseResult.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord.Commands +{ + /// + /// Contains information for the parsing result from the command service's parser. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct ParseResult : IResult + { + public IReadOnlyList ArgValues { get; } + public IReadOnlyList ParamValues { get; } + + /// + public CommandError? Error { get; } + /// + public string ErrorReason { get; } + + /// + /// Provides information about the parameter that caused the parsing error. + /// + /// + /// A indicating the parameter info of the error that may have occurred during parsing; + /// if the parsing was successful or the parsing error is not specific to a single parameter. + /// + public ParameterInfo ErrorParameter { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private ParseResult(IReadOnlyList argValues, IReadOnlyList paramValues, CommandError? error, string errorReason, ParameterInfo errorParamInfo) + { + ArgValues = argValues; + ParamValues = paramValues; + Error = error; + ErrorReason = errorReason; + ErrorParameter = errorParamInfo; + } + + public static ParseResult FromSuccess(IReadOnlyList argValues, IReadOnlyList paramValues) + { + for (int i = 0; i < argValues.Count; i++) + { + if (argValues[i].Values.Count > 1) + return new ParseResult(argValues, paramValues, CommandError.MultipleMatches, "Multiple matches found.", null); + } + for (int i = 0; i < paramValues.Count; i++) + { + if (paramValues[i].Values.Count > 1) + return new ParseResult(argValues, paramValues, CommandError.MultipleMatches, "Multiple matches found.", null); + } + return new ParseResult(argValues, paramValues, null, null, null); + } + public static ParseResult FromSuccess(IReadOnlyList argValues, IReadOnlyList paramValues) + { + var argList = new TypeReaderResult[argValues.Count]; + for (int i = 0; i < argValues.Count; i++) + argList[i] = TypeReaderResult.FromSuccess(argValues[i]); + TypeReaderResult[] paramList = null; + if (paramValues != null) + { + paramList = new TypeReaderResult[paramValues.Count]; + for (int i = 0; i < paramValues.Count; i++) + paramList[i] = TypeReaderResult.FromSuccess(paramValues[i]); + } + return new ParseResult(argList, paramList, null, null, null); + } + + public static ParseResult FromError(CommandError error, string reason) + => new ParseResult(null, null, error, reason, null); + public static ParseResult FromError(CommandError error, string reason, ParameterInfo parameterInfo) + => new ParseResult(null, null, error, reason, parameterInfo); + public static ParseResult FromError(Exception ex) + => FromError(CommandError.Exception, ex.Message); + public static ParseResult FromError(IResult result) + => new ParseResult(null, null, result.Error, result.ErrorReason, null); + public static ParseResult FromError(IResult result, ParameterInfo parameterInfo) + => new ParseResult(null, null, result.Error, result.ErrorReason, parameterInfo); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? $"Success ({ArgValues.Count}{(ParamValues.Count > 0 ? $" +{ParamValues.Count} Values" : "")})" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/PreconditionGroupResult.cs b/src/Discord.Net.Commands/Results/PreconditionGroupResult.cs new file mode 100644 index 0000000..7ecade4 --- /dev/null +++ b/src/Discord.Net.Commands/Results/PreconditionGroupResult.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class PreconditionGroupResult : PreconditionResult + { + public IReadOnlyCollection PreconditionResults { get; } + + protected PreconditionGroupResult(CommandError? error, string errorReason, ICollection preconditions) + : base(error, errorReason) + { + PreconditionResults = (preconditions ?? new List(0)).ToReadOnlyCollection(); + } + + public new static PreconditionGroupResult FromSuccess() + => new PreconditionGroupResult(null, null, null); + public static PreconditionGroupResult FromError(string reason, ICollection preconditions) + => new PreconditionGroupResult(CommandError.UnmetPrecondition, reason, preconditions); + public static new PreconditionGroupResult FromError(Exception ex) + => new PreconditionGroupResult(CommandError.Exception, ex.Message, null); + public static new PreconditionGroupResult FromError(IResult result) //needed? + => new PreconditionGroupResult(result.Error, result.ErrorReason, null); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/PreconditionResult.cs b/src/Discord.Net.Commands/Results/PreconditionResult.cs new file mode 100644 index 0000000..d8e399a --- /dev/null +++ b/src/Discord.Net.Commands/Results/PreconditionResult.cs @@ -0,0 +1,59 @@ +using System; +using System.Diagnostics; + +namespace Discord.Commands +{ + /// + /// Represents a result type for command preconditions. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class PreconditionResult : IResult + { + /// + public CommandError? Error { get; } + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + /// + /// Initializes a new class with the command type + /// and reason. + /// + /// The type of failure. + /// The reason of failure. + protected PreconditionResult(CommandError? error, string errorReason) + { + Error = error; + ErrorReason = errorReason; + } + + /// + /// Returns a with no errors. + /// + public static PreconditionResult FromSuccess() + => new PreconditionResult(null, null); + /// + /// Returns a with and the + /// specified reason. + /// + /// The reason of failure. + public static PreconditionResult FromError(string reason) + => new PreconditionResult(CommandError.UnmetPrecondition, reason); + public static PreconditionResult FromError(Exception ex) + => new PreconditionResult(CommandError.Exception, ex.Message); + /// + /// Returns a with the specified type. + /// + /// The result of failure. + public static PreconditionResult FromError(IResult result) + => new PreconditionResult(result.Error, result.ErrorReason); + + /// + /// Returns a string indicating whether the is successful. + /// + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/RuntimeResult.cs b/src/Discord.Net.Commands/Results/RuntimeResult.cs new file mode 100644 index 0000000..a7febd6 --- /dev/null +++ b/src/Discord.Net.Commands/Results/RuntimeResult.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public abstract class RuntimeResult : IResult + { + /// + /// Initializes a new class with the type of error and reason. + /// + /// The type of failure, or if none. + /// The reason of failure. + protected RuntimeResult(CommandError? error, string reason) + { + Error = error; + Reason = reason; + } + + /// + public CommandError? Error { get; } + /// Describes the execution reason or result. + public string Reason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + /// + string IResult.ErrorReason => Reason; + + public override string ToString() => Reason ?? (IsSuccess ? "Successful" : "Unsuccessful"); + private string DebuggerDisplay => IsSuccess ? $"Success: {Reason ?? "No Reason"}" : $"{Error}: {Reason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/SearchResult.cs b/src/Discord.Net.Commands/Results/SearchResult.cs new file mode 100644 index 0000000..d1f1ea0 --- /dev/null +++ b/src/Discord.Net.Commands/Results/SearchResult.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct SearchResult : IResult + { + public string Text { get; } + public IReadOnlyList Commands { get; } + + /// + public CommandError? Error { get; } + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private SearchResult(string text, IReadOnlyList commands, CommandError? error, string errorReason) + { + Text = text; + Commands = commands; + Error = error; + ErrorReason = errorReason; + } + + public static SearchResult FromSuccess(string text, IReadOnlyList commands) + => new SearchResult(text, commands, null, null); + public static SearchResult FromError(CommandError error, string reason) + => new SearchResult(null, null, error, reason); + public static SearchResult FromError(Exception ex) + => FromError(CommandError.Exception, ex.Message); + public static SearchResult FromError(IResult result) + => new SearchResult(null, null, result.Error, result.ErrorReason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? $"Success ({Commands.Count} Results)" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/Results/TypeReaderResult.cs b/src/Discord.Net.Commands/Results/TypeReaderResult.cs new file mode 100644 index 0000000..2fbdb45 --- /dev/null +++ b/src/Discord.Net.Commands/Results/TypeReaderResult.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; + +namespace Discord.Commands +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct TypeReaderValue + { + public object Value { get; } + public float Score { get; } + + public TypeReaderValue(object value, float score) + { + Value = value; + Score = score; + } + + public override string ToString() => Value?.ToString(); + private string DebuggerDisplay => $"[{Value}, {Math.Round(Score, 2)}]"; + } + + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct TypeReaderResult : IResult + { + public IReadOnlyCollection Values { get; } + + /// + public CommandError? Error { get; } + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + /// TypeReaderResult was not successful. + public object BestMatch => IsSuccess + ? (Values.Count == 1 ? Values.Single().Value : Values.OrderByDescending(v => v.Score).First().Value) + : throw new InvalidOperationException("TypeReaderResult was not successful."); + + private TypeReaderResult(IReadOnlyCollection values, CommandError? error, string errorReason) + { + Values = values; + Error = error; + ErrorReason = errorReason; + } + + public static TypeReaderResult FromSuccess(object value) + => new TypeReaderResult(ImmutableArray.Create(new TypeReaderValue(value, 1.0f)), null, null); + public static TypeReaderResult FromSuccess(TypeReaderValue value) + => new TypeReaderResult(ImmutableArray.Create(value), null, null); + public static TypeReaderResult FromSuccess(IReadOnlyCollection values) + => new TypeReaderResult(values, null, null); + public static TypeReaderResult FromError(CommandError error, string reason) + => new TypeReaderResult(null, error, reason); + public static TypeReaderResult FromError(Exception ex) + => FromError(CommandError.Exception, ex.Message); + public static TypeReaderResult FromError(IResult result) + => new TypeReaderResult(null, result.Error, result.ErrorReason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + private string DebuggerDisplay => IsSuccess ? $"Success ({string.Join(", ", Values)})" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Commands/RunMode.cs b/src/Discord.Net.Commands/RunMode.cs new file mode 100644 index 0000000..d6b4906 --- /dev/null +++ b/src/Discord.Net.Commands/RunMode.cs @@ -0,0 +1,23 @@ +namespace Discord.Commands +{ + /// + /// Specifies the behavior of the command execution workflow. + /// + /// + /// + public enum RunMode + { + /// + /// The default behavior set in . + /// + Default, + /// + /// Executes the command on the same thread as gateway one. + /// + Sync, + /// + /// Executes the command on a different thread from the gateway one. + /// + Async + } +} diff --git a/src/Discord.Net.Commands/Utilities/QuotationAliasUtils.cs b/src/Discord.Net.Commands/Utilities/QuotationAliasUtils.cs new file mode 100644 index 0000000..2612e99 --- /dev/null +++ b/src/Discord.Net.Commands/Utilities/QuotationAliasUtils.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; + +namespace Discord.Commands +{ + /// + /// Utility class which contains the default matching pairs of quotation marks for CommandServiceConfig + /// + internal static class QuotationAliasUtils + { + /// + /// A default map of open-close pairs of quotation marks. + /// Contains many regional and Unicode equivalents. + /// Used in the . + /// + /// + internal static Dictionary GetDefaultAliasMap + { + get + { + // Output of a gist provided by https://gist.github.com/ufcpp + // https://gist.github.com/ufcpp/5b2cf9a9bf7d0b8743714a0b88f7edc5 + // This was not used for the implementation because of incompatibility with netstandard1.1 + return new Dictionary { + {'\"', '\"' }, + {'«', '»' }, + {'‘', '’' }, + {'“', '”' }, + {'„', '‟' }, + {'‹', '›' }, + {'‚', '‛' }, + {'《', '》' }, + {'〈', '〉' }, + {'「', '」' }, + {'『', '』' }, + {'〝', '〞' }, + {'﹁', '﹂' }, + {'﹃', '﹄' }, + {'"', '"' }, + {''', ''' }, + {'「', '」' }, + {'(', ')' }, + {'༺', '༻' }, + {'༼', '༽' }, + {'᚛', '᚜' }, + {'⁅', '⁆' }, + {'⌈', '⌉' }, + {'⌊', '⌋' }, + {'❨', '❩' }, + {'❪', '❫' }, + {'❬', '❭' }, + {'❮', '❯' }, + {'❰', '❱' }, + {'❲', '❳' }, + {'❴', '❵' }, + {'⟅', '⟆' }, + {'⟦', '⟧' }, + {'⟨', '⟩' }, + {'⟪', '⟫' }, + {'⟬', '⟭' }, + {'⟮', '⟯' }, + {'⦃', '⦄' }, + {'⦅', '⦆' }, + {'⦇', '⦈' }, + {'⦉', '⦊' }, + {'⦋', '⦌' }, + {'⦍', '⦎' }, + {'⦏', '⦐' }, + {'⦑', '⦒' }, + {'⦓', '⦔' }, + {'⦕', '⦖' }, + {'⦗', '⦘' }, + {'⧘', '⧙' }, + {'⧚', '⧛' }, + {'⧼', '⧽' }, + {'⸂', '⸃' }, + {'⸄', '⸅' }, + {'⸉', '⸊' }, + {'⸌', '⸍' }, + {'⸜', '⸝' }, + {'⸠', '⸡' }, + {'⸢', '⸣' }, + {'⸤', '⸥' }, + {'⸦', '⸧' }, + {'⸨', '⸩' }, + {'【', '】'}, + {'〔', '〕' }, + {'〖', '〗' }, + {'〘', '〙' }, + {'〚', '〛' } + }; + } + } + } +} diff --git a/src/Discord.Net.Commands/Utilities/ReflectionUtils.cs b/src/Discord.Net.Commands/Utilities/ReflectionUtils.cs new file mode 100644 index 0000000..ec17be9 --- /dev/null +++ b/src/Discord.Net.Commands/Utilities/ReflectionUtils.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Discord.Commands +{ + internal static class ReflectionUtils + { + private static readonly TypeInfo ObjectTypeInfo = typeof(object).GetTypeInfo(); + + internal static T CreateObject(TypeInfo typeInfo, CommandService commands, IServiceProvider services = null) + => CreateBuilder(typeInfo, commands)(services); + internal static Func CreateBuilder(TypeInfo typeInfo, CommandService commands) + { + var constructor = GetConstructor(typeInfo); + var parameters = constructor.GetParameters(); + var properties = GetProperties(typeInfo); + + return (services) => + { + var args = new object[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + args[i] = GetMember(commands, services, parameters[i].ParameterType, typeInfo); + var obj = InvokeConstructor(constructor, args, typeInfo); + + foreach (var property in properties) + property.SetValue(obj, GetMember(commands, services, property.PropertyType, typeInfo)); + return obj; + }; + } + private static T InvokeConstructor(ConstructorInfo constructor, object[] args, TypeInfo ownerType) + { + try + { + return (T)constructor.Invoke(args); + } + catch (Exception ex) + { + throw new Exception($"Failed to create \"{ownerType.FullName}\".", ex); + } + } + + private static ConstructorInfo GetConstructor(TypeInfo ownerType) + { + var constructors = ownerType.DeclaredConstructors.Where(x => !x.IsStatic).ToArray(); + if (constructors.Length == 0) + throw new InvalidOperationException($"No constructor found for \"{ownerType.FullName}\"."); + else if (constructors.Length > 1) + throw new InvalidOperationException($"Multiple constructors found for \"{ownerType.FullName}\"."); + return constructors[0]; + } + private static PropertyInfo[] GetProperties(TypeInfo ownerType) + { + var result = new List(); + while (ownerType != ObjectTypeInfo) + { + foreach (var prop in ownerType.DeclaredProperties) + { + if (prop.SetMethod?.IsStatic == false && prop.SetMethod?.IsPublic == true && prop.GetCustomAttribute() == null) + result.Add(prop); + } + ownerType = ownerType.BaseType.GetTypeInfo(); + } + return result.ToArray(); + } + private static object GetMember(CommandService commands, IServiceProvider services, Type memberType, TypeInfo ownerType) + { + if (memberType == typeof(CommandService)) + return commands; + if (memberType == typeof(IServiceProvider) || memberType == services.GetType()) + return services; + var service = services.GetService(memberType); + if (service != null) + return service; + throw new InvalidOperationException($"Failed to create \"{ownerType.FullName}\", dependency \"{memberType.Name}\" was not found."); + } + } +} diff --git a/src/Discord.Net.Core/AssemblyInfo.cs b/src/Discord.Net.Core/AssemblyInfo.cs new file mode 100644 index 0000000..0031c2d --- /dev/null +++ b/src/Discord.Net.Core/AssemblyInfo.cs @@ -0,0 +1,11 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Discord.Net.Relay")] +[assembly: InternalsVisibleTo("Discord.Net.Rest")] +[assembly: InternalsVisibleTo("Discord.Net.Rpc")] +[assembly: InternalsVisibleTo("Discord.Net.WebSocket")] +[assembly: InternalsVisibleTo("Discord.Net.Webhook")] +[assembly: InternalsVisibleTo("Discord.Net.Commands")] +[assembly: InternalsVisibleTo("Discord.Net.Tests")] +[assembly: InternalsVisibleTo("Discord.Net.Tests.Unit")] +[assembly: InternalsVisibleTo("Discord.Net.Interactions")] diff --git a/src/Discord.Net.Core/Audio/AudioApplication.cs b/src/Discord.Net.Core/Audio/AudioApplication.cs new file mode 100644 index 0000000..3d45966 --- /dev/null +++ b/src/Discord.Net.Core/Audio/AudioApplication.cs @@ -0,0 +1,9 @@ +namespace Discord.Audio +{ + public enum AudioApplication : int + { + Voice, + Music, + Mixed + } +} diff --git a/src/Discord.Net.Core/Audio/AudioInStream.cs b/src/Discord.Net.Core/Audio/AudioInStream.cs new file mode 100644 index 0000000..b795097 --- /dev/null +++ b/src/Discord.Net.Core/Audio/AudioInStream.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + public abstract class AudioInStream : AudioStream + { + public abstract int AvailableFrames { get; } + + public override bool CanRead => true; + public override bool CanWrite => true; + + public abstract Task ReadFrameAsync(CancellationToken cancelToken); + public abstract bool TryReadFrame(CancellationToken cancelToken, out RTPFrame frame); + + public override Task FlushAsync(CancellationToken cancelToken) { throw new NotSupportedException(); } + } +} diff --git a/src/Discord.Net.Core/Audio/AudioOutStream.cs b/src/Discord.Net.Core/Audio/AudioOutStream.cs new file mode 100644 index 0000000..cbc3167 --- /dev/null +++ b/src/Discord.Net.Core/Audio/AudioOutStream.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; + +namespace Discord.Audio +{ + public abstract class AudioOutStream : AudioStream + { + public override bool CanWrite => true; + + /// + /// Reading this stream is not supported. + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + /// + /// Setting the length to this stream is not supported. + public override void SetLength(long value) => + throw new NotSupportedException(); + /// + /// Seeking this stream is not supported.. + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + } +} diff --git a/src/Discord.Net.Core/Audio/AudioStream.cs b/src/Discord.Net.Core/Audio/AudioStream.cs new file mode 100644 index 0000000..ad2985f --- /dev/null +++ b/src/Discord.Net.Core/Audio/AudioStream.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + public abstract class AudioStream : Stream + { + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => false; + + /// This stream does not accept headers. + public virtual void WriteHeader(ushort seq, uint timestamp, bool missed) => + throw new InvalidOperationException("This stream does not accept headers."); + public override void Write(byte[] buffer, int offset, int count) + { + WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + } + public override void Flush() + { + FlushAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + public void Clear() + { + ClearAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + + public virtual Task ClearAsync(CancellationToken cancellationToken) { return Task.Delay(0); } + + /// + /// Reading stream length is not supported. + public override long Length => + throw new NotSupportedException(); + + /// + /// Getting or setting this stream position is not supported. + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + /// + /// Reading this stream is not supported. + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + /// + /// Setting the length to this stream is not supported. + public override void SetLength(long value) => + throw new NotSupportedException(); + /// + /// Seeking this stream is not supported.. + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + } +} diff --git a/src/Discord.Net.Core/Audio/IAudioClient.cs b/src/Discord.Net.Core/Audio/IAudioClient.cs new file mode 100644 index 0000000..1fc34b4 --- /dev/null +++ b/src/Discord.Net.Core/Audio/IAudioClient.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + public interface IAudioClient : IDisposable + { + event Func Connected; + event Func Disconnected; + event Func LatencyUpdated; + event Func UdpLatencyUpdated; + event Func StreamCreated; + event Func StreamDestroyed; + event Func SpeakingUpdated; + event Func ClientDisconnected; + + /// Gets the current connection state of this client. + ConnectionState ConnectionState { get; } + /// Gets the estimated round-trip latency, in milliseconds, to the voice WebSocket server. + int Latency { get; } + /// Gets the estimated round-trip latency, in milliseconds, to the voice UDP server. + int UdpLatency { get; } + + /// Gets the current audio streams. + IReadOnlyDictionary GetStreams(); + + Task StopAsync(); + Task SetSpeakingAsync(bool value); + + /// Creates a new outgoing stream accepting Opus-encoded data. + AudioOutStream CreateOpusStream(int bufferMillis = 1000); + /// Creates a new outgoing stream accepting Opus-encoded data. This is a direct stream with no internal timer. + AudioOutStream CreateDirectOpusStream(); + /// Creates a new outgoing stream accepting PCM (raw) data. + AudioOutStream CreatePCMStream(AudioApplication application, int? bitrate = null, int bufferMillis = 1000, int packetLoss = 30); + /// Creates a new direct outgoing stream accepting PCM (raw) data. This is a direct stream with no internal timer. + AudioOutStream CreateDirectPCMStream(AudioApplication application, int? bitrate = null, int packetLoss = 30); + } +} diff --git a/src/Discord.Net.Core/Audio/RTPFrame.cs b/src/Discord.Net.Core/Audio/RTPFrame.cs new file mode 100644 index 0000000..3c5211a --- /dev/null +++ b/src/Discord.Net.Core/Audio/RTPFrame.cs @@ -0,0 +1,18 @@ +namespace Discord.Audio +{ + public struct RTPFrame + { + public readonly ushort Sequence; + public readonly uint Timestamp; + public readonly byte[] Payload; + public readonly bool Missed; + + public RTPFrame(ushort sequence, uint timestamp, byte[] payload, bool missed) + { + Sequence = sequence; + Timestamp = timestamp; + Payload = payload; + Missed = missed; + } + } +} diff --git a/src/Discord.Net.Core/CDN.cs b/src/Discord.Net.Core/CDN.cs new file mode 100644 index 0000000..ab381ce --- /dev/null +++ b/src/Discord.Net.Core/CDN.cs @@ -0,0 +1,274 @@ +using System; + +namespace Discord +{ + /// + /// Represents a class containing the strings related to various Content Delivery Networks (CDNs). + /// + public static class CDN + { + /// + /// Returns a team icon URL. + /// + /// The team identifier. + /// The icon identifier. + /// + /// A URL pointing to the team's icon. + /// + public static string GetTeamIconUrl(ulong teamId, string iconId) + => iconId != null ? $"{DiscordConfig.CDNUrl}team-icons/{teamId}/{iconId}.jpg" : null; + + /// + /// Returns an application icon URL. + /// + /// The application identifier. + /// The icon identifier. + /// + /// A URL pointing to the application's icon. + /// + public static string GetApplicationIconUrl(ulong appId, string iconId) + => iconId != null ? $"{DiscordConfig.CDNUrl}app-icons/{appId}/{iconId}.jpg" : null; + + /// + /// Returns a user avatar URL. + /// + /// The user snowflake identifier. + /// The avatar identifier. + /// The size of the image to return in horizontal pixels. This can be any power of two between 16 and 2048. + /// The format to return. + /// + /// A URL pointing to the user's avatar in the specified size. + /// + public static string GetUserAvatarUrl(ulong userId, string avatarId, ushort size, ImageFormat format) + { + if (avatarId == null) + return null; + string extension = FormatToExtension(format, avatarId); + return $"{DiscordConfig.CDNUrl}avatars/{userId}/{avatarId}.{extension}?size={size}"; + } + + public static string GetGuildUserAvatarUrl(ulong userId, ulong guildId, string avatarId, ushort size, ImageFormat format) + { + if (avatarId == null) + return null; + string extension = FormatToExtension(format, avatarId); + return $"{DiscordConfig.CDNUrl}guilds/{guildId}/users/{userId}/avatars/{avatarId}.{extension}?size={size}"; + } + + /// + /// Returns a user banner URL. + /// + /// The user snowflake identifier. + /// The banner identifier. + /// The size of the image to return in horizontal pixels. This can be any power of two between 16 and 2048. + /// The format to return. + /// + /// A URL pointing to the user's banner in the specified size. + /// + public static string GetUserBannerUrl(ulong userId, string bannerId, ushort size, ImageFormat format) + { + if (bannerId == null) + return null; + string extension = FormatToExtension(format, bannerId); + return $"{DiscordConfig.CDNUrl}banners/{userId}/{bannerId}.{extension}?size={size}"; + } + + /// + /// Returns the default user avatar URL. + /// + /// The discriminator value of a user. + /// + /// A URL pointing to the user's default avatar when one isn't set. + /// + public static string GetDefaultUserAvatarUrl(ushort discriminator) + { + return $"{DiscordConfig.CDNUrl}embed/avatars/{discriminator % 5}.png"; + } + + /// + /// Returns the default user avatar URL. + /// + /// The Id of a user. + /// + /// A URL pointing to the user's default avatar when one isn't set. + /// + public static string GetDefaultUserAvatarUrl(ulong userId) + { + return $"{DiscordConfig.CDNUrl}embed/avatars/{(userId >> 22) % 6}.png"; + } + + /// + /// Returns an icon URL. + /// + /// The guild snowflake identifier. + /// The icon identifier. + /// The size of the image to return in horizontal pixels. This can be any power of two between 16 and 2048. + /// + /// A URL pointing to the guild's icon. + /// + public static string GetGuildIconUrl(ulong guildId, string iconId, ushort size = 2048) + => iconId != null ? $"{DiscordConfig.CDNUrl}icons/{guildId}/{iconId}.jpg?size={size}" : null; + /// + /// Returns a guild role's icon URL. + /// + /// The role identifier. + /// The icon hash. + /// + /// A URL pointing to the guild role's icon. + /// + public static string GetGuildRoleIconUrl(ulong roleId, string roleHash) + => roleHash != null ? $"{DiscordConfig.CDNUrl}role-icons/{roleId}/{roleHash}.png" : null; + /// + /// Returns a guild splash URL. + /// + /// The guild snowflake identifier. + /// The splash icon identifier. + /// + /// A URL pointing to the guild's splash. + /// + public static string GetGuildSplashUrl(ulong guildId, string splashId) + => splashId != null ? $"{DiscordConfig.CDNUrl}splashes/{guildId}/{splashId}.jpg" : null; + /// + /// Returns a guild discovery splash URL. + /// + /// The guild snowflake identifier. + /// The discovery splash icon identifier. + /// + /// A URL pointing to the guild's discovery splash. + /// + public static string GetGuildDiscoverySplashUrl(ulong guildId, string discoverySplashId) + => discoverySplashId != null ? $"{DiscordConfig.CDNUrl}discovery-splashes/{guildId}/{discoverySplashId}.jpg" : null; + /// + /// Returns a channel icon URL. + /// + /// The channel snowflake identifier. + /// The icon identifier. + /// + /// A URL pointing to the channel's icon. + /// + public static string GetChannelIconUrl(ulong channelId, string iconId) + => iconId != null ? $"{DiscordConfig.CDNUrl}channel-icons/{channelId}/{iconId}.jpg" : null; + + /// + /// Returns a guild banner URL. + /// + /// The guild snowflake identifier. + /// The banner image identifier. + /// The format to return. + /// The size of the image to return in horizontal pixels. This can be any power of two between 16 and 2048 inclusive. + /// + /// A URL pointing to the guild's banner image. + /// + public static string GetGuildBannerUrl(ulong guildId, string bannerId, ImageFormat format, ushort? size = null) + { + if (string.IsNullOrEmpty(bannerId)) + return null; + string extension = FormatToExtension(format, bannerId); + return $"{DiscordConfig.CDNUrl}banners/{guildId}/{bannerId}.{extension}" + (size.HasValue ? $"?size={size}" : string.Empty); + } + /// + /// Returns an emoji URL. + /// + /// The emoji snowflake identifier. + /// Whether this emoji is animated. + /// + /// A URL pointing to the custom emote. + /// + public static string GetEmojiUrl(ulong emojiId, bool animated) + => $"{DiscordConfig.CDNUrl}emojis/{emojiId}.{(animated ? "gif" : "png")}"; + + /// + /// Returns a Rich Presence asset URL. + /// + /// The application identifier. + /// The asset identifier. + /// The size of the image to return in. This can be any power of two between 16 and 2048. + /// The format to return. + /// + /// A URL pointing to the asset image in the specified size. + /// + public static string GetRichAssetUrl(ulong appId, string assetId, ushort size, ImageFormat format) + { + string extension = FormatToExtension(format, ""); + return $"{DiscordConfig.CDNUrl}app-assets/{appId}/{assetId}.{extension}?size={size}"; + } + + /// + /// Returns a Spotify album URL. + /// + /// The identifier for the album art (e.g. 6be8f4c8614ecf4f1dd3ebba8d8692d8ce4951ac). + /// + /// A URL pointing to the Spotify album art. + /// + public static string GetSpotifyAlbumArtUrl(string albumArtId) + => $"https://i.scdn.co/image/{albumArtId}"; + /// + /// Returns a Spotify direct URL for a track. + /// + /// The identifier for the track (e.g. 4uLU6hMCjMI75M1A2tKUQC). + /// + /// A URL pointing to the Spotify track. + /// + public static string GetSpotifyDirectUrl(string trackId) + => $"https://open.spotify.com/track/{trackId}"; + + /// + /// Gets a stickers url based off the id and format. + /// + /// The id of the sticker. + /// The format of the sticker. + /// + /// A URL to the sticker. + /// + public static string GetStickerUrl(ulong stickerId, StickerFormatType format = StickerFormatType.Png) + => $"{DiscordConfig.CDNUrl}stickers/{stickerId}.{FormatToExtension(format)}"; + + /// + /// Returns an events cover image url. if the assetId . + /// + /// The guild id that the event is in. + /// The id of the event. + /// The id of the cover image asset. + /// The format of the image. + /// The size of the image. + /// + public static string GetEventCoverImageUrl(ulong guildId, ulong eventId, string assetId, ImageFormat format = ImageFormat.Auto, ushort size = 1024) + => string.IsNullOrEmpty(assetId) + ? null + : $"{DiscordConfig.CDNUrl}guild-events/{eventId}/{assetId}.{FormatToExtension(format, assetId)}?size={size}"; + + private static string FormatToExtension(StickerFormatType format) + { + return format switch + { + StickerFormatType.None or StickerFormatType.Png or StickerFormatType.Apng => "png", // In the case of the Sticker endpoint, the sticker will be available as PNG if its format_type is PNG or APNG, and as Lottie if its format_type is LOTTIE. + StickerFormatType.Lottie => "lottie", + _ => throw new ArgumentException(nameof(format)), + }; + } + + private static string FormatToExtension(ImageFormat format, string imageId) + { + if (format == ImageFormat.Auto) + format = imageId.StartsWith("a_") ? ImageFormat.Gif : ImageFormat.Png; + return format switch + { + ImageFormat.Gif => "gif", + ImageFormat.Jpeg => "jpeg", + ImageFormat.Png => "png", + ImageFormat.WebP => "webp", + _ => throw new ArgumentException(nameof(format)), + }; + } + + /// + /// Gets an avatar decoration url based off the hash. + /// + /// The hash of the avatar decoraition. + /// + /// A URL to the avatar decoration. + /// + public static string GetAvatarDecorationUrl(string avatarDecorationHash) + => $"{DiscordConfig.CDNUrl}avatar-decoration-presets/{avatarDecorationHash}.png"; + } +} diff --git a/src/Discord.Net.Core/Commands/ICommandContext.cs b/src/Discord.Net.Core/Commands/ICommandContext.cs new file mode 100644 index 0000000..d56eb38 --- /dev/null +++ b/src/Discord.Net.Core/Commands/ICommandContext.cs @@ -0,0 +1,29 @@ +namespace Discord.Commands +{ + /// + /// Represents a context of a command. This may include the client, guild, channel, user, and message. + /// + public interface ICommandContext + { + /// + /// Gets the that the command is executed with. + /// + IDiscordClient Client { get; } + /// + /// Gets the that the command is executed in. + /// + IGuild Guild { get; } + /// + /// Gets the that the command is executed in. + /// + IMessageChannel Channel { get; } + /// + /// Gets the who executed the command. + /// + IUser User { get; } + /// + /// Gets the that the command is interpreted from. + /// + IUserMessage Message { get; } + } +} diff --git a/src/Discord.Net.Core/ConnectionState.cs b/src/Discord.Net.Core/ConnectionState.cs new file mode 100644 index 0000000..fadbc40 --- /dev/null +++ b/src/Discord.Net.Core/ConnectionState.cs @@ -0,0 +1,15 @@ +namespace Discord +{ + /// Specifies the connection state of a client. + public enum ConnectionState : byte + { + /// The client has disconnected from Discord. + Disconnected, + /// The client is connecting to Discord. + Connecting, + /// The client has established a connection to Discord. + Connected, + /// The client is disconnecting from Discord. + Disconnecting + } +} diff --git a/src/Discord.Net.Core/DiscordConfig.cs b/src/Discord.Net.Core/DiscordConfig.cs index 5d4857d..ac703a0 100644 --- a/src/Discord.Net.Core/DiscordConfig.cs +++ b/src/Discord.Net.Core/DiscordConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Reflection; using System.Threading.Tasks; @@ -251,10 +251,10 @@ namespace Discord /// Returns the maximum length of a voice channel status. /// public const int MaxVoiceChannelStatusLength = 500; - + /// /// Returns the maximum number of entitlements that can be gotten per-batch. /// public const int MaxEntitlementsPerBatch = 100; } -} \ No newline at end of file +} diff --git a/src/Discord.Net.Core/DiscordErrorCode.cs b/src/Discord.Net.Core/DiscordErrorCode.cs new file mode 100644 index 0000000..37e469f --- /dev/null +++ b/src/Discord.Net.Core/DiscordErrorCode.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a set of json error codes received by discord. + /// + public enum DiscordErrorCode + { + GeneralError = 0, + + #region UnknownXYZ (10XXX) + UnknownAccount = 10001, + UnknownApplication = 10002, + UnknownChannel = 10003, + UnknownGuild = 10004, + UnknownIntegration = 10005, + UnknownInvite = 10006, + UnknownMember = 10007, + UnknownMessage = 10008, + UnknownPermissionOverwrite = 10009, + UnknownProvider = 10010, + UnknownRole = 10011, + UnknownToken = 10012, + UnknownUser = 10013, + UnknownEmoji = 10014, + UnknownWebhook = 10015, + UnknownWebhookService = 10016, + UnknownSession = 10020, + UnknownBan = 10026, + UnknownSKU = 10027, + UnknownStoreListing = 10028, + UnknownEntitlement = 10029, + UnknownBuild = 10030, + UnknownLobby = 10031, + UnknownBranch = 10032, + UnknownStoreDirectoryLayout = 10033, + UnknownRedistributable = 10036, + UnknownGiftCode = 10038, + UnknownStream = 10049, + UnknownPremiumServerSubscribeCooldown = 10050, + UnknownGuildTemplate = 10057, + UnknownDiscoverableServerCategory = 10059, + UnknownSticker = 10060, + UnknownInteraction = 10062, + UnknownApplicationCommand = 10063, + UnknownVoiceState = 10065, + UnknownApplicationCommandPermissions = 10066, + UnknownStageInstance = 10067, + UnknownGuildMemberVerificationForm = 10068, + UnknownGuildWelcomeScreen = 10069, + UnknownGuildScheduledEvent = 10070, + UnknownGuildScheduledEventUser = 10071, + #endregion + + #region General Actions (20XXX) + UnknownTag = 10087, + BotsCannotUse = 20001, + OnlyBotsCanUse = 20002, + CannotSendExplicitContent = 20009, + ApplicationActionUnauthorized = 20012, + ActionSlowmode = 20016, + OnlyOwnerAction = 20018, + AnnouncementEditRatelimit = 20022, + UnderMinimumAge = 20024, + ChannelWriteRatelimit = 20028, + WriteRatelimitReached = 20029, + WordsNotAllowed = 20031, + GuildPremiumTooLow = 20035, + #endregion + + #region Numeric Limits Reached (30XXX) + MaximumGuildsReached = 30001, + MaximumFriendsReached = 30002, + MaximumPinsReached = 30003, + MaximumRecipientsReached = 30004, + MaximumGuildRolesReached = 30005, + MaximumWebhooksReached = 30007, + MaximumEmojisReached = 30008, + MaximumReactionsReached = 30010, + MaximumNumberOfGDMsReached = 30011, + MaximumGuildChannelsReached = 30013, + MaximumAttachmentsReached = 30015, + MaximumInvitesReached = 30016, + MaximumAnimatedEmojisReached = 30018, + MaximumServerMembersReached = 30019, + MaximumServerCategoriesReached = 30030, + GuildTemplateAlreadyExists = 30031, + MaximumNumberOfApplicationCommandsReached = 30032, + MaximumThreadMembersReached = 30033, + MaxNumberOfDailyApplicationCommandCreatesHasBeenReached = 30034, + MaximumBansForNonGuildMembersReached = 30035, + MaximumBanFetchesReached = 30037, + MaximumUncompletedGuildScheduledEvents = 30038, + MaximumStickersReached = 30039, + MaximumPruneRequestReached = 30040, + MaximumGuildWidgetsReached = 30042, + BitrateIsTooHighForChannelOfThisType = 30052, + MaximumNumberOfEditsReached = 30046, + MaximumNumberOfPinnedThreadsInAForumChannelReached = 30047, + MaximumNumberOfTagsInAForumChannelReached = 30048, + MaximumNumberOfPremiumEmojisReached = 30056, + MaximumNumberOfWebhooksReached = 30058, + MaximumNumberOfChannelPermissionOverwritesReached = 30060, + TheChannelsForThisGuildAreTooLarge = 30061, + #endregion + + #region General Request Errors (40XXX) + TokenUnauthorized = 40001, + InvalidVerification = 40002, + OpeningDMTooFast = 40003, + SendMessagesHasBeenTemporarilyDisabled = 40004, + RequestEntityTooLarge = 40005, + FeatureDisabled = 40006, + UserBanned = 40007, + ConnectionHasBeenRevoked = 40012, + TargetUserNotInVoice = 40032, + MessageAlreadyCrossposted = 40033, + ApplicationNameAlreadyExists = 40041, + ApplicationInteractionFailedToSend = 40043, + CannotSendAMessageInAForumChannel = 40058, + InteractionHasAlreadyBeenAcknowledged = 40060, + TagNamesMustBeUnique = 40061, + ServiceResourceIsBeingRateLimited = 40062, + ThereAreNoTagsAvailableThatCanBeSetByNonModerators = 40066, + ATagIsRequiredToCreateAForumPostInThisChannel = 40067, + #endregion + + #region Action Preconditions/Checks (50XXX) + MissingPermissions = 50001, + InvalidAccountType = 50002, + CannotExecuteForDM = 50003, + GuildWidgetDisabled = 50004, + CannotEditOtherUsersMessage = 50005, + CannotSendEmptyMessage = 50006, + CannotSendMessageToUser = 50007, + CannotSendMessageToVoiceChannel = 50008, + ChannelVerificationTooHigh = 50009, + OAuth2ApplicationDoesntHaveBot = 50010, + OAuth2ApplicationLimitReached = 50011, + InvalidOAuth2State = 50012, + InsufficientPermissions = 50013, + InvalidAuthenticationToken = 50014, + NoteTooLong = 50015, + ProvidedMessageDeleteCountOutOfBounds = 50016, + InvalidMFALevel = 50017, + InvalidPinChannel = 50019, + InvalidInvite = 50020, + CannotExecuteOnSystemMessage = 50021, + CannotExecuteOnChannelType = 50024, + InvalidOAuth2Token = 50025, + MissingOAuth2Scope = 50026, + InvalidWebhookToken = 50027, + InvalidRole = 50028, + InvalidRecipients = 50033, + BulkDeleteMessageTooOld = 50034, + InvalidFormBody = 50035, + InviteAcceptedForGuildThatBotIsntIn = 50036, + InvalidActivityAction = 50039, + InvalidAPIVersion = 50041, + FileUploadTooBig = 50045, + InvalidFileUpload = 50046, + CannotSelfRedeemGift = 50054, + InvalidGuild = 50055, + InvalidRequestOrigin = 50067, + InvalidMessageType = 50068, + PaymentSourceRequiredForGift = 50070, + CannotModifySystemWebhook = 50073, + CannotDeleteRequiredCommunityChannel = 50074, + CannotEditStickersWithinAMessage = 50080, + InvalidSticker = 50081, + CannotExecuteOnArchivedThread = 50083, + InvalidThreadNotificationSettings = 50084, + BeforeValueEarlierThanThreadCreation = 50085, + CommunityServerChannelsMustBeTextChannels = 50086, + TheEntityTypeOfTheEventIsDifferentFromTheEntityYouAreTryingToStartTheEventFor = 50091, + ServerLocaleUnavailable = 50095, + ServerRequiresMonetization = 50097, + ServerRequiresBoosts = 50101, + RequestBodyContainsInvalidJSON = 50109, + OwnershipCannotBeTransferredToABotUser = 50132, + FailedToResizeAssetBelowTheMaximumSize = 50138, + CannotMixSubscriptionAndNonSubscriptionRolesForAnEmoji = 50144, + CannotConvertBetweenPremiumEmojiAndNormalEmoji = 50145, + UploadedFileNotFound = 50146, + FeatureInProcessOfRollingOut = 50155, + CannotSendVoiceMessageInThisChannel = 50173, + MissingPermissionToSendThisSticker = 50600, + #endregion + + #region 2FA (60XXX) + Requires2FA = 60003, + #endregion + + #region User Searches (80XXX) + NoUsersWithTag = 80004, + #endregion + + #region Reactions (90XXX) + ReactionBlocked = 90001, + CannotUseBurstReaction = 90002, + #endregion + + #region API Status (130XXX) + ApplicationNotYetAvailable = 110001, + APIOverloaded = 130000, + #endregion + + #region Stage Errors (150XXX) + StageAlreadyOpened = 150006, + #endregion + + #region Reply and Thread Errors (160XXX) + CannotReplyWithoutReadMessageHistory = 160002, + MessageAlreadyContainsThread = 160004, + ThreadIsLocked = 160005, + MaximumActiveThreadsReached = 160006, + MaximumAnnouncementThreadsReached = 160007, + #endregion + + #region Sticker Uploads (170XXX) + InvalidJSONLottie = 170001, + LottieCantContainRasters = 170002, + StickerMaximumFramerateExceeded = 170003, + StickerMaximumFrameCountExceeded = 170004, + LottieMaximumDimensionsExceeded = 170005, + StickerFramerateBoundsExceeded = 170006, + StickerAnimationDurationTooLong = 170007, + #endregion + + #region Guild Scheduled Events (180XXX) + CannotUpdateFinishedEvent = 180000, + FailedStageCreation = 180002, + #endregion + + #region Forum & Automod + MessageWasBlockedByAutomaticModeration = 200000, + TitleWasBlockedByAutomaticModeration = 200001, + WebhooksPostedToForumChannelsMustHaveAThreadNameOrThreadId = 220001, + WebhooksPostedToForumChannelsCannotHaveBothAThreadNameAndThreadId = 220002, + WebhooksCanOnlyCreateThreadsInForumChannels = 220003, + WebhookServicesCannotBeUsedInForumChannels = 220004, + MessageBlockedByHarmfulLinksFilter = 240000, + #endregion + + #region Onboarding (350XXX) + CannotEnableOnboardingUnmetRequirements = 350000, + CannotUpdateOnboardingBelowRequirements = 350001 + #endregion + } +} diff --git a/src/Discord.Net.Core/DiscordJsonError.cs b/src/Discord.Net.Core/DiscordJsonError.cs new file mode 100644 index 0000000..fdf82ea --- /dev/null +++ b/src/Discord.Net.Core/DiscordJsonError.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic parsed json error received from discord after performing a rest request. + /// + public struct DiscordJsonError + { + /// + /// Gets the json path of the error. + /// + public string Path { get; } + + /// + /// Gets a collection of errors associated with the specific property at the path. + /// + public IReadOnlyCollection Errors { get; } + + internal DiscordJsonError(string path, DiscordError[] errors) + { + Path = path; + Errors = errors.ToImmutableArray(); + } + } + + /// + /// Represents an error with a property. + /// + public struct DiscordError + { + /// + /// Gets the code of the error. + /// + public string Code { get; } + + /// + /// Gets the message describing what went wrong. + /// + public string Message { get; } + + internal DiscordError(string code, string message) + { + Code = code; + Message = message; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/ActivityProperties.cs b/src/Discord.Net.Core/Entities/Activities/ActivityProperties.cs new file mode 100644 index 0000000..15a79df --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/ActivityProperties.cs @@ -0,0 +1,50 @@ +using System; + +namespace Discord +{ + /// + /// Flags for the property, that are ORd together. + /// These describe what the activity payload includes. + /// + [Flags] + public enum ActivityProperties + { + /// + /// Indicates that no actions on this activity can be taken. + /// + None = 0, + Instance = 1, + /// + /// Indicates that this activity can be joined. + /// + Join = 0b10, + /// + /// Indicates that this activity can be spectated. + /// + Spectate = 0b100, + /// + /// Indicates that a user may request to join an activity. + /// + JoinRequest = 0b1000, + /// + /// Indicates that a user can listen along in Spotify. + /// + Sync = 0b10000, + /// + /// Indicates that a user can play this song. + /// + Play = 0b100000, + /// + /// Indicates that a user is playing an activity in a voice channel with friends. + /// + PartyPrivacyFriends = 0b1000000, + /// + /// Indicates that a user is playing an activity in a voice channel. + /// + PartyPrivacyVoiceChannel = 0b10000000, + /// + /// Indicates that a user is playing an activity in a voice channel. + /// + Embedded = 0b10000000 + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/ActivityType.cs b/src/Discord.Net.Core/Entities/Activities/ActivityType.cs new file mode 100644 index 0000000..1f67886 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/ActivityType.cs @@ -0,0 +1,33 @@ +namespace Discord +{ + /// + /// Specifies a Discord user's activity type. + /// + public enum ActivityType + { + /// + /// The user is playing a game. + /// + Playing = 0, + /// + /// The user is streaming online. + /// + Streaming = 1, + /// + /// The user is listening to a song. + /// + Listening = 2, + /// + /// The user is watching some form of media. + /// + Watching = 3, + /// + /// The user has set a custom status. + /// + CustomStatus = 4, + /// + /// The user is competing in a game. + /// + Competing = 5, + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/CustomStatusGame.cs b/src/Discord.Net.Core/Entities/Activities/CustomStatusGame.cs new file mode 100644 index 0000000..7a0cb06 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/CustomStatusGame.cs @@ -0,0 +1,54 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// + /// A user's activity for their custom status. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class CustomStatusGame : Game + { + internal CustomStatusGame() { } + + /// + /// Creates a new custom status activity. + /// + /// + /// Bots can't set custom status emoji. + /// + /// The string displayed as bot's custom status. + public CustomStatusGame(string state) + { + Name = "Custom Status"; + State = state; + Type = ActivityType.CustomStatus; + } + + /// + /// Gets the emote, if it is set. + /// + /// + /// An containing the or set by the user. + /// + public IEmote Emote { get; internal set; } + + /// + /// Gets the timestamp of when this status was created. + /// + /// + /// A containing the time when this status was created. + /// + public DateTimeOffset CreatedAt { get; internal set; } + + /// + /// Gets the state of the status. + /// + public string State { get; internal set; } + + public override string ToString() + => $"{Emote} {State}"; + + private string DebuggerDisplay => $"{Name}"; + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/DefaultApplications.cs b/src/Discord.Net.Core/Entities/Activities/DefaultApplications.cs new file mode 100644 index 0000000..80f128f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/DefaultApplications.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public enum DefaultApplications : ulong + { + /// + /// Watch youtube together. + /// + Youtube = 880218394199220334, + + /// + /// Youtube development application. + /// + YoutubeDev = 880218832743055411, + + /// + /// Poker! + /// + Poker = 755827207812677713, + + /// + /// Betrayal: A Party Adventure. Betrayal is a social deduction game inspired by Werewolf, Town of Salem, and Among Us. + /// + Betrayal = 773336526917861400, + + /// + /// Sit back, relax, and do some fishing! + /// + Fishing = 814288819477020702, + + /// + /// The queens gambit. + /// + Chess = 832012774040141894, + + /// + /// Development version of chess. + /// + ChessDev = 832012586023256104, + + /// + /// LetterTile is a version of scrabble. + /// + LetterTile = 879863686565621790, + + /// + /// Find words in a jumble of letters in coffee. + /// + WordSnack = 879863976006127627, + + /// + /// It's like skribbl.io. + /// + DoodleCrew = 878067389634314250, + + /// + /// It's like cards against humanity. + /// + Awkword = 879863881349087252, + + /// + /// A word-search like game where you unscramble words and score points in a scrabble fashion. + /// + SpellCast = 852509694341283871, + + /// + /// Classic checkers + /// + Checkers = 832013003968348200, + + /// + /// The development version of poker. + /// + PokerDev = 763133495793942528, + + /// + /// SketchyArtist. + /// + SketchyArtist = 879864070101172255 + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/Game.cs b/src/Discord.Net.Core/Entities/Activities/Game.cs new file mode 100644 index 0000000..eb8dc61 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/Game.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; + +namespace Discord +{ + /// + /// A user's game status. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class Game : IActivity + { + /// + public string Name { get; internal set; } + /// + public ActivityType Type { get; internal set; } + /// + public ActivityProperties Flags { get; internal set; } + /// + public string Details { get; internal set; } + + internal Game() { } + /// + /// Creates a with the provided name and . + /// + /// The name of the game. + /// The type of activity. + public Game(string name, ActivityType type = ActivityType.Playing, ActivityProperties flags = ActivityProperties.None, string details = null) + { + Name = name; + Type = type; + Flags = flags; + Details = details; + } + + /// Returns the name of the . + public override string ToString() => Name; + private string DebuggerDisplay => Name; + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/GameAsset.cs b/src/Discord.Net.Core/Entities/Activities/GameAsset.cs new file mode 100644 index 0000000..b78307d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/GameAsset.cs @@ -0,0 +1,38 @@ +namespace Discord +{ + /// + /// An asset for a object containing the text and image. + /// + public class GameAsset + { + internal GameAsset() { } + + internal ulong? ApplicationId { get; set; } + + /// + /// Gets the description of the asset. + /// + /// + /// A string containing the description of the asset. + /// + public string Text { get; internal set; } + /// + /// Gets the image ID of the asset. + /// + /// + /// A string containing the unique image identifier of the asset. + /// + public string ImageId { get; internal set; } + + /// + /// Returns the image URL of the asset. + /// + /// The size of the image to return in. This can be any power of two between 16 and 2048. + /// The format to return. + /// + /// A string pointing to the image URL of the asset; when the application ID does not exist. + /// + public string GetImageUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => ApplicationId.HasValue ? CDN.GetRichAssetUrl(ApplicationId.Value, ImageId, size, format) : null; + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/GameParty.cs b/src/Discord.Net.Core/Entities/Activities/GameParty.cs new file mode 100644 index 0000000..0cfa998 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/GameParty.cs @@ -0,0 +1,26 @@ +namespace Discord +{ + /// + /// Party information for a object. + /// + public class GameParty + { + internal GameParty() { } + + /// + /// Gets the ID of the party. + /// + /// + /// A string containing the unique identifier of the party. + /// + public string Id { get; internal set; } + public long Members { get; internal set; } + /// + /// Gets the party's current and maximum size. + /// + /// + /// A representing the capacity of the party. + /// + public long Capacity { get; internal set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/GameSecrets.cs b/src/Discord.Net.Core/Entities/Activities/GameSecrets.cs new file mode 100644 index 0000000..595b885 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/GameSecrets.cs @@ -0,0 +1,28 @@ +namespace Discord +{ + /// + /// Party secret for a object. + /// + public class GameSecrets + { + /// + /// Gets the secret for a specific instanced match. + /// + public string Match { get; } + /// + /// Gets the secret for joining a party. + /// + public string Join { get; } + /// + /// Gets the secret for spectating a game. + /// + public string Spectate { get; } + + internal GameSecrets(string match, string join, string spectate) + { + Match = match; + Join = join; + Spectate = spectate; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/GameTimestamps.cs b/src/Discord.Net.Core/Entities/Activities/GameTimestamps.cs new file mode 100644 index 0000000..a41388a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/GameTimestamps.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord +{ + /// + /// Timestamps for a object. + /// + public class GameTimestamps + { + /// + /// Gets when the activity started. + /// + public DateTimeOffset? Start { get; } + /// + /// Gets when the activity ends. + /// + public DateTimeOffset? End { get; } + + internal GameTimestamps(DateTimeOffset? start, DateTimeOffset? end) + { + Start = start; + End = end; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/IActivity.cs b/src/Discord.Net.Core/Entities/Activities/IActivity.cs new file mode 100644 index 0000000..96704b8 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/IActivity.cs @@ -0,0 +1,40 @@ +namespace Discord +{ + /// + /// A user's activity status, typically a . + /// + public interface IActivity + { + /// + /// Gets the name of the activity. + /// + /// + /// A string containing the name of the activity that the user is doing. + /// + string Name { get; } + /// + /// Gets the type of the activity. + /// + /// + /// The type of activity. + /// + ActivityType Type { get; } + /// + /// Gets the flags that are relevant to this activity. + /// + /// + /// This value is determined by bitwise OR-ing values together. + /// + /// + /// The value of flags for this activity. + /// + ActivityProperties Flags { get; } + /// + /// Gets the details on what the player is currently doing. + /// + /// + /// A string describing what the player is doing. + /// + string Details { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/RichGame.cs b/src/Discord.Net.Core/Entities/Activities/RichGame.cs new file mode 100644 index 0000000..2da8d74 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/RichGame.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; + +namespace Discord +{ + /// + /// A user's Rich Presence status. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RichGame : Game + { + internal RichGame() { } + + /// + /// Gets the user's current party status. + /// + public string State { get; internal set; } + /// + /// Gets the application ID for the game. + /// + public ulong ApplicationId { get; internal set; } + /// + /// Gets the small image for the presence and their hover texts. + /// + public GameAsset SmallAsset { get; internal set; } + /// + /// Gets the large image for the presence and their hover texts. + /// + public GameAsset LargeAsset { get; internal set; } + /// + /// Gets the information for the current party of the player. + /// + public GameParty Party { get; internal set; } + /// + /// Gets the secrets for Rich Presence joining and spectating. + /// + public GameSecrets Secrets { get; internal set; } + /// + /// Gets the timestamps for start and/or end of the game. + /// + public GameTimestamps Timestamps { get; internal set; } + + /// + /// Returns the name of the Rich Presence. + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} (Rich)"; + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/SpotifyGame.cs b/src/Discord.Net.Core/Entities/Activities/SpotifyGame.cs new file mode 100644 index 0000000..4eab34f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/SpotifyGame.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord +{ + /// + /// A user's activity for listening to a song on Spotify. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SpotifyGame : Game + { + /// + /// Gets the song's artist(s). + /// + /// + /// A collection of string containing all artists featured in the track (e.g. Avicii; Rita Ora). + /// + public IReadOnlyCollection Artists { get; internal set; } + /// + /// Gets the Spotify album title of the song. + /// + /// + /// A string containing the name of the album (e.g. AVĪCI (01)). + /// + public string AlbumTitle { get; internal set; } + /// + /// Gets the track title of the song. + /// + /// + /// A string containing the name of the song (e.g. Lonely Together (feat. Rita Ora)). + /// + public string TrackTitle { get; internal set; } + + /// + /// Gets the date when the track started playing. + /// + /// + /// A containing the start timestamp of the song. + /// + public DateTimeOffset? StartedAt { get; internal set; } + + /// + /// Gets the date when the track ends. + /// + /// + /// A containing the finish timestamp of the song. + /// + public DateTimeOffset? EndsAt { get; internal set; } + + /// + /// Gets the duration of the song. + /// + /// + /// A containing the duration of the song. + /// + public TimeSpan? Duration { get; internal set; } + + /// + /// Gets the elapsed duration of the song. + /// + /// + /// A containing the elapsed duration of the song. + /// + public TimeSpan? Elapsed => DateTimeOffset.UtcNow - StartedAt; + + /// + /// Gets the remaining duration of the song. + /// + /// + /// A containing the remaining duration of the song. + /// + public TimeSpan? Remaining => EndsAt - DateTimeOffset.UtcNow; + + /// + /// Gets the track ID of the song. + /// + /// + /// A string containing the Spotify ID of the track (e.g. 7DoN0sCGIT9IcLrtBDm4f0). + /// + public string TrackId { get; internal set; } + /// + /// Gets the session ID of the song. + /// + /// + /// The purpose of this property is currently unknown. + /// + /// + /// A string containing the session ID. + /// + public string SessionId { get; internal set; } + + /// + /// Gets the URL of the album art. + /// + /// + /// A URL pointing to the album art of the track (e.g. + /// https://i.scdn.co/image/ba2fd8823d42802c2f8738db0b33a4597f2f39e7). + /// + public string AlbumArtUrl { get; internal set; } + /// + /// Gets the direct Spotify URL of the track. + /// + /// + /// A URL pointing directly to the track on Spotify. (e.g. + /// https://open.spotify.com/track/7DoN0sCGIT9IcLrtBDm4f0). + /// + public string TrackUrl { get; internal set; } + + internal SpotifyGame() { } + + /// + /// Gets the full information of the song. + /// + /// + /// A string containing the full information of the song (e.g. + /// Avicii, Rita Ora - Lonely Together (feat. Rita Ora) (3:08) + /// + public override string ToString() => $"{string.Join(", ", Artists)} - {TrackTitle} ({Duration})"; + private string DebuggerDisplay => $"{Name} (Spotify)"; + } +} diff --git a/src/Discord.Net.Core/Entities/Activities/StreamingGame.cs b/src/Discord.Net.Core/Entities/Activities/StreamingGame.cs new file mode 100644 index 0000000..127ae0b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Activities/StreamingGame.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; + +namespace Discord +{ + /// + /// A user's activity for streaming on services such as Twitch. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class StreamingGame : Game + { + /// + /// Gets the URL of the stream. + /// + public string Url { get; internal set; } + + /// + /// Creates a new based on the on the stream URL. + /// + /// The name of the stream. + /// The URL of the stream. + public StreamingGame(string name, string url) + { + Name = name; + Url = url; + Type = ActivityType.Streaming; + } + + /// + /// Gets the name of the stream. + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Url})"; + } +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/EntitlementType.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/EntitlementType.cs new file mode 100644 index 0000000..6dac367 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/EntitlementType.cs @@ -0,0 +1,9 @@ +namespace Discord; + +public enum EntitlementType +{ + /// + /// The entitlement was purchased as an app subscription. + /// + ApplicationSubscription = 8 +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/IEntitlement.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/IEntitlement.cs new file mode 100644 index 0000000..19b2276 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/IEntitlement.cs @@ -0,0 +1,61 @@ +using System; + +namespace Discord; + +public interface IEntitlement : ISnowflakeEntity +{ + /// + /// Gets the ID of the SKU this entitlement is for. + /// + ulong SkuId { get; } + + /// + /// Gets the ID of the user that is granted access to the entitlement's SKU. + /// + /// + /// if the entitlement is for a guild. + /// + ulong? UserId { get; } + + /// + /// Gets the ID of the guild that is granted access to the entitlement's SKU. + /// + /// + /// if the entitlement is for a user. + /// + ulong? GuildId { get; } + + /// + /// Gets the ID of the parent application. + /// + ulong ApplicationId { get; } + + /// + /// Gets the type of the entitlement. + /// + EntitlementType Type { get; } + + /// + /// Gets whether this entitlement has been consumed. + /// + /// + /// Not applicable for App Subscriptions. + /// + bool IsConsumed { get; } + + /// + /// Gets the start date at which the entitlement is valid. + /// + /// + /// when using test entitlements. + /// + DateTimeOffset? StartsAt { get; } + + /// + /// Gets the end date at which the entitlement is no longer valid. + /// + /// + /// when using test entitlements. + /// + DateTimeOffset? EndsAt { get; } +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SKU.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SKU.cs new file mode 100644 index 0000000..338b14e --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SKU.cs @@ -0,0 +1,41 @@ +using System; + +namespace Discord; + +public struct SKU : ISnowflakeEntity +{ + /// + public ulong Id { get; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + /// Gets the type of the SKU. + /// + public SKUType Type { get; } + + /// + /// Gets the ID of the parent application. + /// + public ulong ApplicationId { get; } + + /// + /// Gets the customer-facing name of your premium offering. + /// + public string Name { get; } + + /// + /// Gets the system-generated URL slug based on the SKU's name. + /// + public string Slug { get; } + + internal SKU(ulong id, SKUType type, ulong applicationId, string name, string slug) + { + Id = id; + Type = type; + ApplicationId = applicationId; + Name = name; + Slug = slug; + } +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SKUFlags.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUFlags.cs new file mode 100644 index 0000000..d748bf7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUFlags.cs @@ -0,0 +1,11 @@ +using System; + +namespace Discord; + +[Flags] +public enum SKUFlags +{ + GuildSubscription = 1 << 7, + + UserSubscription = 1 << 8 +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SKUType.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUType.cs new file mode 100644 index 0000000..a15b6c5 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SKUType.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum SKUType +{ + /// + /// Represents a recurring subscription. + /// + Subscription = 5, + + /// + /// System-generated group for each SKU created. + /// + SubscriptionGroup = 6, +} diff --git a/src/Discord.Net.Core/Entities/AppSubscriptions/SubscriptionOwnerType.cs b/src/Discord.Net.Core/Entities/AppSubscriptions/SubscriptionOwnerType.cs new file mode 100644 index 0000000..eb04264 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AppSubscriptions/SubscriptionOwnerType.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum SubscriptionOwnerType +{ + /// + /// The owner of the application subscription is a guild. + /// + Guild = 1, + + /// + /// The owner of the application subscription is a user. + /// + User = 2, +} diff --git a/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnection.cs b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnection.cs new file mode 100644 index 0000000..d2bf54f --- /dev/null +++ b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnection.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the connection object that the user has attached. +/// +public class RoleConnection +{ + /// + /// Gets the vanity name of the platform a bot has connected to. + /// + public string PlatformName { get; } + + /// + /// Gets the username on the platform a bot has connected to. + /// + public string PlatformUsername { get; } + + /// + /// Gets the object mapping keys to their string-ified values. + /// + public IReadOnlyDictionary Metadata { get; } + + internal RoleConnection(string platformName, string platformUsername, IReadOnlyDictionary metadata) + { + PlatformName = platformName; + PlatformUsername = platformUsername; + Metadata = metadata; + } + + /// + /// Initializes a new with the data from this object. + /// + public RoleConnectionProperties ToRoleConnectionProperties() + => new() + { + PlatformName = PlatformName, + PlatformUsername = PlatformUsername, + Metadata = Metadata.ToDictionary() + }; +} diff --git a/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadata.cs b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadata.cs new file mode 100644 index 0000000..2e5a2cc --- /dev/null +++ b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadata.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord; + +/// +/// Represents the role connection metadata object. +/// +public class RoleConnectionMetadata +{ + /// + /// Gets the of metadata value. + /// + public RoleConnectionMetadataType Type { get; } + + /// + /// Gets the dictionary key for the metadata field. + /// + public string Key { get; } + + /// + /// Gets the name of the metadata field. + /// + public string Name { get; } + + /// + /// Gets the description of the metadata field. + /// + public string Description { get; } + + /// + /// Gets translations of the name. if not set. + /// + public IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets translations of the description. if not set. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; } + + internal RoleConnectionMetadata(RoleConnectionMetadataType type, string key, string name, string description, + IDictionary nameLocalizations = null, IDictionary descriptionLocalizations = null) + { + Type = type; + Key = key; + Name = name; + Description = description; + NameLocalizations = nameLocalizations?.ToImmutableDictionary(); + DescriptionLocalizations = descriptionLocalizations?.ToImmutableDictionary(); + } + + /// + /// Initializes a new with the data from this object. + /// + public RoleConnectionMetadataProperties ToRoleConnectionMetadataProperties() + => new() + { + Name = Name, + Description = Description, + Type = Type, + Key = Key, + NameLocalizations = NameLocalizations, + DescriptionLocalizations = DescriptionLocalizations + }; +} diff --git a/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadataProperties.cs b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadataProperties.cs new file mode 100644 index 0000000..e9ea537 --- /dev/null +++ b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadataProperties.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord; + +/// +/// Properties object used to create or modify object. +/// +public class RoleConnectionMetadataProperties +{ + private const int MaxKeyLength = 50; + private const int MaxNameLength = 100; + private const int MaxDescriptionLength = 200; + + private string _key; + private string _name; + private string _description; + + private IReadOnlyDictionary _nameLocalizations; + private IReadOnlyDictionary _descriptionLocalizations; + + /// + /// Gets or sets the of metadata value. + /// + public RoleConnectionMetadataType Type { get; set; } + + /// + /// Gets or sets the dictionary key for the metadata field. + /// + public string Key + { + get => _key; + set + { + Preconditions.AtMost(value.Length, MaxKeyLength, nameof(Key), $"Key length must be less than or equal to {MaxKeyLength}"); + _key = value; + } + } + + /// + /// Gets or sets the name of the metadata field. + /// + public string Name + { + get => _name; + set + { + Preconditions.AtMost(value.Length, MaxNameLength, nameof(Name), $"Name length must be less than or equal to {MaxNameLength}"); + _name = value; + } + } + + /// + /// Gets or sets the description of the metadata field. + /// + public string Description + { + get => _description; + set + { + Preconditions.AtMost(value.Length, MaxDescriptionLength, nameof(Description), $"Description length must be less than or equal to {MaxDescriptionLength}"); + _description = value; + } + } + + /// + /// Gets or sets translations of the name. if not set. + /// + public IReadOnlyDictionary NameLocalizations + { + get => _nameLocalizations; + set + { + if (value is not null) + foreach (var localization in value) + if (localization.Value.Length > MaxNameLength) + throw new ArgumentException($"Name localization length must be less than or equal to {MaxNameLength}. Locale '{localization}'"); + _nameLocalizations = value; + } + } + + /// + /// Gets or sets translations of the description. if not set. + /// + public IReadOnlyDictionary DescriptionLocalizations + { + get => _descriptionLocalizations; + set + { + if (value is not null) + foreach (var localization in value) + if (localization.Value.Length > MaxDescriptionLength) + throw new ArgumentException($"Description localization length must be less than or equal to {MaxDescriptionLength}. Locale '{localization}'"); + _descriptionLocalizations = value; + } + } + + /// + /// Initializes a new instance of . + /// + /// The type of the metadata value. + /// The dictionary key for the metadata field. Max 50 characters. + /// The name of the metadata visible in user profile. Max 100 characters. + /// The description of the metadata visible in user profile. Max 200 characters. + /// Translations for the name. + /// Translations for the description. + public RoleConnectionMetadataProperties(RoleConnectionMetadataType type, string key, string name, string description, + IDictionary nameLocalizations = null, IDictionary descriptionLocalizations = null) + { + Type = type; + Key = key; + Name = name; + Description = description; + NameLocalizations = nameLocalizations?.ToImmutableDictionary(); + DescriptionLocalizations = descriptionLocalizations?.ToImmutableDictionary(); + } + + /// + /// Initializes a new instance of . + /// + public RoleConnectionMetadataProperties() { } + + /// + /// Initializes a new with the data from provided . + /// + public static RoleConnectionMetadataProperties FromRoleConnectionMetadata(RoleConnectionMetadata metadata) + => new() + { + Name = metadata.Name, + Description = metadata.Description, + Type = metadata.Type, + Key = metadata.Key, + NameLocalizations = metadata.NameLocalizations, + DescriptionLocalizations = metadata.DescriptionLocalizations + }; +} + diff --git a/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadataType.cs b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadataType.cs new file mode 100644 index 0000000..50b851a --- /dev/null +++ b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionMetadataType.cs @@ -0,0 +1,49 @@ +using System; + +namespace Discord; + +/// +/// Represents the type of Application Role Connection Metadata. +/// +public enum RoleConnectionMetadataType +{ + /// + /// The metadata's integer value is less than or equal to the guild's configured value. + /// + IntegerLessOrEqual = 1, + + /// + /// The metadata's integer value is greater than or equal to the guild's configured value. + /// + IntegerGreaterOrEqual = 2, + + /// + /// The metadata's integer value is equal to the guild's configured value. + /// + IntegerEqual = 3, + + /// + /// The metadata's integer value is not equal to the guild's configured value. + /// + IntegerNotEqual = 4, + + /// + /// The metadata's ISO8601 string value is less or equal to the guild's configured value. + /// + DateTimeLessOrEqual = 5, + + /// + /// The metadata's ISO8601 string value is greater to the guild's configured value. + /// + DateTimeGreaterOrEqual = 6, + + /// + /// The metadata's integer value is equal to the guild's configured value. + /// + BoolEqual = 7, + + /// + /// The metadata's integer value is equal to the guild's configured value. + /// + BoolNotEqual = 8, +} diff --git a/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionProperties.cs b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionProperties.cs new file mode 100644 index 0000000..cea7882 --- /dev/null +++ b/src/Discord.Net.Core/Entities/ApplicationRoleConnection/RoleConnectionProperties.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the properties used to modify user's . +/// +public class RoleConnectionProperties +{ + private const int MaxPlatformNameLength = 50; + private const int MaxPlatformUsernameLength = 100; + private const int MaxMetadataRecords = 100; + + private string _platformName; + private string _platformUsername; + private Dictionary _metadata; + + /// + /// Gets or sets the vanity name of the platform a bot has connected. Max 50 characters. + /// + public string PlatformName + { + get => _platformName; + set + { + if (value is not null) + Preconditions.AtMost(value.Length, MaxPlatformNameLength, nameof(PlatformName), $"Platform name length must be less or equal to {MaxPlatformNameLength}"); + _platformName = value; + } + } + + /// + /// Gets or sets the username on the platform a bot has connected. Max 100 characters. + /// + public string PlatformUsername + { + get => _platformUsername; + set + { + if (value is not null) + Preconditions.AtMost(value.Length, MaxPlatformUsernameLength, nameof(PlatformUsername), $"Platform username length must be less or equal to {MaxPlatformUsernameLength}"); + _platformUsername = value; + } + } + + /// + /// Gets or sets object mapping keys to their string-ified values. + /// + public Dictionary Metadata + { + get => _metadata; + set + { + if (value is not null) + Preconditions.AtMost(value.Count, MaxPlatformUsernameLength, nameof(Metadata), $"Metadata records count must be less or equal to {MaxMetadataRecords}"); + _metadata = value; + } + } + + /// + /// Adds a metadata record with the provided key and value. + /// + /// The current . + public RoleConnectionProperties WithDate(string key, DateTimeOffset value) + => AddMetadataRecord(key, value.ToString("O")); + + /// + /// Adds a metadata record with the provided key and value. + /// + /// The current . + public RoleConnectionProperties WithBool(string key, bool value) + => AddMetadataRecord(key, value ? "1" : "0"); + + /// + /// Adds a metadata record with the provided key and value. + /// + /// The current . + public RoleConnectionProperties WithNumber(string key, int value) + => AddMetadataRecord(key, value.ToString()); + + /// + /// Adds a metadata record with the provided key and value. + /// + /// The current . + public RoleConnectionProperties WithNumber(string key, uint value) + => AddMetadataRecord(key, value.ToString()); + + /// + /// Adds a metadata record with the provided key and value. + /// + /// The current . + public RoleConnectionProperties WithNumber(string key, long value) + => AddMetadataRecord(key, value.ToString()); + + /// + /// Adds a metadata record with the provided key and value. + /// + /// The current . + public RoleConnectionProperties WithNumber(string key, ulong value) + => AddMetadataRecord(key, value.ToString()); + + internal RoleConnectionProperties AddMetadataRecord(string key, string value) + { + Metadata ??= new Dictionary(); + if (!Metadata.ContainsKey(key)) + Preconditions.AtMost(Metadata.Count + 1, MaxPlatformUsernameLength, nameof(Metadata), $"Metadata records count must be less or equal to {MaxMetadataRecords}"); + + _metadata[key] = value; + return this; + } + + /// + /// Initializes a new instance of . + /// + /// The name of the platform a bot has connected.s + /// Gets the username on the platform a bot has connected. + /// Object mapping keys to their values. + public RoleConnectionProperties(string platformName, string platformUsername, IDictionary metadata = null) + { + PlatformName = platformName; + PlatformUsername = platformUsername; + Metadata = metadata?.ToDictionary() ?? new (); + } + + /// + /// Initializes a new instance of . + /// + public RoleConnectionProperties() + { + Metadata = new(); + } + + /// + /// Initializes a new with the data from provided . + /// + public static RoleConnectionProperties FromRoleConnection(RoleConnection roleConnection) + => new() + { + PlatformName = roleConnection.PlatformName, + PlatformUsername = roleConnection.PlatformUsername, + Metadata = roleConnection.Metadata?.ToDictionary() + }; +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationDiscoverabilityState.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationDiscoverabilityState.cs new file mode 100644 index 0000000..5152c02 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationDiscoverabilityState.cs @@ -0,0 +1,34 @@ +namespace Discord; + +public enum ApplicationDiscoverabilityState +{ + /// + /// Application has no discoverability state. + /// + None = 0, + + /// + /// Application is ineligible for the application directory. + /// + Ineligible = 1, + + /// + /// Application is not listed in the application directory. + /// + NotDiscoverable = 2, + + /// + /// Application is listed in the application directory. + /// + Discoverable = 3, + + /// + /// Application is featureable in the application directory. + /// + Featureable = 4, + + /// + /// Application has been blocked from appearing in the application directory. + /// + Blocked = 5, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationExplicitContentFilterLevel.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationExplicitContentFilterLevel.cs new file mode 100644 index 0000000..45977e1 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationExplicitContentFilterLevel.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum ApplicationExplicitContentFilterLevel +{ + /// + /// Media content will not be filtered. + /// + Disabled = 0, + + /// + /// Media content will be filtered. + /// + Enabled = 1, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationFlags.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationFlags.cs new file mode 100644 index 0000000..b11467c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationFlags.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord; + +/// +/// Represents public flags for an application. +/// +[Flags] +public enum ApplicationFlags +{ + /// + /// Indicates if an app uses the Auto Moderation API. + /// + UsesAutoModApi = 1 << 6, + + /// + /// Indicates that the app has been verified to use GUILD_PRESENCES intent. + /// + GatewayPresence = 1 << 12, + + /// + /// Indicates that the app has enabled the GUILD_PRESENCES intent on a bot in less than 100 servers. + /// + GatewayPresenceLimited = 1 << 13, + + /// + /// Indicates that the app has been verified to use GUILD_MEMBERS intent. + /// + GatewayGuildMembers = 1 << 14, + + /// + /// Indicates that the app has enabled the GUILD_MEMBERS intent on a bot in less than 100 servers. + /// + GatewayGuildMembersLimited = 1 << 15, + + /// + /// Indicates unusual growth of an app that prevents verification. + /// + VerificationPendingGuildLimit = 1 << 16, + + /// + /// Indicates if an app is embedded within the Discord client. + /// + Embedded = 1 << 17, + + /// + /// Indicates that the app has been verified to use MESSAGE_CONTENT intent. + /// + GatewayMessageContent = 1 << 18, + + /// + /// Indicates that the app has enabled the MESSAGE_CONTENT intent on a bot in less than 100 servers. + /// + GatewayMessageContentLimited = 1 << 19, + + /// + /// Indicates if an app has registered global application commands. + /// + ApplicationCommandBadge = 1 << 23, + + /// + /// Indicates if an app is considered active. + /// + ActiveApplication = 1 << 24 +} + diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationInstallParams.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationInstallParams.cs new file mode 100644 index 0000000..672cb8e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationInstallParams.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord; + +/// +/// Represents install parameters for an application. +/// +public class ApplicationInstallParams +{ + /// + /// Gets the scopes to install this application. + /// + public IReadOnlyCollection Scopes { get; } + + /// + /// Gets the default permissions to install this application. + /// + public GuildPermission Permission { get; } + + public ApplicationInstallParams(string[] scopes, GuildPermission permission) + { + Preconditions.NotNull(scopes, nameof(scopes)); + + foreach (var s in scopes) + Preconditions.NotNull(s, nameof(scopes)); + + Scopes = scopes.ToImmutableArray(); + Permission = permission; + } +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationIntegrationType.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationIntegrationType.cs new file mode 100644 index 0000000..b96370e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationIntegrationType.cs @@ -0,0 +1,17 @@ +namespace Discord; + +/// +/// Defines where an application can be installed. +/// +public enum ApplicationIntegrationType +{ + /// + /// The application can be installed to a guild. + /// + GuildInstall = 0, + + /// + /// The application can be installed to a user. + /// + UserInstall = 1, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationInteractionsVersion.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationInteractionsVersion.cs new file mode 100644 index 0000000..18c219d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationInteractionsVersion.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum ApplicationInteractionsVersion +{ + /// + /// Only Interaction Create events are sent as documented (default). + /// + Version1 = 1, + + /// + /// A selection of chosen events are sent. + /// + Version2 = 2, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationMonetizationEligibilityFlags.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationMonetizationEligibilityFlags.cs new file mode 100644 index 0000000..1ccfb8f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationMonetizationEligibilityFlags.cs @@ -0,0 +1,80 @@ +using System; + +namespace Discord; + +/// +/// Gets the monetization eligibility flags for the application combined as a bitfield. +/// +[Flags] +public enum ApplicationMonetizationEligibilityFlags +{ + /// + /// The application has no monetization eligibility flags set. + /// + None = 0, + + /// + /// Application is verified. + /// + Verified = 1 << 0, + + /// + /// Application is owned by a team. + /// + HasTeam = 1 << 1, + + /// + /// Application has the message content intent approved or uses application commands. + /// + ApprovedCommands = 1 << 2, + + /// + /// Application has terms of service set. + /// + TermsOfService = 1 << 3, + + /// + /// Application has a privacy policy set. + /// + PrivacyPolicy = 1 << 4, + + /// + /// Application's name is safe for work. + /// + SafeName = 1 << 5, + + /// + /// Application's description is safe for work. + /// + SafeDescription = 1 << 6, + + /// + /// Application's role connections metadata is safe for work. + /// + SafeRoleConnections = 1 << 7, + + /// + /// Application is not quarantined. + /// + NotQuarantined = 1 << 9, + + /// + /// Application's team members all have verified emails. + /// + TeamMembersEmailVerified = 1 << 15, + + /// + /// Application's team members all have MFA enabled. + /// + TeamMembersMfaEnabled = 1 << 16, + + /// + /// Application has no issues blocking monetization. + /// + NoBlockingIssues = 1 << 17, + + /// + /// Application's team has a valid payout status. + /// + ValidPayoutStatus = 1 << 18, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationMonetizationState.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationMonetizationState.cs new file mode 100644 index 0000000..05670a6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationMonetizationState.cs @@ -0,0 +1,19 @@ +namespace Discord; + +public enum ApplicationMonetizationState +{ + /// + /// Application has no monetization set up. + /// + None = 1, + + /// + /// Application has monetization set up. + /// + Enabled = 2, + + /// + /// Application has been blocked from monetizing. + /// + Blocked = 3, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationRpcState.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationRpcState.cs new file mode 100644 index 0000000..3660fb1 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationRpcState.cs @@ -0,0 +1,29 @@ +namespace Discord; + +public enum ApplicationRpcState +{ + /// + /// Application does not have access to RPC. + /// + Disabled = 0, + + /// + /// Application has not yet been applied for RPC access. + /// + Unsubmitted = 1, + + /// + /// Application has submitted a RPC access request. + /// + Submitted = 2, + + /// + /// Application has been approved for RPC access. + /// + Approved = 3, + + /// + /// Application has been rejected from RPC access. + /// + Rejected = 4, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationStoreState.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationStoreState.cs new file mode 100644 index 0000000..db5c4db --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationStoreState.cs @@ -0,0 +1,29 @@ +namespace Discord; + +public enum ApplicationStoreState +{ + /// + /// Application does not have a commerce license. + /// + None = 1, + + /// + /// Application has a commerce license but has not yet submitted a store approval request. + /// + Paid = 2, + + /// + /// Application has submitted a store approval request. + /// + Submitted = 3, + + /// + /// Application has been approved for the store. + /// + Approved = 4, + + /// + /// Application has been rejected from the store. + /// + Rejected = 5, +} diff --git a/src/Discord.Net.Core/Entities/Applications/ApplicationVerificationState.cs b/src/Discord.Net.Core/Entities/Applications/ApplicationVerificationState.cs new file mode 100644 index 0000000..6a52c31 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ApplicationVerificationState.cs @@ -0,0 +1,24 @@ +namespace Discord; + +public enum ApplicationVerificationState +{ + /// + /// Application is ineligible for verification. + /// + Ineligible = 1, + + /// + /// Application has not yet been applied for verification. + /// + Unsubmitted = 2, + + /// + /// Application has submitted a verification request. + /// + Submitted = 3, + + /// + /// Application has been verified. + /// + Succeeded = 4, +} diff --git a/src/Discord.Net.Core/Entities/Applications/DiscoveryEligibilityFlags.cs b/src/Discord.Net.Core/Entities/Applications/DiscoveryEligibilityFlags.cs new file mode 100644 index 0000000..c426be4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/DiscoveryEligibilityFlags.cs @@ -0,0 +1,100 @@ +using System; + +namespace Discord; + +/// +/// Gets the discovery eligibility flags for the application combined as a bitfield. +/// +[Flags] +public enum DiscoveryEligibilityFlags +{ + /// + /// The application has no eligibility flags. + /// + None = 0, + + /// + /// Application is verified. + /// + Verified = 1 << 0, + + /// + /// Application has at least one tag set. + /// + Tag = 1 << 1, + + /// + /// Application has a description. + /// + Description = 1 << 2, + + /// + /// Application has terms of service set. + /// + TermsOfService = 1 << 3, + + /// + /// Application has a privacy policy set. + /// + PrivacyPolicy = 1 << 4, + + /// + /// Application has a custom install URL or install parameters. + /// + InstallParams = 1 << 5, + + /// + /// Application's name is safe for work. + /// + SafeName = 1 << 6, + + /// + /// Application's description is safe for work. + /// + SafeDescription = 1 << 7, + + /// + /// Application has the message content intent approved or uses application commands. + /// + ApprovedCommands = 1 << 8, + + /// + /// Application has a support guild set. + /// + SupportGuild = 1 << 9, + + /// + /// Application's commands are safe for work. + /// + SafeCommands = 1 << 10, + + /// + /// Application's owner has MFA enabled. + /// + MfaEnabled = 1 << 11, + + /// + /// Application's directory long description is safe for work. + /// + SafeDirectoryOverview = 1 << 12, + + /// + /// Application has at least one supported locale set. + /// + SupportedLocales = 1 << 13, + + /// + /// Application's directory short description is safe for work. + /// + SafeShortDescription = 1 << 14, + + /// + /// Application's role connections metadata is safe for work. + /// + SafeRoleConnections = 1 << 15, + + /// + /// Application is eligible for discovery. + /// + Eligible = 1 << 16, +} diff --git a/src/Discord.Net.Core/Entities/Applications/IApplication.cs b/src/Discord.Net.Core/Entities/Applications/IApplication.cs new file mode 100644 index 0000000..3705717 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/IApplication.cs @@ -0,0 +1,163 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a Discord application created via the developer portal. + /// + public interface IApplication : ISnowflakeEntity + { + /// + /// Gets the name of the application. + /// + string Name { get; } + /// + /// Gets the description of the application. + /// + string Description { get; } + /// + /// Gets the RPC origins of the application. + /// + IReadOnlyCollection RPCOrigins { get; } + /// + /// Gets the application's public flags. + /// + ApplicationFlags Flags { get; } + /// + /// Gets a collection of install parameters for this application; if disabled. + /// + ApplicationInstallParams InstallParams { get; } + /// + /// Gets a collection of tags related to the application. + /// + IReadOnlyCollection Tags { get; } + /// + /// Gets the icon URL of the application. + /// + string IconUrl { get; } + /// + /// Gets if the bot is public. if not set. + /// + bool? IsBotPublic { get; } + /// + /// Gets if the bot requires code grant. if not set. + /// + bool? BotRequiresCodeGrant { get; } + /// + /// Gets the team associated with this application if there is one. + /// + ITeam Team { get; } + /// + /// Gets the partial user object containing info on the owner of the application. + /// + IUser Owner { get; } + /// + /// Gets the url of the app's terms of service. + /// + string TermsOfService { get; } + /// + /// Gets the the url of the app's privacy policy. + /// + string PrivacyPolicy { get; } + + /// + /// Gets application's default custom authorization url. if disabled. + /// + string CustomInstallUrl { get; } + + /// + /// Gets the application's role connection verification entry point. if not set. + /// + string RoleConnectionsVerificationUrl { get; } + + /// + /// Gets the hex encoded key for verification in interactions. + /// + string VerifyKey { get; } + + /// + /// Gets the partial guild object of the application's developer's support server. if not set. + /// + PartialGuild Guild { get; } + + /// + /// Gets the redirect uris configured for the application. + /// + IReadOnlyCollection RedirectUris { get;} + + /// + /// Gets application's interactions endpoint url. if not set. + /// + string InteractionsEndpointUrl { get; } + + /// + /// Gets the approximate count of the guild the application was added to. if not returned. + /// + int? ApproximateGuildCount { get; } + + /// + /// Gets the application's discoverability state. + /// + ApplicationDiscoverabilityState DiscoverabilityState { get; } + + /// + /// Gets the application's discovery eligibility flags. + /// + DiscoveryEligibilityFlags DiscoveryEligibilityFlags { get; } + + /// + /// Gets the application's explicit content filter level for uploaded media content used in application commands. + /// + ApplicationExplicitContentFilterLevel ExplicitContentFilterLevel { get; } + + /// + /// Gets whether the bot is allowed to hook into the application's game directly. + /// + bool IsHook { get; } + + /// + /// Gets event types to be sent to the interaction endpoint. + /// + IReadOnlyCollection InteractionEventTypes { get; } + + /// + /// Gets the interactions version application uses. + /// + ApplicationInteractionsVersion InteractionsVersion { get; } + + /// + /// Whether the application has premium subscriptions. + /// + bool IsMonetized { get; } + + /// + /// Gets the application's monetization eligibility flags. + /// + ApplicationMonetizationEligibilityFlags MonetizationEligibilityFlags { get; } + + /// + /// Gets the application's monetization state. + /// + ApplicationMonetizationState MonetizationState { get; } + + /// + /// Gets the application's rpc state. + /// + ApplicationRpcState RpcState { get; } + + /// + /// Gets the application's store state. + /// + ApplicationStoreState StoreState { get; } + + /// + /// Gets the application's verification state. + /// + ApplicationVerificationState VerificationState { get; } + + /// + /// Gets application install params configured for integration install types. + /// + IReadOnlyDictionary IntegrationTypesConfig { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Applications/ModifyApplicationProperties.cs b/src/Discord.Net.Core/Entities/Applications/ModifyApplicationProperties.cs new file mode 100644 index 0000000..fec519d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Applications/ModifyApplicationProperties.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents properties used to modify current application's bot. +/// +public class ModifyApplicationProperties +{ + /// + /// Gets or sets the http interactions endpoint configured for the application. + /// + public Optional InteractionsEndpointUrl { get; set; } + + /// + /// Gets or sets the role connections verification endpoint configured for the application. + /// + public Optional RoleConnectionsEndpointUrl { get; set; } + + /// + /// Gets or sets the description of the application. + /// + public Optional Description { get; set; } + + /// + /// Gets or sets application's tags + /// + public Optional Tags { get; set; } + + /// + /// Gets or sets the icon of the application. + /// + public Optional Icon { get; set; } + + /// + /// Gets or sets the default rich presence invite cover image of the application. + /// + public Optional CoverImage { get; set; } + + /// + /// Gets or set the default custom authorization URL for the app, if enabled. + /// + public Optional CustomInstallUrl { get; set; } + + /// + /// Gets or sets settings for the app's default in-app authorization link, if enabled. + /// + public Optional InstallParams { get; set; } + + /// + /// Gets or sets app's public flags. + /// + /// + /// Only , and + /// flags can be updated. + /// + public Optional Flags { get; set; } + + /// + /// Gets or sets application install params configured for integration install types. + /// + public Optional> IntegrationTypesConfig { get; set; } + +} diff --git a/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs b/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs new file mode 100644 index 0000000..3288517 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/ActionType.cs @@ -0,0 +1,264 @@ +namespace Discord +{ + /// + /// Representing a type of action within an . + /// + public enum ActionType + { + /// + /// this guild was updated. + /// + GuildUpdated = 1, + + /// + /// A channel was created. + /// + ChannelCreated = 10, + /// + /// A channel was updated. + /// + ChannelUpdated = 11, + /// + /// A channel was deleted. + /// + ChannelDeleted = 12, + + /// + /// A permission overwrite was created for a channel. + /// + OverwriteCreated = 13, + /// + /// A permission overwrite was updated for a channel. + /// + OverwriteUpdated = 14, + /// + /// A permission overwrite was deleted for a channel. + /// + OverwriteDeleted = 15, + + /// + /// A user was kicked from this guild. + /// + Kick = 20, + /// + /// A prune took place in this guild. + /// + Prune = 21, + /// + /// A user banned another user from this guild. + /// + Ban = 22, + /// + /// A user unbanned another user from this guild. + /// + Unban = 23, + + /// + /// A guild member whose information was updated. + /// + MemberUpdated = 24, + /// + /// A guild member's role collection was updated. + /// + MemberRoleUpdated = 25, + /// + /// A guild member moved to a voice channel. + /// + MemberMoved = 26, + /// + /// A guild member disconnected from a voice channel. + /// + MemberDisconnected = 27, + /// + /// A bot was added to this guild. + /// + BotAdded = 28, + + /// + /// A role was created in this guild. + /// + RoleCreated = 30, + /// + /// A role was updated in this guild. + /// + RoleUpdated = 31, + /// + /// A role was deleted from this guild. + /// + RoleDeleted = 32, + + /// + /// An invite was created in this guild. + /// + InviteCreated = 40, + /// + /// An invite was updated in this guild. + /// + InviteUpdated = 41, + /// + /// An invite was deleted from this guild. + /// + InviteDeleted = 42, + + /// + /// A Webhook was created in this guild. + /// + WebhookCreated = 50, + /// + /// A Webhook was updated in this guild. + /// + WebhookUpdated = 51, + /// + /// A Webhook was deleted from this guild. + /// + WebhookDeleted = 52, + + /// + /// An emoji was created in this guild. + /// + EmojiCreated = 60, + /// + /// An emoji was updated in this guild. + /// + EmojiUpdated = 61, + /// + /// An emoji was deleted from this guild. + /// + EmojiDeleted = 62, + + /// + /// A message was deleted from this guild. + /// + MessageDeleted = 72, + /// + /// Multiple messages were deleted from this guild. + /// + MessageBulkDeleted = 73, + /// + /// A message was pinned from this guild. + /// + MessagePinned = 74, + /// + /// A message was unpinned from this guild. + /// + MessageUnpinned = 75, + + /// + /// A integration was created + /// + IntegrationCreated = 80, + /// + /// A integration was updated + /// + IntegrationUpdated = 81, + /// + /// An integration was deleted + /// + IntegrationDeleted = 82, + /// + /// A stage instance was created. + /// + StageInstanceCreated = 83, + /// + /// A stage instance was updated. + /// + StageInstanceUpdated = 84, + /// + /// A stage instance was deleted. + /// + StageInstanceDeleted = 85, + + /// + /// A sticker was created. + /// + StickerCreated = 90, + /// + /// A sticker was updated. + /// + StickerUpdated = 91, + /// + /// A sticker was deleted. + /// + StickerDeleted = 92, + + /// + /// A scheduled event was created. + /// + EventCreate = 100, + /// + /// A scheduled event was created. + /// + EventUpdate = 101, + /// + /// A scheduled event was created. + /// + EventDelete = 102, + + /// + /// A thread was created. + /// + ThreadCreate = 110, + /// + /// A thread was updated. + /// + ThreadUpdate = 111, + /// + /// A thread was deleted. + /// + ThreadDelete = 112, + /// + /// Permissions were updated for a command. + /// + ApplicationCommandPermissionUpdate = 121, + + /// + /// Auto Moderation rule was created. + /// + AutoModerationRuleCreate = 140, + /// + /// Auto Moderation rule was updated. + /// + AutoModerationRuleUpdate = 141, + /// + /// Auto Moderation rule was deleted. + /// + AutoModerationRuleDelete = 142, + /// + /// Message was blocked by Auto Moderation. + /// + AutoModerationBlockMessage = 143, + /// + /// Message was flagged by Auto Moderation. + /// + AutoModerationFlagToChannel = 144, + /// + /// Member was timed out by Auto Moderation. + /// + AutoModerationUserCommunicationDisabled = 145, + + /// + /// Guild Onboarding Question was created. + /// + OnboardingQuestionCreated = 163, + + /// + /// Guild Onboarding Question was updated. + /// + OnboardingQuestionUpdated = 164, + + /// + /// Guild Onboarding was updated. + /// + OnboardingUpdated = 167, + + /// + /// A voice channel status was updated by a user. + /// + VoiceChannelStatusUpdated = 192, + + /// + /// A voice channel status was deleted by a user. + /// + VoiceChannelStatusDeleted = 193 + } +} diff --git a/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogData.cs b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogData.cs new file mode 100644 index 0000000..a99a14e --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogData.cs @@ -0,0 +1,8 @@ +namespace Discord +{ + /// + /// Represents data applied to an . + /// + public interface IAuditLogData + { } +} diff --git a/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogEntry.cs b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogEntry.cs new file mode 100644 index 0000000..7e38758 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogEntry.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic audit log entry. + /// + public interface IAuditLogEntry : ISnowflakeEntity + { + /// + /// Gets the action which occurred to create this entry. + /// + /// + /// The type of action for this audit log entry. + /// + ActionType Action { get; } + + /// + /// Gets the data for this entry. + /// + /// + /// An for this audit log entry; if no data is available. + /// + IAuditLogData Data { get; } + + /// + /// Gets the user responsible for causing the changes. + /// + /// + /// A user object. + /// + IUser User { get; } + + /// + /// Gets the reason behind the change. + /// + /// + /// A string containing the reason for the change; if none is provided. + /// + string Reason { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogInfoModel.cs b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogInfoModel.cs new file mode 100644 index 0000000..880c6e3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AuditLogs/IAuditLogInfoModel.cs @@ -0,0 +1,6 @@ +namespace Discord; + +public interface IAuditLogInfoModel +{ + +} diff --git a/src/Discord.Net.Core/Entities/CacheMode.cs b/src/Discord.Net.Core/Entities/CacheMode.cs new file mode 100644 index 0000000..503a804 --- /dev/null +++ b/src/Discord.Net.Core/Entities/CacheMode.cs @@ -0,0 +1,17 @@ +namespace Discord +{ + /// + /// Specifies the cache mode that should be used. + /// + public enum CacheMode + { + /// + /// Allows the object to be downloaded if it does not exist in the current cache. + /// + AllowDownload, + /// + /// Only allows the object to be pulled from the existing cache. + /// + CacheOnly + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/AudioChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/AudioChannelProperties.cs new file mode 100644 index 0000000..3b2cabc --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/AudioChannelProperties.cs @@ -0,0 +1,18 @@ +namespace Discord +{ + /// + /// Provides properties that are used to modify an with the specified changes. + /// + public class AudioChannelProperties + { + /// + /// Sets whether the user should be muted. + /// + public Optional SelfMute { get; set; } + + /// + /// Sets whether the user should be deafened. + /// + public Optional SelfDeaf { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ChannelFlags.cs b/src/Discord.Net.Core/Entities/Channels/ChannelFlags.cs new file mode 100644 index 0000000..13d0db9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ChannelFlags.cs @@ -0,0 +1,27 @@ +namespace Discord; + +/// +/// Represents public flags for a channel. +/// +public enum ChannelFlags +{ + /// + /// Default value for flags, when none are given to a channel. + /// + None = 0, + + /// + /// Flag given to a thread channel pinned on top of parent forum channel. + /// + Pinned = 1 << 1, + + /// + /// Flag given to a forum or media channel that requires people to select tags when posting. + /// + RequireTag = 1 << 4, + + /// + /// Flag given to a media channel that hides the embedded media download options. + /// + HideMediaDownloadOption = 1 << 15, +} diff --git a/src/Discord.Net.Core/Entities/Channels/ChannelType.cs b/src/Discord.Net.Core/Entities/Channels/ChannelType.cs new file mode 100644 index 0000000..bc39a56 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ChannelType.cs @@ -0,0 +1,75 @@ +namespace Discord; + +/// Defines the types of channels. +public enum ChannelType +{ + /// + /// The channel is a text channel. + /// + Text = 0, + + /// + /// The channel is a Direct Message channel. + /// + DM = 1, + + /// + /// The channel is a voice channel. + /// + Voice = 2, + + /// + /// The channel is a group channel. + /// + Group = 3, + + /// + /// The channel is a category channel. + /// + Category = 4, + + /// + /// The channel is a news channel. + /// + News = 5, + + /// + /// The channel is a store channel. + /// + Store = 6, + + /// + /// The channel is a temporary thread channel under a news channel. + /// + NewsThread = 10, + + /// + /// The channel is a temporary thread channel under a text channel. + /// + PublicThread = 11, + + /// + /// The channel is a private temporary thread channel under a text channel. + /// + PrivateThread = 12, + + /// + /// The channel is a stage voice channel. + /// + Stage = 13, + + /// + /// The channel is a guild directory used in hub servers. (Unreleased) + /// + GuildDirectory = 14, + + /// + /// The channel is a forum channel containing multiple threads. + /// + Forum = 15, + + /// + /// The channel is a media channel containing multiple threads. + /// + Media = 16, +} diff --git a/src/Discord.Net.Core/Entities/Channels/Direction.cs b/src/Discord.Net.Core/Entities/Channels/Direction.cs new file mode 100644 index 0000000..4149617 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/Direction.cs @@ -0,0 +1,30 @@ +namespace Discord +{ + /// + /// Specifies the direction of where entities (e.g. bans/messages) should be retrieved from. + /// + /// + /// This enum is used to specify the direction for retrieving entities. + /// + /// At the time of writing, is not yet implemented into + /// . + /// Attempting to use the method with will throw + /// a . + /// + /// + public enum Direction + { + /// + /// The entity(s) should be retrieved before an entity. + /// + Before, + /// + /// The entity(s) should be retrieved after an entity. + /// + After, + /// + /// The entity(s) should be retrieved around an entity. + /// + Around + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ForumChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/ForumChannelProperties.cs new file mode 100644 index 0000000..336e17e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ForumChannelProperties.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Discord; + +public class ForumChannelProperties : TextChannelProperties +{ + + /// + /// Gets or sets the topic of the channel. + /// + /// + /// Not available in forum channels. + /// + public new Optional SlowModeInterval { get; } + + /// + /// Gets or sets rate limit on creating posts in this forum channel. + /// + /// + /// Setting this value to anything above zero will require each user to wait X seconds before + /// creating another thread; setting this value to 0 will disable rate limits for this channel. + /// + /// Users with or + /// will be exempt from rate limits. + /// + /// + /// Thrown if the value does not fall within [0, 21600]. + public Optional ThreadCreationInterval { get; set; } + + /// + /// Gets or sets a collection of tags inside of this forum channel. + /// + public Optional> Tags { get; set; } + + /// + /// Gets or sets a new default reaction emoji in this forum channel. + /// + public Optional DefaultReactionEmoji { get; set; } + + /// + /// Gets or sets the rule used to order posts in forum channels. + /// + public Optional DefaultSortOrder { get; set; } + + /// + /// Gets or sets the rule used to display posts in a forum channel. + /// + /// + /// This property cannot be changed in media channels. + /// + public Optional DefaultLayout { get; set; } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ForumLayout.cs b/src/Discord.Net.Core/Entities/Channels/ForumLayout.cs new file mode 100644 index 0000000..d20a105 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ForumLayout.cs @@ -0,0 +1,22 @@ +namespace Discord; + +/// +/// Represents the layout type used to display posts in a forum channel. +/// +public enum ForumLayout +{ + /// + /// A preferred forum layout hasn't been set by a server admin + /// + Default = 0, + + /// + /// List View: display forum posts in a text-focused list + /// + List = 1, + + /// + /// Gallery View: display forum posts in a media-focused gallery + /// + Grid = 2 +} diff --git a/src/Discord.Net.Core/Entities/Channels/ForumSortOrder.cs b/src/Discord.Net.Core/Entities/Channels/ForumSortOrder.cs new file mode 100644 index 0000000..2a576d9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ForumSortOrder.cs @@ -0,0 +1,17 @@ +namespace Discord; + +/// +/// Defines the rule used to order posts in forum channels. +/// +public enum ForumSortOrder +{ + /// + /// Sort forum posts by activity. + /// + LatestActivity = 0, + + /// + /// Sort forum posts by creation time (from most recent to oldest). + /// + CreationDate = 1 +} diff --git a/src/Discord.Net.Core/Entities/Channels/GuildChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/GuildChannelProperties.cs new file mode 100644 index 0000000..1e7d69c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/GuildChannelProperties.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Properties that are used to modify an with the specified changes. + /// + /// + public class GuildChannelProperties + { + /// + /// Gets or sets the channel to this name. + /// + /// + /// This property defines the new name for this channel. + /// + /// When modifying an , the must be alphanumeric with + /// dashes. It must match the RegEx [a-z0-9-_]{2,100}. + /// + /// + public Optional Name { get; set; } + /// + /// Moves the channel to the following position. This property is zero-based. + /// + public Optional Position { get; set; } + /// + /// Gets or sets the category ID for this channel. + /// + /// + /// Setting this value to a category's snowflake identifier will change or set this channel's parent to the + /// specified channel; setting this value to will detach this channel from its parent if one + /// is set. + /// + public Optional CategoryId { get; set; } + /// + /// Gets or sets the permission overwrites for this channel. + /// + public Optional> PermissionOverwrites { get; set; } + + /// + /// Gets or sets the flags of the channel. + /// + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IAudioChannel.cs b/src/Discord.Net.Core/Entities/Channels/IAudioChannel.cs new file mode 100644 index 0000000..dba706b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IAudioChannel.cs @@ -0,0 +1,52 @@ +using Discord.Audio; +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic audio channel. + /// + public interface IAudioChannel : IChannel + { + /// + /// Gets the RTC region for this audio channel. + /// + /// + /// This property can be . + /// + string RTCRegion { get; } + + /// + /// Connects to this audio channel. + /// + /// Determines whether the client should deaf itself upon connection. + /// Determines whether the client should mute itself upon connection. + /// Determines whether the audio client is an external one or not. + /// Determines whether the client should send a disconnect call before sending the new voice state. + /// + /// A task representing the asynchronous connection operation. The task result contains the + /// responsible for the connection. + /// + Task ConnectAsync(bool selfDeaf = false, bool selfMute = false, bool external = false, bool disconnect = true); + + /// + /// Disconnects from this audio channel. + /// + /// + /// A task representing the asynchronous operation for disconnecting from the audio channel. + /// + Task DisconnectAsync(); + + /// + /// Modifies this audio channel. + /// + /// The properties to modify the channel with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + /// + Task ModifyAsync(Action func, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ICategoryChannel.cs b/src/Discord.Net.Core/Entities/Channels/ICategoryChannel.cs new file mode 100644 index 0000000..838908b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ICategoryChannel.cs @@ -0,0 +1,9 @@ +namespace Discord +{ + /// + /// Represents a generic category channel. + /// + public interface ICategoryChannel : IGuildChannel + { + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IChannel.cs b/src/Discord.Net.Core/Entities/Channels/IChannel.cs new file mode 100644 index 0000000..1de3724 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IChannel.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic channel. + /// + public interface IChannel : ISnowflakeEntity + { + /// + /// Gets the name of this channel. + /// + /// + /// A string containing the name of this channel. + /// + string Name { get; } + + /// + /// Gets a collection of users that are able to view the channel or are currently in this channel. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// This method will attempt to fetch all users that is able to view this channel or is currently in this channel. + /// The library will attempt to split up the requests according to and . + /// In other words, if there are 3000 users, and the constant + /// is 1000, the request will be split into 3 individual requests; thus returning 53individual asynchronous + /// responses, hence the need of flattening. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + IAsyncEnumerable> GetUsersAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets a user in this channel. + /// + /// The snowflake identifier of the user (e.g. 168693960628371456). + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a user object that + /// represents the found user; if none is found. + /// + Task GetUserAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IDMChannel.cs b/src/Discord.Net.Core/Entities/Channels/IDMChannel.cs new file mode 100644 index 0000000..f0ef7f3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IDMChannel.cs @@ -0,0 +1,27 @@ +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic direct-message channel. + /// + public interface IDMChannel : IMessageChannel, IPrivateChannel + { + /// + /// Gets the recipient of all messages in this channel. + /// + /// + /// A user object that represents the other user in this channel. + /// + IUser Recipient { get; } + + /// + /// Closes this private channel, removing it from your channel list. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous close operation. + /// + Task CloseAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IForumChannel.cs b/src/Discord.Net.Core/Entities/Channels/IForumChannel.cs new file mode 100644 index 0000000..dda6a6c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IForumChannel.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a forum channel in a guild that can create posts. + /// + public interface IForumChannel : IMentionable, INestedChannel, IIntegrationChannel + { + /// + /// Gets a value that indicates whether the channel is NSFW. + /// + /// + /// if the channel has the NSFW flag enabled; otherwise . + /// + bool IsNsfw { get; } + + /// + /// Gets the current topic for this text channel. + /// + /// + /// A string representing the topic set in the channel; if none is set. + /// + string Topic { get; } + + /// + /// Gets the default archive duration for a newly created post. + /// + ThreadArchiveDuration DefaultAutoArchiveDuration { get; } + + /// + /// Gets a collection of tags inside of this forum channel. + /// + IReadOnlyCollection Tags { get; } + + /// + /// Gets the current rate limit on creating posts in this forum channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// + int ThreadCreationInterval { get; } + + /// + /// Gets the current default slow-mode delay for threads in this forum channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// + int DefaultSlowModeInterval { get; } + + /// + /// Gets the emoji to show in the add reaction button on a thread in a forum channel + /// + /// + /// If the emoji is only the will be populated. + /// Use to get the emoji. + /// + IEmote DefaultReactionEmoji { get; } + + /// + /// Gets the rule used to order posts in forum channels. + /// + /// + /// Defaults to null, which indicates a preferred sort order hasn't been set + /// + ForumSortOrder? DefaultSortOrder { get; } + + /// + /// Gets the rule used to display posts in a forum channel. + /// + ForumLayout DefaultLayout { get; } + + /// + /// Modifies this forum channel. + /// + /// + /// This method modifies the current forum channel with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// The delegate containing the properties to modify the channel with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Creates a new post (thread) within the forum. + /// + /// The title of the post. + /// The archive duration of the post. + /// The slowmode for the posts thread. + /// The message to be sent. + /// The to be sent. + /// The options to be used when sending the request. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the message. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only is permitted. + /// An array of to be applied to the post. + /// + /// A task that represents the asynchronous creation operation. + /// + Task CreatePostAsync(string title, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, int? slowmode = null, + string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null); + + /// + /// Creates a new post (thread) within the forum. + /// + /// The title of the post. + /// The archive duration of the post. + /// The slowmode for the posts thread. + /// The file path of the file. + /// The message to be sent. + /// The to be sent. + /// The options to be used when sending the request. + /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only is permitted. + /// An array of to be applied to the post. + /// + /// A task that represents the asynchronous creation operation. + /// + Task CreatePostWithFileAsync(string title, string filePath, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, + ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null); + + /// + /// Creates a new post (thread) within the forum. + /// + /// The title of the post. + /// The of the file to be sent. + /// The name of the attachment. + /// The archive duration of the post. + /// The slowmode for the posts thread. + /// The message to be sent. + /// The to be sent. + /// The options to be used when sending the request. + /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only is permitted. + /// An array of to be applied to the post. + /// + /// A task that represents the asynchronous creation operation. + /// + public Task CreatePostWithFileAsync(string title, Stream stream, string filename, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, + ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null); + + /// + /// Creates a new post (thread) within the forum. + /// + /// The title of the post. + /// The attachment containing the file and description. + /// The archive duration of the post. + /// The slowmode for the posts thread. + /// The message to be sent. + /// The to be sent. + /// The options to be used when sending the request. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only is permitted. + /// An array of to be applied to the post. + /// + /// A task that represents the asynchronous creation operation. + /// + public Task CreatePostWithFileAsync(string title, FileAttachment attachment, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null); + + /// + /// Creates a new post (thread) within the forum. + /// + /// The title of the post. + /// A collection of attachments to upload. + /// The archive duration of the post. + /// The slowmode for the posts thread. + /// The message to be sent. + /// The to be sent. + /// The options to be used when sending the request. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// An array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only is permitted. + /// An array of to be applied to the post. + /// + /// A task that represents the asynchronous creation operation. + /// + public Task CreatePostWithFilesAsync(string title, IEnumerable attachments, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null); + + /// + /// Gets a collection of active threads within this forum channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the threads. The task result contains + /// a collection of active threads. + /// + Task> GetActiveThreadsAsync(RequestOptions options = null); + + /// + /// Gets a collection of publicly archived threads within this forum channel. + /// + /// The optional limit of how many to get. + /// The optional date to return threads created before this timestamp. + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the threads. The task result contains + /// a collection of publicly archived threads. + /// + Task> GetPublicArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null); + + /// + /// Gets a collection of privately archived threads within this forum channel. + /// + /// + /// The bot requires the permission in order to execute this request. + /// + /// The optional limit of how many to get. + /// The optional date to return threads created before this timestamp. + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the threads. The task result contains + /// a collection of privately archived threads. + /// + Task> GetPrivateArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null); + + /// + /// Gets a collection of privately archived threads that the current bot has joined within this forum channel. + /// + /// The optional limit of how many to get. + /// The optional date to return threads created before this timestamp. + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the threads. The task result contains + /// a collection of privately archived threads. + /// + Task> GetJoinedPrivateArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IGroupChannel.cs b/src/Discord.Net.Core/Entities/Channels/IGroupChannel.cs new file mode 100644 index 0000000..77af345 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IGroupChannel.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic private group channel. + /// + public interface IGroupChannel : IMessageChannel, IPrivateChannel, IAudioChannel + { + /// + /// Leaves this group. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous leave operation. + /// + Task LeaveAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IGuildChannel.cs b/src/Discord.Net.Core/Entities/Channels/IGuildChannel.cs new file mode 100644 index 0000000..f948e31 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IGuildChannel.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic guild channel. + /// + /// + /// + /// + public interface IGuildChannel : IChannel, IDeletable + { + /// + /// Gets the position of this channel. + /// + /// + /// An representing the position of this channel in the guild's channel list relative to + /// others of the same type. + /// + int Position { get; } + + /// + /// Gets the flags related to this channel. + /// + /// + /// This value is determined by bitwise OR-ing values together. + /// + /// + /// A channel's flags, if any is associated. + /// + ChannelFlags Flags { get; } + + /// + /// Gets the guild associated with this channel. + /// + /// + /// A guild object that this channel belongs to. + /// + IGuild Guild { get; } + /// + /// Gets the guild ID associated with this channel. + /// + /// + /// An representing the guild snowflake identifier for the guild that this channel + /// belongs to. + /// + ulong GuildId { get; } + /// + /// Gets a collection of permission overwrites for this channel. + /// + /// + /// A collection of overwrites associated with this channel. + /// + IReadOnlyCollection PermissionOverwrites { get; } + + /// + /// Modifies this guild channel. + /// + /// + /// This method modifies the current guild channel with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// The delegate containing the properties to modify the channel with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Gets the permission overwrite for a specific role. + /// + /// The role to get the overwrite from. + /// + /// An overwrite object for the targeted role; if none is set. + /// + OverwritePermissions? GetPermissionOverwrite(IRole role); + /// + /// Gets the permission overwrite for a specific user. + /// + /// The user to get the overwrite from. + /// + /// An overwrite object for the targeted user; if none is set. + /// + OverwritePermissions? GetPermissionOverwrite(IUser user); + /// + /// Removes the permission overwrite for the given role, if one exists. + /// + /// The role to remove the overwrite from. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous operation for removing the specified permissions from the channel. + /// + Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null); + /// + /// Removes the permission overwrite for the given user, if one exists. + /// + /// The user to remove the overwrite from. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous operation for removing the specified permissions from the channel. + /// + Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null); + + /// + /// Adds or updates the permission overwrite for the given role. + /// + /// + /// The following example fetches a role via and a channel via + /// . Next, it checks if an overwrite had already been set via + /// ; if not, it denies the role from sending any + /// messages to the channel. + /// + /// + /// The role to add the overwrite to. + /// The overwrite to add to the role. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous permission operation for adding the specified permissions to the + /// channel. + /// + Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null); + /// + /// Adds or updates the permission overwrite for the given user. + /// + /// + /// The following example fetches a user via and a channel via + /// . Next, it checks if an overwrite had already been set via + /// ; if not, it denies the user from sending any + /// messages to the channel. + /// + /// + /// The user to add the overwrite to. + /// The overwrite to add to the user. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous permission operation for adding the specified permissions to the channel. + /// + Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null); + + /// + /// Gets a collection of users that are able to view the channel or are currently in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + new IAsyncEnumerable> GetUsersAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a user in this channel. + /// + /// The snowflake identifier of the user. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous get operation. The task result contains a guild user object that + /// represents the user; if none is found. + /// + new Task GetUserAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IIntegrationChannel.cs b/src/Discord.Net.Core/Entities/Channels/IIntegrationChannel.cs new file mode 100644 index 0000000..0dab959 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IIntegrationChannel.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord; + +/// +/// Represents a channel in a guild that can create webhooks. +/// +public interface IIntegrationChannel : IGuildChannel +{ + /// + /// Creates a webhook in this channel. + /// + /// The name of the webhook. + /// The avatar of the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// webhook. + /// + Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null); + + /// + /// Gets a webhook available in this channel. + /// + /// The identifier of the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a webhook associated + /// with the identifier; if the webhook is not found. + /// + Task GetWebhookAsync(ulong id, RequestOptions options = null); + + /// + /// Gets the webhooks available in this channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of webhooks that is available in this channel. + /// + Task> GetWebhooksAsync(RequestOptions options = null); +} diff --git a/src/Discord.Net.Core/Entities/Channels/IMediaChannel.cs b/src/Discord.Net.Core/Entities/Channels/IMediaChannel.cs new file mode 100644 index 0000000..93cd65a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IMediaChannel.cs @@ -0,0 +1,9 @@ +namespace Discord; + +/// +/// Represents a media channel in a guild that can create posts. +/// +public interface IMediaChannel : IForumChannel +{ + +} diff --git a/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs b/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs new file mode 100644 index 0000000..6e5d23f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IMessageChannel.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic channel that can send and receive messages. + /// + public interface IMessageChannel : IChannel + { + /// + /// Sends a message to this message channel. + /// + /// + /// The following example sends a message with the current system time in RFC 1123 format to the channel and + /// deletes itself after 5 seconds. + /// + /// + /// The message to be sent. + /// Determines whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message references to be included. Used to reply to specific messages. + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the message. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only + /// and is permitted. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + /// + /// Sends a file to this message channel with an optional caption. + /// + /// + /// The following example uploads a local file called wumpus.txt along with the text + /// good discord boi to the channel. + /// + /// The following example uploads a local image called b1nzy.jpg embedded inside a rich embed to the + /// channel. + /// + /// + /// + /// This method sends a file as if you are uploading an attachment directly from your Discord client. + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// The file path of the file. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message references to be included. Used to reply to specific messages. + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only and is permitted. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + /// + /// Sends a file to this message channel with an optional caption. + /// + /// + /// The following example uploads a streamed image that will be called b1nzy.jpg embedded inside a + /// rich embed to the channel. + /// + /// + /// + /// This method sends a file as if you are uploading an attachment directly from your Discord client. + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// The of the file to be sent. + /// The name of the attachment. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// Whether the message attachment should be hidden as a spoiler. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message references to be included. Used to reply to specific messages. + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only and is permitted. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + /// + /// Sends a file to this message channel with an optional caption. + /// + /// + /// This method sends a file as if you are uploading an attachment directly from your Discord client. + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// The attachment containing the file and description. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message references to be included. Used to reply to specific messages. + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only and is permitted. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + /// + /// Sends a collection of files to this message channel. + /// + /// + /// This method sends files as if you are uploading attachments directly from your Discord client. + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// A collection of attachments to upload. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message references to be included. Used to reply to specific messages. + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the file. + /// A array of s to send with this response. Max 10. + /// A message flag to be applied to the sent message, only and is permitted. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + /// Gets a message from this message channel. + /// + /// The snowflake identifier of the message. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the message. The task result contains + /// the retrieved message; if no message is found with the specified identifier. + /// + Task GetMessageAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets the last N messages from this message channel. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// + /// Do not fetch too many messages at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// This method will attempt to fetch the number of messages specified under . The + /// library will attempt to split up the requests according to your and + /// . In other words, should the user request 500 messages, + /// and the constant is 100, the request will + /// be split into 5 individual requests; thus returning 5 individual asynchronous responses, hence the need + /// of flattening. + /// + /// + /// The following example downloads 300 messages and gets messages that belong to the user + /// 53905483156684800. + /// + /// + /// The numbers of message to be gotten from. + /// The that determines whether the object should be fetched from + /// cache. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, + CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// + /// Do not fetch too many messages at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// This method will attempt to fetch the number of messages specified under around + /// the message depending on the . The library will + /// attempt to split up the requests according to your and + /// . In other words, should the user request 500 messages, + /// and the constant is 100, the request will + /// be split into 5 individual requests; thus returning 5 individual asynchronous responses, hence the need + /// of flattening. + /// + /// + /// The following example gets 5 message prior to the message identifier 442012544660537354. + /// + /// The following example attempts to retrieve messageCount number of messages from the + /// beginning of the channel and prints them to the console. + /// + /// + /// The ID of the starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The that determines whether the object should be fetched from + /// cache. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, + CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// + /// Do not fetch too many messages at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// This method will attempt to fetch the number of messages specified under around + /// the message depending on the . The library will + /// attempt to split up the requests according to your and + /// . In other words, should the user request 500 messages, + /// and the constant is 100, the request will + /// be split into 5 individual requests; thus returning 5 individual asynchronous responses, hence the need + /// of flattening. + /// + /// + /// The following example gets 5 message prior to a specific message, oldMessage. + /// + /// + /// The starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The that determines whether the object should be fetched from + /// cache. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, + CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of pinned messages in this channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation for retrieving pinned messages in this channel. + /// The task result contains a collection of messages found in the pinned messages. + /// + Task> GetPinnedMessagesAsync(RequestOptions options = null); + + /// + /// Deletes a message. + /// + /// The snowflake identifier of the message that would be removed. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task DeleteMessageAsync(ulong messageId, RequestOptions options = null); + /// Deletes a message based on the provided message in this channel. + /// The message that would be removed. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task DeleteMessageAsync(IMessage message, RequestOptions options = null); + + /// + /// Modifies a message. + /// + /// + /// This method modifies this message with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// The snowflake identifier of the message that would be changed. + /// A delegate containing the properties to modify the message with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null); + + /// + /// Broadcasts the "user is typing" message to all users in this channel, lasting 10 seconds. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation that triggers the broadcast. + /// + Task TriggerTypingAsync(RequestOptions options = null); + /// + /// Continuously broadcasts the "user is typing" message to all users in this channel until the returned + /// object is disposed. + /// + /// + /// The following example keeps the client in the typing state until LongRunningAsync has finished. + /// + /// + /// The options to be used when sending the request. + /// + /// A disposable object that, upon its disposal, will stop the client from broadcasting its typing state in + /// this channel. + /// + IDisposable EnterTypingState(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs new file mode 100644 index 0000000..973ac7b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/INestedChannel.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a type of guild channel that can be nested within a category. + /// + public interface INestedChannel : IGuildChannel + { + /// + /// Gets the parent (category) ID of this channel in the guild's channel list. + /// + /// + /// A representing the snowflake identifier of the parent of this channel; + /// if none is set. + /// + ulong? CategoryId { get; } + /// + /// Gets the parent (category) channel of this channel. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the category channel + /// representing the parent of this channel; if none is set. + /// + Task GetCategoryAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Syncs the permissions of this nested channel with its parent's. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for syncing channel permissions with its parent's. + /// + Task SyncPermissionsAsync(RequestOptions options = null); + + /// + /// Creates a new invite to this channel. + /// + /// + /// The following example creates a new invite to this channel; the invite lasts for 12 hours and can only + /// be used 3 times throughout its lifespan. + /// + /// await guildChannel.CreateInviteAsync(maxAge: 43200, maxUses: 3); + /// + /// + /// The time (in seconds) until the invite expires. Set to to never expire. + /// The max amount of times this invite may be used. Set to to have unlimited uses. + /// If , the user accepting this invite will be kicked from the guild after closing their client. + /// If , don't try to reuse a similar invite (useful for creating many unique one time use invites). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous invite creation operation. The task result contains an invite + /// metadata object containing information for the created invite. + /// + Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null); + + /// + /// Creates a new invite to this channel. + /// + /// The id of the embedded application to open for this invite. + /// The time (in seconds) until the invite expires. Set to to never expire. + /// The max amount of times this invite may be used. Set to to have unlimited uses. + /// If , the user accepting this invite will be kicked from the guild after closing their client. + /// If , don't try to reuse a similar invite (useful for creating many unique one time use invites). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous invite creation operation. The task result contains an invite + /// metadata object containing information for the created invite. + /// + Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null); + + /// + /// Creates a new invite to this channel. + /// + /// The application to open for this invite. + /// The time (in seconds) until the invite expires. Set to to never expire. + /// The max amount of times this invite may be used. Set to to have unlimited uses. + /// If , the user accepting this invite will be kicked from the guild after closing their client. + /// If , don't try to reuse a similar invite (useful for creating many unique one time use invites). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous invite creation operation. The task result contains an invite + /// metadata object containing information for the created invite. + /// + Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null); + + /// + /// Creates a new invite to this channel. + /// + /// + /// The following example creates a new invite to this channel; the invite lasts for 12 hours and can only + /// be used 3 times throughout its lifespan. + /// + /// await guildChannel.CreateInviteAsync(maxAge: 43200, maxUses: 3); + /// + /// + /// The id of the user whose stream to display for this invite. + /// The time (in seconds) until the invite expires. Set to to never expire. + /// The max amount of times this invite may be used. Set to to have unlimited uses. + /// If , the user accepting this invite will be kicked from the guild after closing their client. + /// If , don't try to reuse a similar invite (useful for creating many unique one time use invites). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous invite creation operation. The task result contains an invite + /// metadata object containing information for the created invite. + /// + Task CreateInviteToStreamAsync(IUser user, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null); + /// + /// Gets a collection of all invites to this channel. + /// B + /// + /// The following example gets all of the invites that have been created in this channel and selects the + /// most used invite. + /// + /// var invites = await channel.GetInvitesAsync(); + /// if (invites.Count == 0) return; + /// var invite = invites.OrderByDescending(x => x.Uses).FirstOrDefault(); + /// + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of invite metadata that are created for this channel. + /// + Task> GetInvitesAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/INewsChannel.cs b/src/Discord.Net.Core/Entities/Channels/INewsChannel.cs new file mode 100644 index 0000000..7a94ac8 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/INewsChannel.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace Discord; + +/// +/// Represents a generic news channel in a guild that can send and receive messages. +/// +public interface INewsChannel : ITextChannel +{ + /// + /// Follow this channel to send messages to a target channel. + /// + /// + /// The Id of the created webhook. + /// + Task FollowAnnouncementChannelAsync(ulong channelId, RequestOptions options = null); +} diff --git a/src/Discord.Net.Core/Entities/Channels/IPrivateChannel.cs b/src/Discord.Net.Core/Entities/Channels/IPrivateChannel.cs new file mode 100644 index 0000000..cd2307c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IPrivateChannel.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a generic channel that is private to select recipients. + /// + public interface IPrivateChannel : IChannel + { + /// + /// Gets the users that can access this channel. + /// + /// + /// A read-only collection of users that can access this channel. + /// + IReadOnlyCollection Recipients { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IStageChannel.cs b/src/Discord.Net.Core/Entities/Channels/IStageChannel.cs new file mode 100644 index 0000000..8dbc4c2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IStageChannel.cs @@ -0,0 +1,106 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic Stage Channel. + /// + public interface IStageChannel : IVoiceChannel + { + /// + /// Gets the of the current stage. + /// + /// + /// If the stage isn't live then this property will be set to . + /// + StagePrivacyLevel? PrivacyLevel { get; } + + /// + /// Gets whether or not stage discovery is disabled. + /// + bool? IsDiscoverableDisabled { get; } + + /// + /// Gets whether or not the stage is live. + /// + bool IsLive { get; } + + /// + /// Starts the stage, creating a stage instance. + /// + /// The topic for the stage/ + /// The privacy level of the stage. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous start operation. + /// + Task StartStageAsync(string topic, StagePrivacyLevel privacyLevel = StagePrivacyLevel.GuildOnly, RequestOptions options = null); + + /// + /// Modifies the current stage instance. + /// + /// The properties to modify the stage instance with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modify operation. + /// + Task ModifyInstanceAsync(Action func, RequestOptions options = null); + + /// + /// Stops the stage, deleting the stage instance. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous stop operation. + /// + Task StopStageAsync(RequestOptions options = null); + + /// + /// Indicates that the bot would like to speak within a stage channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous request to speak operation. + /// + Task RequestToSpeakAsync(RequestOptions options = null); + + /// + /// Makes the current user become a speaker within a stage. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous speaker modify operation. + /// + Task BecomeSpeakerAsync(RequestOptions options = null); + + /// + /// Makes the current user a listener. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous stop operation. + /// + Task StopSpeakingAsync(RequestOptions options = null); + + /// + /// Makes a user a speaker within a stage. + /// + /// The user to make the speaker. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous move operation. + /// + Task MoveToSpeakerAsync(IGuildUser user, RequestOptions options = null); + + /// + /// Removes a user from speaking. + /// + /// The user to remove from speaking. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous remove operation. + /// + Task RemoveFromSpeakerAsync(IGuildUser user, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ITextChannel.cs b/src/Discord.Net.Core/Entities/Channels/ITextChannel.cs new file mode 100644 index 0000000..54956f1 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ITextChannel.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic channel in a guild that can send and receive messages. + /// + public interface ITextChannel : IMessageChannel, IMentionable, INestedChannel, IIntegrationChannel + { + /// + /// Gets a value that indicates whether the channel is NSFW. + /// + /// + /// if the channel has the NSFW flag enabled; otherwise . + /// + bool IsNsfw { get; } + + /// + /// Gets the current topic for this text channel. + /// + /// + /// A string representing the topic set in the channel; if none is set. + /// + string Topic { get; } + + /// + /// Gets the current slow-mode delay for this channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// + int SlowModeInterval { get; } + + /// + /// Gets the current default slow-mode delay for threads in this channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// + int DefaultSlowModeInterval { get; } + + /// + /// Gets the default auto-archive duration for client-created threads in this channel. + /// + /// + /// The value of this property does not affect API thread creation, it will not respect this value. + /// + /// + /// The default auto-archive duration for thread creation in this channel. + /// + ThreadArchiveDuration DefaultArchiveDuration { get; } + + /// + /// Bulk-deletes multiple messages. + /// + /// + /// The following example gets 250 messages from the channel and deletes them. + /// + /// var messages = await textChannel.GetMessagesAsync(250).FlattenAsync(); + /// await textChannel.DeleteMessagesAsync(messages); + /// + /// + /// + /// This method attempts to remove the messages specified in bulk. + /// + /// Due to the limitation set by Discord, this method can only remove messages that are posted within 14 days! + /// + /// + /// The messages to be bulk-deleted. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous bulk-removal operation. + /// + Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null); + /// + /// Bulk-deletes multiple messages. + /// + /// + /// This method attempts to remove the messages specified in bulk. + /// + /// Due to the limitation set by Discord, this method can only remove messages that are posted within 14 days! + /// + /// + /// The snowflake identifier of the messages to be bulk-deleted. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous bulk-removal operation. + /// + Task DeleteMessagesAsync(IEnumerable messageIds, RequestOptions options = null); + + /// + /// Modifies this text channel. + /// + /// The delegate containing the properties to modify the channel with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Creates a thread within this . + /// + /// + /// When is the thread type will be based off of the + /// channel its created in. When called on a , it creates a . + /// When called on a , it creates a . The id of the created + /// thread will be the same as the id of the message, and as such a message can only have a + /// single thread created from it. + /// + /// The name of the thread. + /// + /// The type of the thread. + /// + /// Note: This parameter is not used if the parameter is not specified. + /// + /// + /// + /// The duration on which this thread archives after. + /// + /// Note: Options and + /// are only available for guilds that are boosted. You can check in the to see if the + /// guild has the THREE_DAY_THREAD_ARCHIVE and SEVEN_DAY_THREAD_ARCHIVE. + /// + /// + /// The message which to start the thread from. + /// Whether non-moderators can add other non-moderators to a thread; only available when creating a private thread + /// The amount of seconds a user has to wait before sending another message (0-21600) + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous create operation. The task result contains a + /// + Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, + IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null); + + /// + /// Gets a collection of active threads within this channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the threads. The task result contains + /// a collection of active threads. + /// + Task> GetActiveThreadsAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IThreadChannel.cs b/src/Discord.Net.Core/Entities/Channels/IThreadChannel.cs new file mode 100644 index 0000000..be49672 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IThreadChannel.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a thread channel inside of a guild. + /// + public interface IThreadChannel : ITextChannel + { + /// + /// Gets the type of the current thread channel. + /// + ThreadType Type { get; } + + /// + /// Gets whether or not the current user has joined this thread. + /// + bool HasJoined { get; } + + /// + /// Gets whether or not the current thread is archived. + /// + bool IsArchived { get; } + + /// + /// Gets the duration of time before the thread is automatically archived after no activity. + /// + ThreadArchiveDuration AutoArchiveDuration { get; } + + /// + /// Gets the timestamp when the thread's archive status was last changed, used for calculating recent activity. + /// + DateTimeOffset ArchiveTimestamp { get; } + + /// + /// Gets whether or not the current thread is locked. + /// + bool IsLocked { get; } + + /// + /// Gets an approximate count of users in a thread, stops counting after 50. + /// + int MemberCount { get; } + + /// + /// Gets an approximate count of messages in a thread, stops counting after 50. + /// + int MessageCount { get; } + + /// + /// Gets whether non-moderators can add other non-moderators to a thread. + /// + /// + /// This property is only available on private threads. + /// + bool? IsInvitable { get; } + + /// + /// Gets ids of tags applied to a forum thread + /// + /// + /// This property is only available on forum threads. + /// + IReadOnlyCollection AppliedTags { get; } + + /// + /// Gets when the thread was created. + /// + /// + /// This property is only populated for threads created after 2022-01-09, hence the default date of this + /// property will be that date. + /// + new DateTimeOffset CreatedAt { get; } + + /// + /// Gets the id of the creator of the thread. + /// + ulong OwnerId { get; } + + /// + /// Joins the current thread. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous join operation. + /// + Task JoinAsync(RequestOptions options = null); + + /// + /// Leaves the current thread. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous leave operation. + /// + Task LeaveAsync(RequestOptions options = null); + + /// + /// Adds a user to this thread. + /// + /// The to add. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation of adding a member to a thread. + /// + Task AddUserAsync(IGuildUser user, RequestOptions options = null); + + /// + /// Removes a user from this thread. + /// + /// The to remove from this thread. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation of removing a user from this thread. + /// + Task RemoveUserAsync(IGuildUser user, RequestOptions options = null); + + /// + /// Modifies this thread channel. + /// + /// The delegate containing the properties to modify the channel with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + /// + Task ModifyAsync(Action func, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/IVoiceChannel.cs b/src/Discord.Net.Core/Entities/Channels/IVoiceChannel.cs new file mode 100644 index 0000000..00f03d8 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IVoiceChannel.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic voice channel in a guild. + /// + public interface IVoiceChannel : ITextChannel, IAudioChannel + { + /// + /// Gets the bit-rate that the clients in this voice channel are requested to use. + /// + /// + /// An representing the bit-rate (bps) that this voice channel defines and requests the + /// client(s) to use. + /// + int Bitrate { get; } + /// + /// Gets the max number of users allowed to be connected to this channel at once. + /// + /// + /// An representing the maximum number of users that are allowed to be connected to this + /// channel at once; if a limit is not set. + /// + int? UserLimit { get; } + + /// + /// Gets the video quality mode for this channel. + /// + VideoQualityMode VideoQualityMode { get; } + + /// + /// Modifies this voice channel. + /// + /// The properties to modify the channel with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Sets the voice channel status in the current channel. + /// + /// The string to set as status. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task SetStatusAsync(string status, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ReorderChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/ReorderChannelProperties.cs new file mode 100644 index 0000000..ffd90da --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ReorderChannelProperties.cs @@ -0,0 +1,32 @@ +namespace Discord +{ + /// + /// Provides properties that are used to reorder an . + /// + public class ReorderChannelProperties + { + /// + /// Gets the ID of the channel to apply this position to. + /// + /// + /// A representing the snowflake identifier of this channel. + /// + public ulong Id { get; } + /// + /// Gets the new zero-based position of this channel. + /// + /// + /// An representing the new position of this channel. + /// + public int Position { get; } + + /// Initializes a new instance of the class used to reorder a channel. + /// Sets the ID of the channel to apply this position to. + /// Sets the new zero-based position of this channel. + public ReorderChannelProperties(ulong id, int position) + { + Id = id; + Position = position; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/StageInstanceProperties.cs b/src/Discord.Net.Core/Entities/Channels/StageInstanceProperties.cs new file mode 100644 index 0000000..35201fe --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/StageInstanceProperties.cs @@ -0,0 +1,18 @@ +namespace Discord +{ + /// + /// Represents properties to use when modifying a stage instance. + /// + public class StageInstanceProperties + { + /// + /// Gets or sets the topic of the stage. + /// + public Optional Topic { get; set; } + + /// + /// Gets or sets the privacy level of the stage. + /// + public Optional PrivacyLevel { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs b/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs new file mode 100644 index 0000000..0582a3e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs @@ -0,0 +1,17 @@ +namespace Discord +{ + /// + /// Represents the privacy level of a stage. + /// + public enum StagePrivacyLevel + { + /// + /// The Stage instance is visible publicly, such as on Stage Discovery. + /// + Public = 1, + /// + /// The Stage instance is visible to only guild members. + /// + GuildOnly = 2 + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/TextChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/TextChannelProperties.cs new file mode 100644 index 0000000..a0d6846 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/TextChannelProperties.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Provides properties that are used to modify an with the specified changes. + /// + /// + public class TextChannelProperties : GuildChannelProperties + { + /// + /// Gets or sets the topic of the channel. + /// + /// + /// Setting this value to any string other than or will set the + /// channel topic or description to the desired value. + /// + public Optional Topic { get; set; } + /// + /// Gets or sets whether this channel should be flagged as NSFW. + /// + /// + /// Setting this value to will mark the channel as NSFW (Not Safe For Work) and will prompt the + /// user about its possibly mature nature before they may view the channel; setting this value to will + /// remove the NSFW indicator. + /// + public Optional IsNsfw { get; set; } + /// + /// Gets or sets the slow-mode ratelimit in seconds for this channel. + /// + /// + /// Setting this value to anything above zero will require each user to wait X seconds before + /// sending another message; setting this value to 0 will disable slow-mode for this channel. + /// + /// Users with or + /// will be exempt from slow-mode. + /// + /// + /// Thrown if the value does not fall within [0, 21600]. + public Optional SlowModeInterval { get; set; } + + /// + /// Gets or sets the auto archive duration. + /// + public Optional AutoArchiveDuration { get; set; } + + /// + /// Gets or sets the default slow-mode for threads in this channel. + /// + /// + /// Setting this value to anything above zero will require each user to wait X seconds before + /// sending another message; setting this value to 0 will disable slow-mode for child threads. + /// + /// Users with or + /// will be exempt from slow-mode. + /// + /// + /// Thrown if the value does not fall within [0, 21600]. + public Optional DefaultSlowModeInterval { get; set; } + + /// + /// Gets or sets the type of the channel. Only applicable for or channels. + /// + public Optional ChannelType { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ThreadArchiveDuration.cs b/src/Discord.Net.Core/Entities/Channels/ThreadArchiveDuration.cs new file mode 100644 index 0000000..6c1b7a4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ThreadArchiveDuration.cs @@ -0,0 +1,28 @@ +namespace Discord +{ + /// + /// Represents the thread auto archive duration. + /// + public enum ThreadArchiveDuration + { + /// + /// One hour (60 minutes). + /// + OneHour = 60, + + /// + /// One day (1440 minutes). + /// + OneDay = 1440, + + /// + /// Three days (4320 minutes). + /// + ThreeDays = 4320, + + /// + /// One week (10080 minutes). + /// + OneWeek = 10080 + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ThreadChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/ThreadChannelProperties.cs new file mode 100644 index 0000000..af5c441 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ThreadChannelProperties.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Discord; + + +/// +/// Provides properties that are used to modify an with the specified changes. +/// +/// +public class ThreadChannelProperties : TextChannelProperties +{ + /// + /// Gets or sets the tags applied to a forum thread + /// + public Optional> AppliedTags { get; set; } + + /// + /// Gets or sets whether or not the thread is locked. + /// + public Optional Locked { get; set; } + + /// + /// Gets or sets whether or not the thread is archived. + /// + public Optional Archived { get; set; } +} diff --git a/src/Discord.Net.Core/Entities/Channels/ThreadType.cs b/src/Discord.Net.Core/Entities/Channels/ThreadType.cs new file mode 100644 index 0000000..379128d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/ThreadType.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + /// + /// Represents types of threads. + /// + public enum ThreadType + { + /// + /// Represents a temporary sub-channel within a GUILD_NEWS channel. + /// + NewsThread = 10, + + /// + /// Represents a temporary sub-channel within a GUILD_TEXT channel. + /// + PublicThread = 11, + + /// + /// Represents a temporary sub-channel within a GUILD_TEXT channel that is only viewable by those invited and those with the MANAGE_THREADS permission + /// + PrivateThread = 12 + } +} diff --git a/src/Discord.Net.Core/Entities/Channels/VideoQualityMode.cs b/src/Discord.Net.Core/Entities/Channels/VideoQualityMode.cs new file mode 100644 index 0000000..f04523b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/VideoQualityMode.cs @@ -0,0 +1,17 @@ +namespace Discord; + +/// +/// Represents a video quality mode for voice channels. +/// +public enum VideoQualityMode +{ + /// + /// Discord chooses the quality for optimal performance. + /// + Auto = 1, + + /// + /// 720p. + /// + Full = 2 +} diff --git a/src/Discord.Net.Core/Entities/Channels/VoiceChannelProperties.cs b/src/Discord.Net.Core/Entities/Channels/VoiceChannelProperties.cs new file mode 100644 index 0000000..ee0795c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/VoiceChannelProperties.cs @@ -0,0 +1,33 @@ +using System; + +namespace Discord; + +/// +/// Provides properties that are used to modify an with the specified changes. +/// +public class VoiceChannelProperties : TextChannelProperties +{ + /// + /// Gets or sets the bitrate of the voice connections in this channel. Must be greater than 8000. + /// + public Optional Bitrate { get; set; } + /// + /// Gets or sets the maximum number of users that can be present in a channel, or if none. + /// + public Optional UserLimit { get; set; } + /// + /// Gets or sets the channel voice region id, automatic when set to . + /// + public Optional RTCRegion { get; set; } + + /// + /// Get or sets the video quality mode for this channel. + /// + public Optional VideoQualityMode { get; set; } + + /// + /// Not supported in voice channels + /// + /// + public new Optional Topic { get; } +} diff --git a/src/Discord.Net.Core/Entities/Emotes/Emoji.cs b/src/Discord.Net.Core/Entities/Emotes/Emoji.cs new file mode 100644 index 0000000..0bae88c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Emotes/Emoji.cs @@ -0,0 +1,7094 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Linq; + +namespace Discord +{ + /// + /// A Unicode emoji. + /// + public class Emoji : IEmote + { + /// + public string Name { get; } + + /// + /// Gets the Unicode representation of this emoji. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Name; + + /// + /// Initializes a new class with the provided Unicode. + /// + /// The pure UTF-8 encoding of an emoji. + public Emoji(string unicode) + { + Name = unicode; + } + + /// + /// Determines whether the specified emoji is equal to the current one. + /// + /// The object to compare with the current object. + public override bool Equals(object other) + { + if (other == null) + return false; + + if (other == this) + return true; + + return other is Emoji otherEmoji && string.Equals(Name, otherEmoji.Name); + } + + /// Tries to parse an from its raw format. + /// The raw encoding of an emoji. For example: :heart: or ❤ + /// An emoji. + public static bool TryParse(string text, out Emoji result) + { + result = null; + if (string.IsNullOrWhiteSpace(text)) + return false; + + if (NamesAndUnicodes.ContainsKey(text)) + result = new Emoji(NamesAndUnicodes[text]); + + if (Unicodes.Contains(text)) + result = new Emoji(text); + + return result != null; + } + + /// Parse an from its raw format. + /// The raw encoding of an emoji. For example: :heart: or ❤ + /// String is not emoji or unicode! + public static Emoji Parse(string emojiStr) + { + if (!TryParse(emojiStr, out var emoji)) + throw new FormatException("String is not emoji name or unicode!"); + + return emoji; + } + + /// + public override int GetHashCode() => Name.GetHashCode(); + + private static IReadOnlyDictionary NamesAndUnicodes { get; } = new Dictionary + { + [",:("] = "\uD83D\uDE13", + [",:)"] = "\uD83D\uDE05", + [",:-("] = "\uD83D\uDE13", + [",:-)"] = "\uD83D\uDE05", + [",=("] = "\uD83D\uDE13", + [",=)"] = "\uD83D\uDE05", + [",=-("] = "\uD83D\uDE13", + [",=-)"] = "\uD83D\uDE05", + ["0:)"] = "\uD83D\uDE07", + ["0:-)"] = "\uD83D\uDE07", + ["0=)"] = "\uD83D\uDE07", + ["0=-)"] = "\uD83D\uDE07", + ["8-)"] = "\uD83D\uDE0E", + [":$"] = "\uD83D\uDE12", + [":'("] = "\uD83D\uDE22", + [":')"] = "\uD83D\uDE02", + [":'-("] = "\uD83D\uDE22", + [":'-)"] = "\uD83D\uDE02", + [":'-D"] = "\uD83D\uDE02", + [":'D"] = "\uD83D\uDE02", + [":("] = "\uD83D\uDE26", + [":)"] = "\uD83D\uDE42", + [":*"] = "\uD83D\uDE17", + [":+1:"] = "\uD83D\uDC4D", + [":+1::skin-tone-1:"] = "\uD83D\uDC4D\uD83C\uDFFB", + [":+1::skin-tone-2:"] = "\uD83D\uDC4D\uD83C\uDFFC", + [":+1::skin-tone-3:"] = "\uD83D\uDC4D\uD83C\uDFFD", + [":+1::skin-tone-4:"] = "\uD83D\uDC4D\uD83C\uDFFE", + [":+1::skin-tone-5:"] = "\uD83D\uDC4D\uD83C\uDFFF", + [":+1_tone1:"] = "\uD83D\uDC4D\uD83C\uDFFB", + [":+1_tone2:"] = "\uD83D\uDC4D\uD83C\uDFFC", + [":+1_tone3:"] = "\uD83D\uDC4D\uD83C\uDFFD", + [":+1_tone4:"] = "\uD83D\uDC4D\uD83C\uDFFE", + [":+1_tone5:"] = "\uD83D\uDC4D\uD83C\uDFFF", + [":,'("] = "\uD83D\uDE2D", + [":,'-("] = "\uD83D\uDE2D", + [":,("] = "\uD83D\uDE22", + [":,)"] = "\uD83D\uDE02", + [":,-("] = "\uD83D\uDE22", + [":,-)"] = "\uD83D\uDE02", + [":,-D"] = "\uD83D\uDE02", + [":,D"] = "\uD83D\uDE02", + [":-$"] = "\uD83D\uDE12", + [":-("] = "\uD83D\uDE26", + [":-)"] = "\uD83D\uDE42", + [":-*"] = "\uD83D\uDE17", + [":-/"] = "\uD83D\uDE15", + [":-1:"] = "\uD83D\uDC4E", + [":-1::skin-tone-1:"] = "\uD83D\uDC4E\uD83C\uDFFB", + [":-1::skin-tone-2:"] = "\uD83D\uDC4E\uD83C\uDFFC", + [":-1::skin-tone-3:"] = "\uD83D\uDC4E\uD83C\uDFFD", + [":-1::skin-tone-4:"] = "\uD83D\uDC4E\uD83C\uDFFE", + [":-1::skin-tone-5:"] = "\uD83D\uDC4E\uD83C\uDFFF", + [":-@"] = "\uD83D\uDE21", + [":-D"] = "\uD83D\uDE04", + [":-O"] = "\uD83D\uDE2E", + [":-P"] = "\uD83D\uDE1B", + [":-S"] = "\uD83D\uDE12", + [":-Z"] = "\uD83D\uDE12", + [":-\")"] = "\uD83D\uDE0A", + [":-\\"] = "\uD83D\uDE15", + [":-o"] = "\uD83D\uDE2E", + [":-|"] = "\uD83D\uDE10", + [":100:"] = "\uD83D\uDCAF", + [":1234:"] = "\uD83D\uDD22", + [":8ball:"] = "\uD83C\uDFB1", + [":@"] = "\uD83D\uDE21", + [":D"] = "\uD83D\uDE04", + [":O"] = "\uD83D\uDE2E", + [":P"] = "\uD83D\uDE1B", + [":\")"] = "\uD83D\uDE0A", + [":_1_tone1:"] = "\uD83D\uDC4E\uD83C\uDFFB", + [":_1_tone2:"] = "\uD83D\uDC4E\uD83C\uDFFC", + [":_1_tone3:"] = "\uD83D\uDC4E\uD83C\uDFFD", + [":_1_tone4:"] = "\uD83D\uDC4E\uD83C\uDFFE", + [":_1_tone5:"] = "\uD83D\uDC4E\uD83C\uDFFF", + [":a:"] = "\uD83C\uDD70️", + [":ab:"] = "\uD83C\uDD8E", + [":abacus:"] = "\uD83E\uDDEE", + [":abc:"] = "\uD83D\uDD24", + [":abcd:"] = "\uD83D\uDD21", + [":accept:"] = "\uD83C\uDE51", + [":accordion:"] = "\uD83E\uDE97", + [":adhesive_bandage:"] = "\uD83E\uDE79", + [":admission_tickets:"] = "\uD83C\uDF9F️", + [":adult:"] = "\uD83E\uDDD1", + [":adult::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFB", + [":adult::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC", + [":adult::skin-tone-3:"] = "\uD83E\uDDD1\uD83C\uDFFD", + [":adult::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE", + [":adult::skin-tone-5:"] = "\uD83E\uDDD1\uD83C\uDFFF", + [":adult_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF", + [":adult_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB", + [":adult_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE", + [":adult_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC", + [":adult_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD", + [":adult_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFB", + [":adult_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC", + [":adult_tone3:"] = "\uD83E\uDDD1\uD83C\uDFFD", + [":adult_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE", + [":adult_tone5:"] = "\uD83E\uDDD1\uD83C\uDFFF", + [":aerial_tramway:"] = "\uD83D\uDEA1", + [":airplane:"] = "✈️", + [":airplane_arriving:"] = "\uD83D\uDEEC", + [":airplane_departure:"] = "\uD83D\uDEEB", + [":airplane_small:"] = "\uD83D\uDEE9️", + [":alarm_clock:"] = "⏰", + [":alembic:"] = "⚗️", + [":alien:"] = "\uD83D\uDC7D", + [":ambulance:"] = "\uD83D\uDE91", + [":amphora:"] = "\uD83C\uDFFA", + [":anatomical_heart:"] = "\uD83E\uDEC0", + [":anchor:"] = "⚓", + [":angel:"] = "\uD83D\uDC7C", + [":angel::skin-tone-1:"] = "\uD83D\uDC7C\uD83C\uDFFB", + [":angel::skin-tone-2:"] = "\uD83D\uDC7C\uD83C\uDFFC", + [":angel::skin-tone-3:"] = "\uD83D\uDC7C\uD83C\uDFFD", + [":angel::skin-tone-4:"] = "\uD83D\uDC7C\uD83C\uDFFE", + [":angel::skin-tone-5:"] = "\uD83D\uDC7C\uD83C\uDFFF", + [":angel_tone1:"] = "\uD83D\uDC7C\uD83C\uDFFB", + [":angel_tone2:"] = "\uD83D\uDC7C\uD83C\uDFFC", + [":angel_tone3:"] = "\uD83D\uDC7C\uD83C\uDFFD", + [":angel_tone4:"] = "\uD83D\uDC7C\uD83C\uDFFE", + [":angel_tone5:"] = "\uD83D\uDC7C\uD83C\uDFFF", + [":anger:"] = "\uD83D\uDCA2", + [":anger_right:"] = "\uD83D\uDDEF️", + [":angry:"] = "\uD83D\uDE20", + [":anguished:"] = "\uD83D\uDE27", + [":ant:"] = "\uD83D\uDC1C", + [":apple:"] = "\uD83C\uDF4E", + [":aquarius:"] = "♒", + [":archery:"] = "\uD83C\uDFF9", + [":aries:"] = "♈", + [":arrow_backward:"] = "◀️", + [":arrow_double_down:"] = "⏬", + [":arrow_double_up:"] = "⏫", + [":arrow_down:"] = "⬇️", + [":arrow_down_small:"] = "\uD83D\uDD3D", + [":arrow_forward:"] = "▶️", + [":arrow_heading_down:"] = "⤵️", + [":arrow_heading_up:"] = "⤴️", + [":arrow_left:"] = "⬅️", + [":arrow_lower_left:"] = "↙️", + [":arrow_lower_right:"] = "↘️", + [":arrow_right:"] = "➡️", + [":arrow_right_hook:"] = "↪️", + [":arrow_up:"] = "⬆️", + [":arrow_up_down:"] = "↕️", + [":arrow_up_small:"] = "\uD83D\uDD3C", + [":arrow_upper_left:"] = "↖️", + [":arrow_upper_right:"] = "↗️", + [":arrows_clockwise:"] = "\uD83D\uDD03", + [":arrows_counterclockwise:"] = "\uD83D\uDD04", + [":art:"] = "\uD83C\uDFA8", + [":articulated_lorry:"] = "\uD83D\uDE9B", + [":asterisk:"] = "*️⃣", + [":astonished:"] = "\uD83D\uDE32", + [":athletic_shoe:"] = "\uD83D\uDC5F", + [":atm:"] = "\uD83C\uDFE7", + [":atom:"] = "⚛️", + [":atom_symbol:"] = "⚛️", + [":auto_rickshaw:"] = "\uD83D\uDEFA", + [":avocado:"] = "\uD83E\uDD51", + [":axe:"] = "\uD83E\uDE93", + [":b:"] = "\uD83C\uDD71️", + [":baby:"] = "\uD83D\uDC76", + [":baby::skin-tone-1:"] = "\uD83D\uDC76\uD83C\uDFFB", + [":baby::skin-tone-2:"] = "\uD83D\uDC76\uD83C\uDFFC", + [":baby::skin-tone-3:"] = "\uD83D\uDC76\uD83C\uDFFD", + [":baby::skin-tone-4:"] = "\uD83D\uDC76\uD83C\uDFFE", + [":baby::skin-tone-5:"] = "\uD83D\uDC76\uD83C\uDFFF", + [":baby_bottle:"] = "\uD83C\uDF7C", + [":baby_chick:"] = "\uD83D\uDC24", + [":baby_symbol:"] = "\uD83D\uDEBC", + [":baby_tone1:"] = "\uD83D\uDC76\uD83C\uDFFB", + [":baby_tone2:"] = "\uD83D\uDC76\uD83C\uDFFC", + [":baby_tone3:"] = "\uD83D\uDC76\uD83C\uDFFD", + [":baby_tone4:"] = "\uD83D\uDC76\uD83C\uDFFE", + [":baby_tone5:"] = "\uD83D\uDC76\uD83C\uDFFF", + [":back:"] = "\uD83D\uDD19", + [":back_of_hand:"] = "\uD83E\uDD1A", + [":back_of_hand::skin-tone-1:"] = "\uD83E\uDD1A\uD83C\uDFFB", + [":back_of_hand::skin-tone-2:"] = "\uD83E\uDD1A\uD83C\uDFFC", + [":back_of_hand::skin-tone-3:"] = "\uD83E\uDD1A\uD83C\uDFFD", + [":back_of_hand::skin-tone-4:"] = "\uD83E\uDD1A\uD83C\uDFFE", + [":back_of_hand::skin-tone-5:"] = "\uD83E\uDD1A\uD83C\uDFFF", + [":back_of_hand_tone1:"] = "\uD83E\uDD1A\uD83C\uDFFB", + [":back_of_hand_tone2:"] = "\uD83E\uDD1A\uD83C\uDFFC", + [":back_of_hand_tone3:"] = "\uD83E\uDD1A\uD83C\uDFFD", + [":back_of_hand_tone4:"] = "\uD83E\uDD1A\uD83C\uDFFE", + [":back_of_hand_tone5:"] = "\uD83E\uDD1A\uD83C\uDFFF", + [":bacon:"] = "\uD83E\uDD53", + [":badger:"] = "\uD83E\uDDA1", + [":badminton:"] = "\uD83C\uDFF8", + [":bagel:"] = "\uD83E\uDD6F", + [":baggage_claim:"] = "\uD83D\uDEC4", + [":baguette_bread:"] = "\uD83E\uDD56", + [":ballet_shoes:"] = "\uD83E\uDE70", + [":balloon:"] = "\uD83C\uDF88", + [":ballot_box:"] = "\uD83D\uDDF3️", + [":ballot_box_with_ballot:"] = "\uD83D\uDDF3️", + [":ballot_box_with_check:"] = "☑️", + [":bamboo:"] = "\uD83C\uDF8D", + [":banana:"] = "\uD83C\uDF4C", + [":bangbang:"] = "‼️", + [":banjo:"] = "\uD83E\uDE95", + [":bank:"] = "\uD83C\uDFE6", + [":bar_chart:"] = "\uD83D\uDCCA", + [":barber:"] = "\uD83D\uDC88", + [":baseball:"] = "⚾", + [":basket:"] = "\uD83E\uDDFA", + [":basketball:"] = "\uD83C\uDFC0", + [":basketball_player:"] = "⛹️", + [":basketball_player::skin-tone-1:"] = "⛹\uD83C\uDFFB", + [":basketball_player::skin-tone-2:"] = "⛹\uD83C\uDFFC", + [":basketball_player::skin-tone-3:"] = "⛹\uD83C\uDFFD", + [":basketball_player::skin-tone-4:"] = "⛹\uD83C\uDFFE", + [":basketball_player::skin-tone-5:"] = "⛹\uD83C\uDFFF", + [":basketball_player_tone1:"] = "⛹\uD83C\uDFFB", + [":basketball_player_tone2:"] = "⛹\uD83C\uDFFC", + [":basketball_player_tone3:"] = "⛹\uD83C\uDFFD", + [":basketball_player_tone4:"] = "⛹\uD83C\uDFFE", + [":basketball_player_tone5:"] = "⛹\uD83C\uDFFF", + [":bat:"] = "\uD83E\uDD87", + [":bath:"] = "\uD83D\uDEC0", + [":bath::skin-tone-1:"] = "\uD83D\uDEC0\uD83C\uDFFB", + [":bath::skin-tone-2:"] = "\uD83D\uDEC0\uD83C\uDFFC", + [":bath::skin-tone-3:"] = "\uD83D\uDEC0\uD83C\uDFFD", + [":bath::skin-tone-4:"] = "\uD83D\uDEC0\uD83C\uDFFE", + [":bath::skin-tone-5:"] = "\uD83D\uDEC0\uD83C\uDFFF", + [":bath_tone1:"] = "\uD83D\uDEC0\uD83C\uDFFB", + [":bath_tone2:"] = "\uD83D\uDEC0\uD83C\uDFFC", + [":bath_tone3:"] = "\uD83D\uDEC0\uD83C\uDFFD", + [":bath_tone4:"] = "\uD83D\uDEC0\uD83C\uDFFE", + [":bath_tone5:"] = "\uD83D\uDEC0\uD83C\uDFFF", + [":bathtub:"] = "\uD83D\uDEC1", + [":battery:"] = "\uD83D\uDD0B", + [":beach:"] = "\uD83C\uDFD6️", + [":beach_umbrella:"] = "⛱️", + [":beach_with_umbrella:"] = "\uD83C\uDFD6️", + [":beans:"] = "\uD83E\uDED8", + [":bear:"] = "\uD83D\uDC3B", + [":bearded_person:"] = "\uD83E\uDDD4", + [":bearded_person::skin-tone-1:"] = "\uD83E\uDDD4\uD83C\uDFFB", + [":bearded_person::skin-tone-2:"] = "\uD83E\uDDD4\uD83C\uDFFC", + [":bearded_person::skin-tone-3:"] = "\uD83E\uDDD4\uD83C\uDFFD", + [":bearded_person::skin-tone-4:"] = "\uD83E\uDDD4\uD83C\uDFFE", + [":bearded_person::skin-tone-5:"] = "\uD83E\uDDD4\uD83C\uDFFF", + [":bearded_person_dark_skin_tone:"] = "\uD83E\uDDD4\uD83C\uDFFF", + [":bearded_person_light_skin_tone:"] = "\uD83E\uDDD4\uD83C\uDFFB", + [":bearded_person_medium_dark_skin_tone:"] = "\uD83E\uDDD4\uD83C\uDFFE", + [":bearded_person_medium_light_skin_tone:"] = "\uD83E\uDDD4\uD83C\uDFFC", + [":bearded_person_medium_skin_tone:"] = "\uD83E\uDDD4\uD83C\uDFFD", + [":bearded_person_tone1:"] = "\uD83E\uDDD4\uD83C\uDFFB", + [":bearded_person_tone2:"] = "\uD83E\uDDD4\uD83C\uDFFC", + [":bearded_person_tone3:"] = "\uD83E\uDDD4\uD83C\uDFFD", + [":bearded_person_tone4:"] = "\uD83E\uDDD4\uD83C\uDFFE", + [":bearded_person_tone5:"] = "\uD83E\uDDD4\uD83C\uDFFF", + [":beaver:"] = "\uD83E\uDDAB", + [":bed:"] = "\uD83D\uDECF️", + [":bee:"] = "\uD83D\uDC1D", + [":beer:"] = "\uD83C\uDF7A", + [":beers:"] = "\uD83C\uDF7B", + [":beetle:"] = "\uD83D\uDC1E", + [":beetle:"] = "\uD83E\uDEB2", + [":beginner:"] = "\uD83D\uDD30", + [":bell:"] = "\uD83D\uDD14", + [":bell_pepper:"] = "\uD83E\uDED1", + [":bellhop:"] = "\uD83D\uDECE️", + [":bellhop_bell:"] = "\uD83D\uDECE️", + [":bento:"] = "\uD83C\uDF71", + [":beverage_box:"] = "\uD83E\uDDC3", + [":bicyclist:"] = "\uD83D\uDEB4", + [":bicyclist::skin-tone-1:"] = "\uD83D\uDEB4\uD83C\uDFFB", + [":bicyclist::skin-tone-2:"] = "\uD83D\uDEB4\uD83C\uDFFC", + [":bicyclist::skin-tone-3:"] = "\uD83D\uDEB4\uD83C\uDFFD", + [":bicyclist::skin-tone-4:"] = "\uD83D\uDEB4\uD83C\uDFFE", + [":bicyclist::skin-tone-5:"] = "\uD83D\uDEB4\uD83C\uDFFF", + [":bicyclist_tone1:"] = "\uD83D\uDEB4\uD83C\uDFFB", + [":bicyclist_tone2:"] = "\uD83D\uDEB4\uD83C\uDFFC", + [":bicyclist_tone3:"] = "\uD83D\uDEB4\uD83C\uDFFD", + [":bicyclist_tone4:"] = "\uD83D\uDEB4\uD83C\uDFFE", + [":bicyclist_tone5:"] = "\uD83D\uDEB4\uD83C\uDFFF", + [":bike:"] = "\uD83D\uDEB2", + [":bikini:"] = "\uD83D\uDC59", + [":billed_cap:"] = "\uD83E\uDDE2", + [":biohazard:"] = "☣️", + [":biohazard_sign:"] = "☣️", + [":bird:"] = "\uD83D\uDC26", + [":birthday:"] = "\uD83C\uDF82", + [":bison:"] = "\uD83E\uDDAC", + [":biting_lip:"] = "\uD83E\uDEE6", + [":black_cat:"] = "\uD83D\uDC08\u200D\u2B1B", + [":black_circle:"] = "⚫", + [":black_heart:"] = "\uD83D\uDDA4", + [":black_joker:"] = "\uD83C\uDCCF", + [":black_large_square:"] = "⬛", + [":black_medium_small_square:"] = "◾", + [":black_medium_square:"] = "◼️", + [":black_nib:"] = "✒️", + [":black_small_square:"] = "▪️", + [":black_square_button:"] = "\uD83D\uDD32", + [":blond_haired_man:"] = "\uD83D\uDC71\u200D♂️", + [":blond_haired_man::skin-tone-1:"] = "\uD83D\uDC71\uD83C\uDFFB\u200D♂️", + [":blond_haired_man::skin-tone-2:"] = "\uD83D\uDC71\uD83C\uDFFC\u200D♂️", + [":blond_haired_man::skin-tone-3:"] = "\uD83D\uDC71\uD83C\uDFFD\u200D♂️", + [":blond_haired_man::skin-tone-4:"] = "\uD83D\uDC71\uD83C\uDFFE\u200D♂️", + [":blond_haired_man::skin-tone-5:"] = "\uD83D\uDC71\uD83C\uDFFF\u200D♂️", + [":blond_haired_man_dark_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFF\u200D♂️", + [":blond_haired_man_light_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFB\u200D♂️", + [":blond_haired_man_medium_dark_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFE\u200D♂️", + [":blond_haired_man_medium_light_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFC\u200D♂️", + [":blond_haired_man_medium_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFD\u200D♂️", + [":blond_haired_man_tone1:"] = "\uD83D\uDC71\uD83C\uDFFB\u200D♂️", + [":blond_haired_man_tone2:"] = "\uD83D\uDC71\uD83C\uDFFC\u200D♂️", + [":blond_haired_man_tone3:"] = "\uD83D\uDC71\uD83C\uDFFD\u200D♂️", + [":blond_haired_man_tone4:"] = "\uD83D\uDC71\uD83C\uDFFE\u200D♂️", + [":blond_haired_man_tone5:"] = "\uD83D\uDC71\uD83C\uDFFF\u200D♂️", + [":blond_haired_person:"] = "\uD83D\uDC71", + [":blond_haired_person::skin-tone-1:"] = "\uD83D\uDC71\uD83C\uDFFB", + [":blond_haired_person::skin-tone-2:"] = "\uD83D\uDC71\uD83C\uDFFC", + [":blond_haired_person::skin-tone-3:"] = "\uD83D\uDC71\uD83C\uDFFD", + [":blond_haired_person::skin-tone-4:"] = "\uD83D\uDC71\uD83C\uDFFE", + [":blond_haired_person::skin-tone-5:"] = "\uD83D\uDC71\uD83C\uDFFF", + [":blond_haired_person_tone1:"] = "\uD83D\uDC71\uD83C\uDFFB", + [":blond_haired_person_tone2:"] = "\uD83D\uDC71\uD83C\uDFFC", + [":blond_haired_person_tone3:"] = "\uD83D\uDC71\uD83C\uDFFD", + [":blond_haired_person_tone4:"] = "\uD83D\uDC71\uD83C\uDFFE", + [":blond_haired_person_tone5:"] = "\uD83D\uDC71\uD83C\uDFFF", + [":blond_haired_woman:"] = "\uD83D\uDC71\u200D♀️", + [":blond_haired_woman::skin-tone-1:"] = "\uD83D\uDC71\uD83C\uDFFB\u200D♀️", + [":blond_haired_woman::skin-tone-2:"] = "\uD83D\uDC71\uD83C\uDFFC\u200D♀️", + [":blond_haired_woman::skin-tone-3:"] = "\uD83D\uDC71\uD83C\uDFFD\u200D♀️", + [":blond_haired_woman::skin-tone-4:"] = "\uD83D\uDC71\uD83C\uDFFE\u200D♀️", + [":blond_haired_woman::skin-tone-5:"] = "\uD83D\uDC71\uD83C\uDFFF\u200D♀️", + [":blond_haired_woman_dark_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFF\u200D♀️", + [":blond_haired_woman_light_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFB\u200D♀️", + [":blond_haired_woman_medium_dark_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFE\u200D♀️", + [":blond_haired_woman_medium_light_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFC\u200D♀️", + [":blond_haired_woman_medium_skin_tone:"] = "\uD83D\uDC71\uD83C\uDFFD\u200D♀️", + [":blond_haired_woman_tone1:"] = "\uD83D\uDC71\uD83C\uDFFB\u200D♀️", + [":blond_haired_woman_tone2:"] = "\uD83D\uDC71\uD83C\uDFFC\u200D♀️", + [":blond_haired_woman_tone3:"] = "\uD83D\uDC71\uD83C\uDFFD\u200D♀️", + [":blond_haired_woman_tone4:"] = "\uD83D\uDC71\uD83C\uDFFE\u200D♀️", + [":blond_haired_woman_tone5:"] = "\uD83D\uDC71\uD83C\uDFFF\u200D♀️", + [":blossom:"] = "\uD83C\uDF3C", + [":blowfish:"] = "\uD83D\uDC21", + [":blue_book:"] = "\uD83D\uDCD8", + [":blue_car:"] = "\uD83D\uDE99", + [":blue_circle:"] = "\uD83D\uDD35", + [":blue_heart:"] = "\uD83D\uDC99", + [":blue_square:"] = "\uD83D\uDFE6", + [":blueberries:"] = "\uD83E\uDED0", + [":blush:"] = "\uD83D\uDE0A", + [":boar:"] = "\uD83D\uDC17", + [":bomb:"] = "\uD83D\uDCA3", + [":bone:"] = "\uD83E\uDDB4", + [":book:"] = "\uD83D\uDCD6", + [":bookmark:"] = "\uD83D\uDD16", + [":bookmark_tabs:"] = "\uD83D\uDCD1", + [":books:"] = "\uD83D\uDCDA", + [":boom:"] = "\uD83D\uDCA5", + [":boomerang:"] = "\uD83E\uDE83", + [":boot:"] = "\uD83D\uDC62", + [":bottle_with_popping_cork:"] = "\uD83C\uDF7E", + [":bouquet:"] = "\uD83D\uDC90", + [":bow:"] = "\uD83D\uDE47", + [":bow::skin-tone-1:"] = "\uD83D\uDE47\uD83C\uDFFB", + [":bow::skin-tone-2:"] = "\uD83D\uDE47\uD83C\uDFFC", + [":bow::skin-tone-3:"] = "\uD83D\uDE47\uD83C\uDFFD", + [":bow::skin-tone-4:"] = "\uD83D\uDE47\uD83C\uDFFE", + [":bow::skin-tone-5:"] = "\uD83D\uDE47\uD83C\uDFFF", + [":bow_and_arrow:"] = "\uD83C\uDFF9", + [":bow_tone1:"] = "\uD83D\uDE47\uD83C\uDFFB", + [":bow_tone2:"] = "\uD83D\uDE47\uD83C\uDFFC", + [":bow_tone3:"] = "\uD83D\uDE47\uD83C\uDFFD", + [":bow_tone4:"] = "\uD83D\uDE47\uD83C\uDFFE", + [":bow_tone5:"] = "\uD83D\uDE47\uD83C\uDFFF", + [":bowl_with_spoon:"] = "\uD83E\uDD63", + [":bowling:"] = "\uD83C\uDFB3", + [":boxing_glove:"] = "\uD83E\uDD4A", + [":boxing_gloves:"] = "\uD83E\uDD4A", + [":boy:"] = "\uD83D\uDC66", + [":boy::skin-tone-1:"] = "\uD83D\uDC66\uD83C\uDFFB", + [":boy::skin-tone-2:"] = "\uD83D\uDC66\uD83C\uDFFC", + [":boy::skin-tone-3:"] = "\uD83D\uDC66\uD83C\uDFFD", + [":boy::skin-tone-4:"] = "\uD83D\uDC66\uD83C\uDFFE", + [":boy::skin-tone-5:"] = "\uD83D\uDC66\uD83C\uDFFF", + [":boy_tone1:"] = "\uD83D\uDC66\uD83C\uDFFB", + [":boy_tone2:"] = "\uD83D\uDC66\uD83C\uDFFC", + [":boy_tone3:"] = "\uD83D\uDC66\uD83C\uDFFD", + [":boy_tone4:"] = "\uD83D\uDC66\uD83C\uDFFE", + [":boy_tone5:"] = "\uD83D\uDC66\uD83C\uDFFF", + [":brain:"] = "\uD83E\uDDE0", + [":bread:"] = "\uD83C\uDF5E", + [":breast_feeding:"] = "\uD83E\uDD31", + [":breast_feeding::skin-tone-1:"] = "\uD83E\uDD31\uD83C\uDFFB", + [":breast_feeding::skin-tone-2:"] = "\uD83E\uDD31\uD83C\uDFFC", + [":breast_feeding::skin-tone-3:"] = "\uD83E\uDD31\uD83C\uDFFD", + [":breast_feeding::skin-tone-4:"] = "\uD83E\uDD31\uD83C\uDFFE", + [":breast_feeding::skin-tone-5:"] = "\uD83E\uDD31\uD83C\uDFFF", + [":breast_feeding_dark_skin_tone:"] = "\uD83E\uDD31\uD83C\uDFFF", + [":breast_feeding_light_skin_tone:"] = "\uD83E\uDD31\uD83C\uDFFB", + [":breast_feeding_medium_dark_skin_tone:"] = "\uD83E\uDD31\uD83C\uDFFE", + [":breast_feeding_medium_light_skin_tone:"] = "\uD83E\uDD31\uD83C\uDFFC", + [":breast_feeding_medium_skin_tone:"] = "\uD83E\uDD31\uD83C\uDFFD", + [":breast_feeding_tone1:"] = "\uD83E\uDD31\uD83C\uDFFB", + [":breast_feeding_tone2:"] = "\uD83E\uDD31\uD83C\uDFFC", + [":breast_feeding_tone3:"] = "\uD83E\uDD31\uD83C\uDFFD", + [":breast_feeding_tone4:"] = "\uD83E\uDD31\uD83C\uDFFE", + [":breast_feeding_tone5:"] = "\uD83E\uDD31\uD83C\uDFFF", + [":bricks:"] = "\uD83E\uDDF1", + [":bride_with_veil:"] = "\uD83D\uDC70", + [":bride_with_veil::skin-tone-1:"] = "\uD83D\uDC70\uD83C\uDFFB", + [":bride_with_veil::skin-tone-2:"] = "\uD83D\uDC70\uD83C\uDFFC", + [":bride_with_veil::skin-tone-3:"] = "\uD83D\uDC70\uD83C\uDFFD", + [":bride_with_veil::skin-tone-4:"] = "\uD83D\uDC70\uD83C\uDFFE", + [":bride_with_veil::skin-tone-5:"] = "\uD83D\uDC70\uD83C\uDFFF", + [":bride_with_veil_tone1:"] = "\uD83D\uDC70\uD83C\uDFFB", + [":bride_with_veil_tone2:"] = "\uD83D\uDC70\uD83C\uDFFC", + [":bride_with_veil_tone3:"] = "\uD83D\uDC70\uD83C\uDFFD", + [":bride_with_veil_tone4:"] = "\uD83D\uDC70\uD83C\uDFFE", + [":bride_with_veil_tone5:"] = "\uD83D\uDC70\uD83C\uDFFF", + [":bridge_at_night:"] = "\uD83C\uDF09", + [":briefcase:"] = "\uD83D\uDCBC", + [":briefs:"] = "\uD83E\uDE72", + [":broccoli:"] = "\uD83E\uDD66", + [":broken_heart:"] = "\uD83D\uDC94", + [":broom:"] = "\uD83E\uDDF9", + [":brown_circle:"] = "\uD83D\uDFE4", + [":brown_heart:"] = "\uD83E\uDD0E", + [":brown_square:"] = "\uD83D\uDFEB", + [":bubble_tea:"] = "\uD83E\uDDCB", + [":bubbles:"] = "\uD83E\uDEE7", + [":bucket:"] = "\uD83E\uDEA3", + [":bug:"] = "\uD83D\uDC1B", + [":building_construction:"] = "\uD83C\uDFD7️", + [":bulb:"] = "\uD83D\uDCA1", + [":bullettrain_front:"] = "\uD83D\uDE85", + [":bullettrain_side:"] = "\uD83D\uDE84", + [":burrito:"] = "\uD83C\uDF2F", + [":bus:"] = "\uD83D\uDE8C", + [":busstop:"] = "\uD83D\uDE8F", + [":bust_in_silhouette:"] = "\uD83D\uDC64", + [":busts_in_silhouette:"] = "\uD83D\uDC65", + [":butter:"] = "\uD83E\uDDC8", + [":butterfly:"] = "\uD83E\uDD8B", + [":cactus:"] = "\uD83C\uDF35", + [":cake:"] = "\uD83C\uDF70", + [":calendar:"] = "\uD83D\uDCC6", + [":calendar_spiral:"] = "\uD83D\uDDD3️", + [":call_me:"] = "\uD83E\uDD19", + [":call_me::skin-tone-1:"] = "\uD83E\uDD19\uD83C\uDFFB", + [":call_me::skin-tone-2:"] = "\uD83E\uDD19\uD83C\uDFFC", + [":call_me::skin-tone-3:"] = "\uD83E\uDD19\uD83C\uDFFD", + [":call_me::skin-tone-4:"] = "\uD83E\uDD19\uD83C\uDFFE", + [":call_me::skin-tone-5:"] = "\uD83E\uDD19\uD83C\uDFFF", + [":call_me_hand:"] = "\uD83E\uDD19", + [":call_me_hand::skin-tone-1:"] = "\uD83E\uDD19\uD83C\uDFFB", + [":call_me_hand::skin-tone-2:"] = "\uD83E\uDD19\uD83C\uDFFC", + [":call_me_hand::skin-tone-3:"] = "\uD83E\uDD19\uD83C\uDFFD", + [":call_me_hand::skin-tone-4:"] = "\uD83E\uDD19\uD83C\uDFFE", + [":call_me_hand::skin-tone-5:"] = "\uD83E\uDD19\uD83C\uDFFF", + [":call_me_hand_tone1:"] = "\uD83E\uDD19\uD83C\uDFFB", + [":call_me_hand_tone2:"] = "\uD83E\uDD19\uD83C\uDFFC", + [":call_me_hand_tone3:"] = "\uD83E\uDD19\uD83C\uDFFD", + [":call_me_hand_tone4:"] = "\uD83E\uDD19\uD83C\uDFFE", + [":call_me_hand_tone5:"] = "\uD83E\uDD19\uD83C\uDFFF", + [":call_me_tone1:"] = "\uD83E\uDD19\uD83C\uDFFB", + [":call_me_tone2:"] = "\uD83E\uDD19\uD83C\uDFFC", + [":call_me_tone3:"] = "\uD83E\uDD19\uD83C\uDFFD", + [":call_me_tone4:"] = "\uD83E\uDD19\uD83C\uDFFE", + [":call_me_tone5:"] = "\uD83E\uDD19\uD83C\uDFFF", + [":calling:"] = "\uD83D\uDCF2", + [":camel:"] = "\uD83D\uDC2B", + [":camera:"] = "\uD83D\uDCF7", + [":camera_with_flash:"] = "\uD83D\uDCF8", + [":camping:"] = "\uD83C\uDFD5️", + [":cancer:"] = "♋", + [":candle:"] = "\uD83D\uDD6F️", + [":candy:"] = "\uD83C\uDF6C", + [":canned_food:"] = "\uD83E\uDD6B", + [":canoe:"] = "\uD83D\uDEF6", + [":capital_abcd:"] = "\uD83D\uDD20", + [":capricorn:"] = "♑", + [":card_box:"] = "\uD83D\uDDC3️", + [":card_file_box:"] = "\uD83D\uDDC3️", + [":card_index:"] = "\uD83D\uDCC7", + [":card_index_dividers:"] = "\uD83D\uDDC2️", + [":carousel_horse:"] = "\uD83C\uDFA0", + [":carpentry_saw:"] = "\uD83E\uDE9A", + [":carrot:"] = "\uD83E\uDD55", + [":cartwheel:"] = "\uD83E\uDD38", + [":cartwheel::skin-tone-1:"] = "\uD83E\uDD38\uD83C\uDFFB", + [":cartwheel::skin-tone-2:"] = "\uD83E\uDD38\uD83C\uDFFC", + [":cartwheel::skin-tone-3:"] = "\uD83E\uDD38\uD83C\uDFFD", + [":cartwheel::skin-tone-4:"] = "\uD83E\uDD38\uD83C\uDFFE", + [":cartwheel::skin-tone-5:"] = "\uD83E\uDD38\uD83C\uDFFF", + [":cartwheel_tone1:"] = "\uD83E\uDD38\uD83C\uDFFB", + [":cartwheel_tone2:"] = "\uD83E\uDD38\uD83C\uDFFC", + [":cartwheel_tone3:"] = "\uD83E\uDD38\uD83C\uDFFD", + [":cartwheel_tone4:"] = "\uD83E\uDD38\uD83C\uDFFE", + [":cartwheel_tone5:"] = "\uD83E\uDD38\uD83C\uDFFF", + [":cat2:"] = "\uD83D\uDC08", + [":cat:"] = "\uD83D\uDC31", + [":cd:"] = "\uD83D\uDCBF", + [":chains:"] = "⛓️", + [":chair:"] = "\uD83E\uDE91", + [":champagne:"] = "\uD83C\uDF7E", + [":champagne_glass:"] = "\uD83E\uDD42", + [":chart:"] = "\uD83D\uDCB9", + [":chart_with_downwards_trend:"] = "\uD83D\uDCC9", + [":chart_with_upwards_trend:"] = "\uD83D\uDCC8", + [":checkered_flag:"] = "\uD83C\uDFC1", + [":cheese:"] = "\uD83E\uDDC0", + [":cheese_wedge:"] = "\uD83E\uDDC0", + [":cherries:"] = "\uD83C\uDF52", + [":cherry_blossom:"] = "\uD83C\uDF38", + [":chess_pawn:"] = "♟️", + [":chestnut:"] = "\uD83C\uDF30", + [":chicken:"] = "\uD83D\uDC14", + [":child:"] = "\uD83E\uDDD2", + [":child::skin-tone-1:"] = "\uD83E\uDDD2\uD83C\uDFFB", + [":child::skin-tone-2:"] = "\uD83E\uDDD2\uD83C\uDFFC", + [":child::skin-tone-3:"] = "\uD83E\uDDD2\uD83C\uDFFD", + [":child::skin-tone-4:"] = "\uD83E\uDDD2\uD83C\uDFFE", + [":child::skin-tone-5:"] = "\uD83E\uDDD2\uD83C\uDFFF", + [":child_dark_skin_tone:"] = "\uD83E\uDDD2\uD83C\uDFFF", + [":child_light_skin_tone:"] = "\uD83E\uDDD2\uD83C\uDFFB", + [":child_medium_dark_skin_tone:"] = "\uD83E\uDDD2\uD83C\uDFFE", + [":child_medium_light_skin_tone:"] = "\uD83E\uDDD2\uD83C\uDFFC", + [":child_medium_skin_tone:"] = "\uD83E\uDDD2\uD83C\uDFFD", + [":child_tone1:"] = "\uD83E\uDDD2\uD83C\uDFFB", + [":child_tone2:"] = "\uD83E\uDDD2\uD83C\uDFFC", + [":child_tone3:"] = "\uD83E\uDDD2\uD83C\uDFFD", + [":child_tone4:"] = "\uD83E\uDDD2\uD83C\uDFFE", + [":child_tone5:"] = "\uD83E\uDDD2\uD83C\uDFFF", + [":children_crossing:"] = "\uD83D\uDEB8", + [":chipmunk:"] = "\uD83D\uDC3F️", + [":chocolate_bar:"] = "\uD83C\uDF6B", + [":chopsticks:"] = "\uD83E\uDD62", + [":christmas_tree:"] = "\uD83C\uDF84", + [":church:"] = "⛪", + [":cinema:"] = "\uD83C\uDFA6", + [":circus_tent:"] = "\uD83C\uDFAA", + [":city_dusk:"] = "\uD83C\uDF06", + [":city_sunrise:"] = "\uD83C\uDF07", + [":city_sunset:"] = "\uD83C\uDF07", + [":cityscape:"] = "\uD83C\uDFD9️", + [":cl:"] = "\uD83C\uDD91", + [":clap:"] = "\uD83D\uDC4F", + [":clap::skin-tone-1:"] = "\uD83D\uDC4F\uD83C\uDFFB", + [":clap::skin-tone-2:"] = "\uD83D\uDC4F\uD83C\uDFFC", + [":clap::skin-tone-3:"] = "\uD83D\uDC4F\uD83C\uDFFD", + [":clap::skin-tone-4:"] = "\uD83D\uDC4F\uD83C\uDFFE", + [":clap::skin-tone-5:"] = "\uD83D\uDC4F\uD83C\uDFFF", + [":clap_tone1:"] = "\uD83D\uDC4F\uD83C\uDFFB", + [":clap_tone2:"] = "\uD83D\uDC4F\uD83C\uDFFC", + [":clap_tone3:"] = "\uD83D\uDC4F\uD83C\uDFFD", + [":clap_tone4:"] = "\uD83D\uDC4F\uD83C\uDFFE", + [":clap_tone5:"] = "\uD83D\uDC4F\uD83C\uDFFF", + [":clapper:"] = "\uD83C\uDFAC", + [":classical_building:"] = "\uD83C\uDFDB️", + [":clinking_glass:"] = "\uD83E\uDD42", + [":clipboard:"] = "\uD83D\uDCCB", + [":clock1030:"] = "\uD83D\uDD65", + [":clock10:"] = "\uD83D\uDD59", + [":clock1130:"] = "\uD83D\uDD66", + [":clock11:"] = "\uD83D\uDD5A", + [":clock1230:"] = "\uD83D\uDD67", + [":clock12:"] = "\uD83D\uDD5B", + [":clock130:"] = "\uD83D\uDD5C", + [":clock1:"] = "\uD83D\uDD50", + [":clock230:"] = "\uD83D\uDD5D", + [":clock2:"] = "\uD83D\uDD51", + [":clock330:"] = "\uD83D\uDD5E", + [":clock3:"] = "\uD83D\uDD52", + [":clock430:"] = "\uD83D\uDD5F", + [":clock4:"] = "\uD83D\uDD53", + [":clock530:"] = "\uD83D\uDD60", + [":clock5:"] = "\uD83D\uDD54", + [":clock630:"] = "\uD83D\uDD61", + [":clock6:"] = "\uD83D\uDD55", + [":clock730:"] = "\uD83D\uDD62", + [":clock7:"] = "\uD83D\uDD56", + [":clock830:"] = "\uD83D\uDD63", + [":clock8:"] = "\uD83D\uDD57", + [":clock930:"] = "\uD83D\uDD64", + [":clock9:"] = "\uD83D\uDD58", + [":clock:"] = "\uD83D\uDD70️", + [":closed_book:"] = "\uD83D\uDCD5", + [":closed_lock_with_key:"] = "\uD83D\uDD10", + [":closed_umbrella:"] = "\uD83C\uDF02", + [":cloud:"] = "☁️", + [":cloud_lightning:"] = "\uD83C\uDF29️", + [":cloud_rain:"] = "\uD83C\uDF27️", + [":cloud_snow:"] = "\uD83C\uDF28️", + [":cloud_tornado:"] = "\uD83C\uDF2A️", + [":cloud_with_lightning:"] = "\uD83C\uDF29️", + [":cloud_with_rain:"] = "\uD83C\uDF27️", + [":cloud_with_snow:"] = "\uD83C\uDF28️", + [":cloud_with_tornado:"] = "\uD83C\uDF2A️", + [":clown:"] = "\uD83E\uDD21", + [":clown_face:"] = "\uD83E\uDD21", + [":clubs:"] = "♣️", + [":coat:"] = "\uD83E\uDDE5", + [":cockroach:"] = "\uD83E\uDEB3", + [":cocktail:"] = "\uD83C\uDF78", + [":coconut:"] = "\uD83E\uDD65", + [":coffee:"] = "☕", + [":coffin:"] = "⚰️", + [":coin:"] = "\uD83E\uDE99", + [":coin:"] = "\uD83E\uDE99", + [":cold_face:"] = "\uD83E\uDD76", + [":cold_sweat:"] = "\uD83D\uDE30", + [":comet:"] = "☄️", + [":compass:"] = "\uD83E\uDDED", + [":compression:"] = "\uD83D\uDDDC️", + [":computer:"] = "\uD83D\uDCBB", + [":confetti_ball:"] = "\uD83C\uDF8A", + [":confounded:"] = "\uD83D\uDE16", + [":confused:"] = "\uD83D\uDE15", + [":congratulations:"] = "㊗️", + [":construction:"] = "\uD83D\uDEA7", + [":construction_site:"] = "\uD83C\uDFD7️", + [":construction_worker:"] = "\uD83D\uDC77", + [":construction_worker::skin-tone-1:"] = "\uD83D\uDC77\uD83C\uDFFB", + [":construction_worker::skin-tone-2:"] = "\uD83D\uDC77\uD83C\uDFFC", + [":construction_worker::skin-tone-3:"] = "\uD83D\uDC77\uD83C\uDFFD", + [":construction_worker::skin-tone-4:"] = "\uD83D\uDC77\uD83C\uDFFE", + [":construction_worker::skin-tone-5:"] = "\uD83D\uDC77\uD83C\uDFFF", + [":construction_worker_tone1:"] = "\uD83D\uDC77\uD83C\uDFFB", + [":construction_worker_tone2:"] = "\uD83D\uDC77\uD83C\uDFFC", + [":construction_worker_tone3:"] = "\uD83D\uDC77\uD83C\uDFFD", + [":construction_worker_tone4:"] = "\uD83D\uDC77\uD83C\uDFFE", + [":construction_worker_tone5:"] = "\uD83D\uDC77\uD83C\uDFFF", + [":control_knobs:"] = "\uD83C\uDF9B️", + [":convenience_store:"] = "\uD83C\uDFEA", + [":cookie:"] = "\uD83C\uDF6A", + [":cooking:"] = "\uD83C\uDF73", + [":cool:"] = "\uD83C\uDD92", + [":cop:"] = "\uD83D\uDC6E", + [":cop::skin-tone-1:"] = "\uD83D\uDC6E\uD83C\uDFFB", + [":cop::skin-tone-2:"] = "\uD83D\uDC6E\uD83C\uDFFC", + [":cop::skin-tone-3:"] = "\uD83D\uDC6E\uD83C\uDFFD", + [":cop::skin-tone-4:"] = "\uD83D\uDC6E\uD83C\uDFFE", + [":cop::skin-tone-5:"] = "\uD83D\uDC6E\uD83C\uDFFF", + [":cop_tone1:"] = "\uD83D\uDC6E\uD83C\uDFFB", + [":cop_tone2:"] = "\uD83D\uDC6E\uD83C\uDFFC", + [":cop_tone3:"] = "\uD83D\uDC6E\uD83C\uDFFD", + [":cop_tone4:"] = "\uD83D\uDC6E\uD83C\uDFFE", + [":cop_tone5:"] = "\uD83D\uDC6E\uD83C\uDFFF", + [":copyright:"] = "©️", + [":coral:"] = "\uD83E\uDEB8", + [":corn:"] = "\uD83C\uDF3D", + [":couch:"] = "\uD83D\uDECB️", + [":couch_and_lamp:"] = "\uD83D\uDECB️", + [":couple:"] = "\uD83D\uDC6B", + [":couple_mm:"] = "\uD83D\uDC68\u200D❤️\u200D\uD83D\uDC68", + [":couple_with_heart:"] = "\uD83D\uDC91", + [":couple_with_heart::skin-tone-1:"] = "\uD83D\uDC91\uD83C\uDFFB", + [":couple_with_heart::skin-tone-2:"] = "\uD83D\uDC91\uD83C\uDFFC", + [":couple_with_heart::skin-tone-3:"] = "\uD83D\uDC91\uD83C\uDFFD", + [":couple_with_heart::skin-tone-4:"] = "\uD83D\uDC91\uD83C\uDFFE", + [":couple_with_heart::skin-tone-5:"] = "\uD83D\uDC91\uD83C\uDFFF", + [":couple_with_heart_dark_skin_tone:"] = "\uD83D\uDC91\uD83C\uDFFF", + [":couple_with_heart_light_skin_tone:"] = "\uD83D\uDC91\uD83C\uDFFB", + [":couple_with_heart_man_man::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_dark_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_dark_skin_tone_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone_tone3:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_dark_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_dark_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_light_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_light_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_light_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_light_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_light_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_medium_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_medium_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_medium_light_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_medium_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_man_man_medium_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_medium_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_medium_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_medium_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_medium_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_medium_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_medium_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_man_man_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_man_man_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_man_man_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_man_man_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_medium_dark_skin_tone:"] = "\uD83D\uDC91\uD83C\uDFFE", + [":couple_with_heart_medium_light_skin_tone:"] = "\uD83D\uDC91\uD83C\uDFFC", + [":couple_with_heart_medium_skin_tone:"] = "\uD83D\uDC91\uD83C\uDFFD", + [":couple_with_heart_mm:"] = "\uD83D\uDC68\u200D❤️\u200D\uD83D\uDC68", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-3:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_dark_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_dark_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_dark_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_dark_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_dark_skin_tone_tone3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone_tone3:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_dark_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_dark_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_light_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_light_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_light_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_light_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_light_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_light_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_light_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_light_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_light_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_light_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_light_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_light_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_medium_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_medium_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_medium_dark_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_medium_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_medium_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_medium_light_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_medium_light_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_medium_light_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_medium_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_medium_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_medium_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_medium_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_medium_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_medium_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_medium_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":couple_with_heart_person_person_medium_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_medium_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_medium_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_medium_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":couple_with_heart_person_person_medium_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_person_person_medium_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":couple_with_heart_person_person_medium_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":couple_with_heart_person_person_medium_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":couple_with_heart_tone1:"] = "\uD83D\uDC91\uD83C\uDFFB", + [":couple_with_heart_tone2:"] = "\uD83D\uDC91\uD83C\uDFFC", + [":couple_with_heart_tone3:"] = "\uD83D\uDC91\uD83C\uDFFD", + [":couple_with_heart_tone4:"] = "\uD83D\uDC91\uD83C\uDFFE", + [":couple_with_heart_tone5:"] = "\uD83D\uDC91\uD83C\uDFFF", + [":couple_with_heart_woman_man:"] = "\uD83D\uDC69\u200D❤️\u200D\uD83D\uDC68", + [":couple_with_heart_woman_man::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_light_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_medium_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_medium_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_medium_light_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_medium_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_man_medium_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_medium_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFB", + [":couple_with_heart_woman_man_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFC", + [":couple_with_heart_woman_man_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFD", + [":couple_with_heart_woman_man_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFE", + [":couple_with_heart_woman_man_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC68\uD83C\uDFFF", + [":couple_with_heart_woman_woman::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_light_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_medium_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_medium_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_medium_light_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_medium_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_woman_woman_medium_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_medium_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFB", + [":couple_with_heart_woman_woman_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFC", + [":couple_with_heart_woman_woman_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFD", + [":couple_with_heart_woman_woman_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFE", + [":couple_with_heart_woman_woman_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC69\uD83C\uDFFF", + [":couple_with_heart_ww:"] = "\uD83D\uDC69\u200D❤️\u200D\uD83D\uDC69", + [":couple_ww:"] = "\uD83D\uDC69\u200D❤️\u200D\uD83D\uDC69", + [":couplekiss:"] = "\uD83D\uDC8F", + [":couplekiss_mm:"] = "\uD83D\uDC68\u200D❤️\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", + [":couplekiss_ww:"] = "\uD83D\uDC69\u200D❤️\u200D\uD83D\uDC8B\u200D\uD83D\uDC69", + [":cow2:"] = "\uD83D\uDC04", + [":cow:"] = "\uD83D\uDC2E", + [":cowboy:"] = "\uD83E\uDD20", + [":crab:"] = "\uD83E\uDD80", + [":crayon:"] = "\uD83D\uDD8D️", + [":credit_card:"] = "\uD83D\uDCB3", + [":crescent_moon:"] = "\uD83C\uDF19", + [":cricket:"] = "\uD83E\uDD97", + [":cricket_bat_ball:"] = "\uD83C\uDFCF", + [":cricket_game:"] = "\uD83C\uDFCF", + [":crocodile:"] = "\uD83D\uDC0A", + [":croissant:"] = "\uD83E\uDD50", + [":cross:"] = "✝️", + [":crossed_flags:"] = "\uD83C\uDF8C", + [":crossed_swords:"] = "⚔️", + [":crown:"] = "\uD83D\uDC51", + [":cruise_ship:"] = "\uD83D\uDEF3️", + [":crutch:"] = "\uD83E\uDE7C", + [":cry:"] = "\uD83D\uDE22", + [":crying_cat_face:"] = "\uD83D\uDE3F", + [":crystal_ball:"] = "\uD83D\uDD2E", + [":cucumber:"] = "\uD83E\uDD52", + [":cup_with_straw:"] = "\uD83E\uDD64", + [":cupcake:"] = "\uD83E\uDDC1", + [":cupid:"] = "\uD83D\uDC98", + [":curling_stone:"] = "\uD83E\uDD4C", + [":curly_loop:"] = "➰", + [":currency_exchange:"] = "\uD83D\uDCB1", + [":curry:"] = "\uD83C\uDF5B", + [":custard:"] = "\uD83C\uDF6E", + [":customs:"] = "\uD83D\uDEC3", + [":cut_of_meat:"] = "\uD83E\uDD69", + [":cyclone:"] = "\uD83C\uDF00", + [":dagger:"] = "\uD83D\uDDE1️", + [":dagger_knife:"] = "\uD83D\uDDE1️", + [":dancer:"] = "\uD83D\uDC83", + [":dancer::skin-tone-1:"] = "\uD83D\uDC83\uD83C\uDFFB", + [":dancer::skin-tone-2:"] = "\uD83D\uDC83\uD83C\uDFFC", + [":dancer::skin-tone-3:"] = "\uD83D\uDC83\uD83C\uDFFD", + [":dancer::skin-tone-4:"] = "\uD83D\uDC83\uD83C\uDFFE", + [":dancer::skin-tone-5:"] = "\uD83D\uDC83\uD83C\uDFFF", + [":dancer_tone1:"] = "\uD83D\uDC83\uD83C\uDFFB", + [":dancer_tone2:"] = "\uD83D\uDC83\uD83C\uDFFC", + [":dancer_tone3:"] = "\uD83D\uDC83\uD83C\uDFFD", + [":dancer_tone4:"] = "\uD83D\uDC83\uD83C\uDFFE", + [":dancer_tone5:"] = "\uD83D\uDC83\uD83C\uDFFF", + [":dancers:"] = "\uD83D\uDC6F", + [":dango:"] = "\uD83C\uDF61", + [":dark_sunglasses:"] = "\uD83D\uDD76️", + [":dart:"] = "\uD83C\uDFAF", + [":dash:"] = "\uD83D\uDCA8", + [":date:"] = "\uD83D\uDCC5", + [":deaf_man:"] = "\uD83E\uDDCF\u200D♂️", + [":deaf_man::skin-tone-1:"] = "\uD83E\uDDCF\uD83C\uDFFB\u200D♂️", + [":deaf_man::skin-tone-2:"] = "\uD83E\uDDCF\uD83C\uDFFC\u200D♂️", + [":deaf_man::skin-tone-3:"] = "\uD83E\uDDCF\uD83C\uDFFD\u200D♂️", + [":deaf_man::skin-tone-4:"] = "\uD83E\uDDCF\uD83C\uDFFE\u200D♂️", + [":deaf_man::skin-tone-5:"] = "\uD83E\uDDCF\uD83C\uDFFF\u200D♂️", + [":deaf_man_dark_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFF\u200D♂️", + [":deaf_man_light_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFB\u200D♂️", + [":deaf_man_medium_dark_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFE\u200D♂️", + [":deaf_man_medium_light_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFC\u200D♂️", + [":deaf_man_medium_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFD\u200D♂️", + [":deaf_man_tone1:"] = "\uD83E\uDDCF\uD83C\uDFFB\u200D♂️", + [":deaf_man_tone2:"] = "\uD83E\uDDCF\uD83C\uDFFC\u200D♂️", + [":deaf_man_tone3:"] = "\uD83E\uDDCF\uD83C\uDFFD\u200D♂️", + [":deaf_man_tone4:"] = "\uD83E\uDDCF\uD83C\uDFFE\u200D♂️", + [":deaf_man_tone5:"] = "\uD83E\uDDCF\uD83C\uDFFF\u200D♂️", + [":deaf_person:"] = "\uD83E\uDDCF", + [":deaf_person::skin-tone-1:"] = "\uD83E\uDDCF\uD83C\uDFFB", + [":deaf_person::skin-tone-2:"] = "\uD83E\uDDCF\uD83C\uDFFC", + [":deaf_person::skin-tone-3:"] = "\uD83E\uDDCF\uD83C\uDFFD", + [":deaf_person::skin-tone-4:"] = "\uD83E\uDDCF\uD83C\uDFFE", + [":deaf_person::skin-tone-5:"] = "\uD83E\uDDCF\uD83C\uDFFF", + [":deaf_person_dark_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFF", + [":deaf_person_light_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFB", + [":deaf_person_medium_dark_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFE", + [":deaf_person_medium_light_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFC", + [":deaf_person_medium_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFD", + [":deaf_person_tone1:"] = "\uD83E\uDDCF\uD83C\uDFFB", + [":deaf_person_tone2:"] = "\uD83E\uDDCF\uD83C\uDFFC", + [":deaf_person_tone3:"] = "\uD83E\uDDCF\uD83C\uDFFD", + [":deaf_person_tone4:"] = "\uD83E\uDDCF\uD83C\uDFFE", + [":deaf_person_tone5:"] = "\uD83E\uDDCF\uD83C\uDFFF", + [":deaf_woman:"] = "\uD83E\uDDCF\u200D♀️", + [":deaf_woman::skin-tone-1:"] = "\uD83E\uDDCF\uD83C\uDFFB\u200D♀️", + [":deaf_woman::skin-tone-2:"] = "\uD83E\uDDCF\uD83C\uDFFC\u200D♀️", + [":deaf_woman::skin-tone-3:"] = "\uD83E\uDDCF\uD83C\uDFFD\u200D♀️", + [":deaf_woman::skin-tone-4:"] = "\uD83E\uDDCF\uD83C\uDFFE\u200D♀️", + [":deaf_woman::skin-tone-5:"] = "\uD83E\uDDCF\uD83C\uDFFF\u200D♀️", + [":deaf_woman_dark_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFF\u200D♀️", + [":deaf_woman_light_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFB\u200D♀️", + [":deaf_woman_medium_dark_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFE\u200D♀️", + [":deaf_woman_medium_light_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFC\u200D♀️", + [":deaf_woman_medium_skin_tone:"] = "\uD83E\uDDCF\uD83C\uDFFD\u200D♀️", + [":deaf_woman_tone1:"] = "\uD83E\uDDCF\uD83C\uDFFB\u200D♀️", + [":deaf_woman_tone2:"] = "\uD83E\uDDCF\uD83C\uDFFC\u200D♀️", + [":deaf_woman_tone3:"] = "\uD83E\uDDCF\uD83C\uDFFD\u200D♀️", + [":deaf_woman_tone4:"] = "\uD83E\uDDCF\uD83C\uDFFE\u200D♀️", + [":deaf_woman_tone5:"] = "\uD83E\uDDCF\uD83C\uDFFF\u200D♀️", + [":deciduous_tree:"] = "\uD83C\uDF33", + [":deer:"] = "\uD83E\uDD8C", + [":department_store:"] = "\uD83C\uDFEC", + [":derelict_house_building:"] = "\uD83C\uDFDA️", + [":desert:"] = "\uD83C\uDFDC️", + [":desert_island:"] = "\uD83C\uDFDD️", + [":desktop:"] = "\uD83D\uDDA5️", + [":desktop_computer:"] = "\uD83D\uDDA5️", + [":detective:"] = "\uD83D\uDD75️", + [":detective::skin-tone-1:"] = "\uD83D\uDD75\uD83C\uDFFB", + [":detective::skin-tone-2:"] = "\uD83D\uDD75\uD83C\uDFFC", + [":detective::skin-tone-3:"] = "\uD83D\uDD75\uD83C\uDFFD", + [":detective::skin-tone-4:"] = "\uD83D\uDD75\uD83C\uDFFE", + [":detective::skin-tone-5:"] = "\uD83D\uDD75\uD83C\uDFFF", + [":detective_tone1:"] = "\uD83D\uDD75\uD83C\uDFFB", + [":detective_tone2:"] = "\uD83D\uDD75\uD83C\uDFFC", + [":detective_tone3:"] = "\uD83D\uDD75\uD83C\uDFFD", + [":detective_tone4:"] = "\uD83D\uDD75\uD83C\uDFFE", + [":detective_tone5:"] = "\uD83D\uDD75\uD83C\uDFFF", + [":diamond_shape_with_a_dot_inside:"] = "\uD83D\uDCA0", + [":diamonds:"] = "♦️", + [":disappointed:"] = "\uD83D\uDE1E", + [":disappointed_relieved:"] = "\uD83D\uDE25", + [":disguised_face:"] = "\uD83E\uDD78", + [":disguised_face:"] = "\uD83E\uDD78", + [":dividers:"] = "\uD83D\uDDC2️", + [":diving_mask:"] = "\uD83E\uDD3F", + [":diya_lamp:"] = "\uD83E\uDE94", + [":dizzy:"] = "\uD83D\uDCAB", + [":dizzy_face:"] = "\uD83D\uDE35", + [":dna:"] = "\uD83E\uDDEC", + [":do_not_litter:"] = "\uD83D\uDEAF", + [":dodo:"] = "\uD83E\uDDA4", + [":dog2:"] = "\uD83D\uDC15", + [":dog:"] = "\uD83D\uDC36", + [":dollar:"] = "\uD83D\uDCB5", + [":dolls:"] = "\uD83C\uDF8E", + [":dolphin:"] = "\uD83D\uDC2C", + [":door:"] = "\uD83D\uDEAA", + [":dotted_line_face:"] = "\uD83E\uDEE5", + [":double_vertical_bar:"] = "⏸️", + [":doughnut:"] = "\uD83C\uDF69", + [":dove:"] = "\uD83D\uDD4A️", + [":dove_of_peace:"] = "\uD83D\uDD4A️", + [":dragon:"] = "\uD83D\uDC09", + [":dragon_face:"] = "\uD83D\uDC32", + [":dress:"] = "\uD83D\uDC57", + [":dromedary_camel:"] = "\uD83D\uDC2A", + [":drool:"] = "\uD83E\uDD24", + [":drooling_face:"] = "\uD83E\uDD24", + [":drop_of_blood:"] = "\uD83E\uDE78", + [":droplet:"] = "\uD83D\uDCA7", + [":drum:"] = "\uD83E\uDD41", + [":drum_with_drumsticks:"] = "\uD83E\uDD41", + [":duck:"] = "\uD83E\uDD86", + [":dumpling:"] = "\uD83E\uDD5F", + [":dvd:"] = "\uD83D\uDCC0", + [":e_mail:"] = "\uD83D\uDCE7", + [":eagle:"] = "\uD83E\uDD85", + [":ear:"] = "\uD83D\uDC42", + [":ear::skin-tone-1:"] = "\uD83D\uDC42\uD83C\uDFFB", + [":ear::skin-tone-2:"] = "\uD83D\uDC42\uD83C\uDFFC", + [":ear::skin-tone-3:"] = "\uD83D\uDC42\uD83C\uDFFD", + [":ear::skin-tone-4:"] = "\uD83D\uDC42\uD83C\uDFFE", + [":ear::skin-tone-5:"] = "\uD83D\uDC42\uD83C\uDFFF", + [":ear_of_rice:"] = "\uD83C\uDF3E", + [":ear_tone1:"] = "\uD83D\uDC42\uD83C\uDFFB", + [":ear_tone2:"] = "\uD83D\uDC42\uD83C\uDFFC", + [":ear_tone3:"] = "\uD83D\uDC42\uD83C\uDFFD", + [":ear_tone4:"] = "\uD83D\uDC42\uD83C\uDFFE", + [":ear_tone5:"] = "\uD83D\uDC42\uD83C\uDFFF", + [":ear_with_hearing_aid:"] = "\uD83E\uDDBB", + [":ear_with_hearing_aid::skin-tone-1:"] = "\uD83E\uDDBB\uD83C\uDFFB", + [":ear_with_hearing_aid::skin-tone-2:"] = "\uD83E\uDDBB\uD83C\uDFFC", + [":ear_with_hearing_aid::skin-tone-3:"] = "\uD83E\uDDBB\uD83C\uDFFD", + [":ear_with_hearing_aid::skin-tone-4:"] = "\uD83E\uDDBB\uD83C\uDFFE", + [":ear_with_hearing_aid::skin-tone-5:"] = "\uD83E\uDDBB\uD83C\uDFFF", + [":ear_with_hearing_aid_dark_skin_tone:"] = "\uD83E\uDDBB\uD83C\uDFFF", + [":ear_with_hearing_aid_light_skin_tone:"] = "\uD83E\uDDBB\uD83C\uDFFB", + [":ear_with_hearing_aid_medium_dark_skin_tone:"] = "\uD83E\uDDBB\uD83C\uDFFE", + [":ear_with_hearing_aid_medium_light_skin_tone:"] = "\uD83E\uDDBB\uD83C\uDFFC", + [":ear_with_hearing_aid_medium_skin_tone:"] = "\uD83E\uDDBB\uD83C\uDFFD", + [":ear_with_hearing_aid_tone1:"] = "\uD83E\uDDBB\uD83C\uDFFB", + [":ear_with_hearing_aid_tone2:"] = "\uD83E\uDDBB\uD83C\uDFFC", + [":ear_with_hearing_aid_tone3:"] = "\uD83E\uDDBB\uD83C\uDFFD", + [":ear_with_hearing_aid_tone4:"] = "\uD83E\uDDBB\uD83C\uDFFE", + [":ear_with_hearing_aid_tone5:"] = "\uD83E\uDDBB\uD83C\uDFFF", + [":earth_africa:"] = "\uD83C\uDF0D", + [":earth_americas:"] = "\uD83C\uDF0E", + [":earth_asia:"] = "\uD83C\uDF0F", + [":egg:"] = "\uD83E\uDD5A", + [":eggplant:"] = "\uD83C\uDF46", + [":eight:"] = "8️⃣", + [":eight_pointed_black_star:"] = "✴️", + [":eight_spoked_asterisk:"] = "✳️", + [":eject:"] = "⏏️", + [":eject_symbol:"] = "⏏️", + [":electric_plug:"] = "\uD83D\uDD0C", + [":elephant:"] = "\uD83D\uDC18", + [":elevator:"] = "\uD83D\uDED7", + [":elf:"] = "\uD83E\uDDDD", + [":elf::skin-tone-1:"] = "\uD83E\uDDDD\uD83C\uDFFB", + [":elf::skin-tone-2:"] = "\uD83E\uDDDD\uD83C\uDFFC", + [":elf::skin-tone-3:"] = "\uD83E\uDDDD\uD83C\uDFFD", + [":elf::skin-tone-4:"] = "\uD83E\uDDDD\uD83C\uDFFE", + [":elf::skin-tone-5:"] = "\uD83E\uDDDD\uD83C\uDFFF", + [":elf_dark_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFF", + [":elf_light_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFB", + [":elf_medium_dark_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFE", + [":elf_medium_light_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFC", + [":elf_medium_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFD", + [":elf_tone1:"] = "\uD83E\uDDDD\uD83C\uDFFB", + [":elf_tone2:"] = "\uD83E\uDDDD\uD83C\uDFFC", + [":elf_tone3:"] = "\uD83E\uDDDD\uD83C\uDFFD", + [":elf_tone4:"] = "\uD83E\uDDDD\uD83C\uDFFE", + [":elf_tone5:"] = "\uD83E\uDDDD\uD83C\uDFFF", + [":email:"] = "\uD83D\uDCE7", + [":empty_nest:"] = "\uD83E\uDEB9", + [":end:"] = "\uD83D\uDD1A", + [":england:"] = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67\uDB40\uDC7F", + [":envelope:"] = "✉️", + [":envelope_with_arrow:"] = "\uD83D\uDCE9", + [":euro:"] = "\uD83D\uDCB6", + [":european_castle:"] = "\uD83C\uDFF0", + [":european_post_office:"] = "\uD83C\uDFE4", + [":evergreen_tree:"] = "\uD83C\uDF32", + [":exclamation:"] = "❗", + [":expecting_woman:"] = "\uD83E\uDD30", + [":expecting_woman::skin-tone-1:"] = "\uD83E\uDD30\uD83C\uDFFB", + [":expecting_woman::skin-tone-2:"] = "\uD83E\uDD30\uD83C\uDFFC", + [":expecting_woman::skin-tone-3:"] = "\uD83E\uDD30\uD83C\uDFFD", + [":expecting_woman::skin-tone-4:"] = "\uD83E\uDD30\uD83C\uDFFE", + [":expecting_woman::skin-tone-5:"] = "\uD83E\uDD30\uD83C\uDFFF", + [":expecting_woman_tone1:"] = "\uD83E\uDD30\uD83C\uDFFB", + [":expecting_woman_tone2:"] = "\uD83E\uDD30\uD83C\uDFFC", + [":expecting_woman_tone3:"] = "\uD83E\uDD30\uD83C\uDFFD", + [":expecting_woman_tone4:"] = "\uD83E\uDD30\uD83C\uDFFE", + [":expecting_woman_tone5:"] = "\uD83E\uDD30\uD83C\uDFFF", + [":exploding_head:"] = "\uD83E\uDD2F", + [":expressionless:"] = "\uD83D\uDE11", + [":eye:"] = "\uD83D\uDC41️", + [":eye_in_speech_bubble:"] = "\uD83D\uDC41\u200D\uD83D\uDDE8", + [":eyeglasses:"] = "\uD83D\uDC53", + [":eyes:"] = "\uD83D\uDC40", + [":face_exhaling:"] = "\uD83D\uDE2E\u200D\uD83D\uDCA8", + [":face_holding_back_tears:"] = "\uD83E\uDD79", + [":face_in_clouds:"] = "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", + [":face_palm:"] = "\uD83E\uDD26", + [":face_palm::skin-tone-1:"] = "\uD83E\uDD26\uD83C\uDFFB", + [":face_palm::skin-tone-2:"] = "\uD83E\uDD26\uD83C\uDFFC", + [":face_palm::skin-tone-3:"] = "\uD83E\uDD26\uD83C\uDFFD", + [":face_palm::skin-tone-4:"] = "\uD83E\uDD26\uD83C\uDFFE", + [":face_palm::skin-tone-5:"] = "\uD83E\uDD26\uD83C\uDFFF", + [":face_palm_tone1:"] = "\uD83E\uDD26\uD83C\uDFFB", + [":face_palm_tone2:"] = "\uD83E\uDD26\uD83C\uDFFC", + [":face_palm_tone3:"] = "\uD83E\uDD26\uD83C\uDFFD", + [":face_palm_tone4:"] = "\uD83E\uDD26\uD83C\uDFFE", + [":face_palm_tone5:"] = "\uD83E\uDD26\uD83C\uDFFF", + [":face_vomiting:"] = "\uD83E\uDD2E", + [":face_with_cowboy_hat:"] = "\uD83E\uDD20", + [":face_with_diagonal_mouth:"] = "\uD83E\uDEE4", + [":face_with_hand_over_mouth:"] = "\uD83E\uDD2D", + [":face_with_head_bandage:"] = "\uD83E\uDD15", + [":face_with_monocle:"] = "\uD83E\uDDD0", + [":face_with_open_eyes_and_hand_over_mouth:"] = "\uD83E\uDEE0", + [":face_with_peeking_eye:"] = "\uD83E\uDEE3", + [":face_with_raised_eyebrow:"] = "\uD83E\uDD28", + [":face_with_rolling_eyes:"] = "\uD83D\uDE44", + [":face_with_spiral_eyes:"] = "\uD83D\uDE35\u200D\uD83D\uDCAB", + [":face_with_symbols_over_mouth:"] = "\uD83E\uDD2C", + [":face_with_thermometer:"] = "\uD83E\uDD12", + [":facepalm:"] = "\uD83E\uDD26", + [":facepalm::skin-tone-1:"] = "\uD83E\uDD26\uD83C\uDFFB", + [":facepalm::skin-tone-2:"] = "\uD83E\uDD26\uD83C\uDFFC", + [":facepalm::skin-tone-3:"] = "\uD83E\uDD26\uD83C\uDFFD", + [":facepalm::skin-tone-4:"] = "\uD83E\uDD26\uD83C\uDFFE", + [":facepalm::skin-tone-5:"] = "\uD83E\uDD26\uD83C\uDFFF", + [":facepalm_tone1:"] = "\uD83E\uDD26\uD83C\uDFFB", + [":facepalm_tone2:"] = "\uD83E\uDD26\uD83C\uDFFC", + [":facepalm_tone3:"] = "\uD83E\uDD26\uD83C\uDFFD", + [":facepalm_tone4:"] = "\uD83E\uDD26\uD83C\uDFFE", + [":facepalm_tone5:"] = "\uD83E\uDD26\uD83C\uDFFF", + [":factory:"] = "\uD83C\uDFED", + [":fairy:"] = "\uD83E\uDDDA", + [":fairy::skin-tone-1:"] = "\uD83E\uDDDA\uD83C\uDFFB", + [":fairy::skin-tone-2:"] = "\uD83E\uDDDA\uD83C\uDFFC", + [":fairy::skin-tone-3:"] = "\uD83E\uDDDA\uD83C\uDFFD", + [":fairy::skin-tone-4:"] = "\uD83E\uDDDA\uD83C\uDFFE", + [":fairy::skin-tone-5:"] = "\uD83E\uDDDA\uD83C\uDFFF", + [":fairy_dark_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFF", + [":fairy_light_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFB", + [":fairy_medium_dark_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFE", + [":fairy_medium_light_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFC", + [":fairy_medium_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFD", + [":fairy_tone1:"] = "\uD83E\uDDDA\uD83C\uDFFB", + [":fairy_tone2:"] = "\uD83E\uDDDA\uD83C\uDFFC", + [":fairy_tone3:"] = "\uD83E\uDDDA\uD83C\uDFFD", + [":fairy_tone4:"] = "\uD83E\uDDDA\uD83C\uDFFE", + [":fairy_tone5:"] = "\uD83E\uDDDA\uD83C\uDFFF", + [":falafel:"] = "\uD83E\uDDC6", + [":fallen_leaf:"] = "\uD83C\uDF42", + [":family:"] = "\uD83D\uDC6A", + [":family_man_boy:"] = "\uD83D\uDC68\u200D\uD83D\uDC66", + [":family_man_boy_boy:"] = "\uD83D\uDC68\u200D\uD83D\uDC66\u200D\uD83D\uDC66", + [":family_man_girl:"] = "\uD83D\uDC68\u200D\uD83D\uDC67", + [":family_man_girl_boy:"] = "\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC66", + [":family_man_girl_girl:"] = "\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC67", + [":family_man_woman_boy:"] = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC66", + [":family_mmb:"] = "\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC66", + [":family_mmbb:"] = "\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC66\u200D\uD83D\uDC66", + [":family_mmg:"] = "\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC67", + [":family_mmgb:"] = "\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC66", + [":family_mmgg:"] = "\uD83D\uDC68\u200D\uD83D\uDC68\u200D\uD83D\uDC67\u200D\uD83D\uDC67", + [":family_mwbb:"] = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66", + [":family_mwg:"] = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67", + [":family_mwgb:"] = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66", + [":family_mwgg:"] = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67", + [":family_woman_boy:"] = "\uD83D\uDC69\u200D\uD83D\uDC66", + [":family_woman_boy_boy:"] = "\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66", + [":family_woman_girl:"] = "\uD83D\uDC69\u200D\uD83D\uDC67", + [":family_woman_girl_boy:"] = "\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66", + [":family_woman_girl_girl:"] = "\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67", + [":family_wwb:"] = "\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66", + [":family_wwbb:"] = "\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66", + [":family_wwg:"] = "\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67", + [":family_wwgb:"] = "\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66", + [":family_wwgg:"] = "\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC67", + [":fast_forward:"] = "⏩", + [":fax:"] = "\uD83D\uDCE0", + [":fearful:"] = "\uD83D\uDE28", + [":feather:"] = "\uD83E\uDEB6", + [":feet:"] = "\uD83D\uDC3E", + [":female_sign:"] = "♀️", + [":fencer:"] = "\uD83E\uDD3A", + [":fencing:"] = "\uD83E\uDD3A", + [":ferris_wheel:"] = "\uD83C\uDFA1", + [":ferry:"] = "⛴️", + [":field_hockey:"] = "\uD83C\uDFD1", + [":file_cabinet:"] = "\uD83D\uDDC4️", + [":file_folder:"] = "\uD83D\uDCC1", + [":film_frames:"] = "\uD83C\uDF9E️", + [":film_projector:"] = "\uD83D\uDCFD️", + [":fingers_crossed:"] = "\uD83E\uDD1E", + [":fingers_crossed::skin-tone-1:"] = "\uD83E\uDD1E\uD83C\uDFFB", + [":fingers_crossed::skin-tone-2:"] = "\uD83E\uDD1E\uD83C\uDFFC", + [":fingers_crossed::skin-tone-3:"] = "\uD83E\uDD1E\uD83C\uDFFD", + [":fingers_crossed::skin-tone-4:"] = "\uD83E\uDD1E\uD83C\uDFFE", + [":fingers_crossed::skin-tone-5:"] = "\uD83E\uDD1E\uD83C\uDFFF", + [":fingers_crossed_tone1:"] = "\uD83E\uDD1E\uD83C\uDFFB", + [":fingers_crossed_tone2:"] = "\uD83E\uDD1E\uD83C\uDFFC", + [":fingers_crossed_tone3:"] = "\uD83E\uDD1E\uD83C\uDFFD", + [":fingers_crossed_tone4:"] = "\uD83E\uDD1E\uD83C\uDFFE", + [":fingers_crossed_tone5:"] = "\uD83E\uDD1E\uD83C\uDFFF", + [":fire:"] = "\uD83D\uDD25", + [":fire_engine:"] = "\uD83D\uDE92", + [":fire_extinguisher:"] = "\uD83E\uDDEF", + [":firecracker:"] = "\uD83E\uDDE8", + [":fireworks:"] = "\uD83C\uDF86", + [":first_place:"] = "\uD83E\uDD47", + [":first_place_medal:"] = "\uD83E\uDD47", + [":first_quarter_moon:"] = "\uD83C\uDF13", + [":first_quarter_moon_with_face:"] = "\uD83C\uDF1B", + [":fish:"] = "\uD83D\uDC1F", + [":fish_cake:"] = "\uD83C\uDF65", + [":fishing_pole_and_fish:"] = "\uD83C\uDFA3", + [":fist:"] = "✊", + [":fist::skin-tone-1:"] = "✊\uD83C\uDFFB", + [":fist::skin-tone-2:"] = "✊\uD83C\uDFFC", + [":fist::skin-tone-3:"] = "✊\uD83C\uDFFD", + [":fist::skin-tone-4:"] = "✊\uD83C\uDFFE", + [":fist::skin-tone-5:"] = "✊\uD83C\uDFFF", + [":fist_tone1:"] = "✊\uD83C\uDFFB", + [":fist_tone2:"] = "✊\uD83C\uDFFC", + [":fist_tone3:"] = "✊\uD83C\uDFFD", + [":fist_tone4:"] = "✊\uD83C\uDFFE", + [":fist_tone5:"] = "✊\uD83C\uDFFF", + [":five:"] = "5️⃣", + [":flag_ac:"] = "\uD83C\uDDE6\uD83C\uDDE8", + [":flag_ad:"] = "\uD83C\uDDE6\uD83C\uDDE9", + [":flag_ae:"] = "\uD83C\uDDE6\uD83C\uDDEA", + [":flag_af:"] = "\uD83C\uDDE6\uD83C\uDDEB", + [":flag_ag:"] = "\uD83C\uDDE6\uD83C\uDDEC", + [":flag_ai:"] = "\uD83C\uDDE6\uD83C\uDDEE", + [":flag_al:"] = "\uD83C\uDDE6\uD83C\uDDF1", + [":flag_am:"] = "\uD83C\uDDE6\uD83C\uDDF2", + [":flag_ao:"] = "\uD83C\uDDE6\uD83C\uDDF4", + [":flag_aq:"] = "\uD83C\uDDE6\uD83C\uDDF6", + [":flag_ar:"] = "\uD83C\uDDE6\uD83C\uDDF7", + [":flag_as:"] = "\uD83C\uDDE6\uD83C\uDDF8", + [":flag_at:"] = "\uD83C\uDDE6\uD83C\uDDF9", + [":flag_au:"] = "\uD83C\uDDE6\uD83C\uDDFA", + [":flag_aw:"] = "\uD83C\uDDE6\uD83C\uDDFC", + [":flag_ax:"] = "\uD83C\uDDE6\uD83C\uDDFD", + [":flag_az:"] = "\uD83C\uDDE6\uD83C\uDDFF", + [":flag_ba:"] = "\uD83C\uDDE7\uD83C\uDDE6", + [":flag_bb:"] = "\uD83C\uDDE7\uD83C\uDDE7", + [":flag_bd:"] = "\uD83C\uDDE7\uD83C\uDDE9", + [":flag_be:"] = "\uD83C\uDDE7\uD83C\uDDEA", + [":flag_bf:"] = "\uD83C\uDDE7\uD83C\uDDEB", + [":flag_bg:"] = "\uD83C\uDDE7\uD83C\uDDEC", + [":flag_bh:"] = "\uD83C\uDDE7\uD83C\uDDED", + [":flag_bi:"] = "\uD83C\uDDE7\uD83C\uDDEE", + [":flag_bj:"] = "\uD83C\uDDE7\uD83C\uDDEF", + [":flag_bl:"] = "\uD83C\uDDE7\uD83C\uDDF1", + [":flag_black:"] = "\uD83C\uDFF4", + [":flag_bm:"] = "\uD83C\uDDE7\uD83C\uDDF2", + [":flag_bn:"] = "\uD83C\uDDE7\uD83C\uDDF3", + [":flag_bo:"] = "\uD83C\uDDE7\uD83C\uDDF4", + [":flag_bq:"] = "\uD83C\uDDE7\uD83C\uDDF6", + [":flag_br:"] = "\uD83C\uDDE7\uD83C\uDDF7", + [":flag_bs:"] = "\uD83C\uDDE7\uD83C\uDDF8", + [":flag_bt:"] = "\uD83C\uDDE7\uD83C\uDDF9", + [":flag_bv:"] = "\uD83C\uDDE7\uD83C\uDDFB", + [":flag_bw:"] = "\uD83C\uDDE7\uD83C\uDDFC", + [":flag_by:"] = "\uD83C\uDDE7\uD83C\uDDFE", + [":flag_bz:"] = "\uD83C\uDDE7\uD83C\uDDFF", + [":flag_ca:"] = "\uD83C\uDDE8\uD83C\uDDE6", + [":flag_cc:"] = "\uD83C\uDDE8\uD83C\uDDE8", + [":flag_cd:"] = "\uD83C\uDDE8\uD83C\uDDE9", + [":flag_cf:"] = "\uD83C\uDDE8\uD83C\uDDEB", + [":flag_cg:"] = "\uD83C\uDDE8\uD83C\uDDEC", + [":flag_ch:"] = "\uD83C\uDDE8\uD83C\uDDED", + [":flag_ci:"] = "\uD83C\uDDE8\uD83C\uDDEE", + [":flag_ck:"] = "\uD83C\uDDE8\uD83C\uDDF0", + [":flag_cl:"] = "\uD83C\uDDE8\uD83C\uDDF1", + [":flag_cm:"] = "\uD83C\uDDE8\uD83C\uDDF2", + [":flag_cn:"] = "\uD83C\uDDE8\uD83C\uDDF3", + [":flag_co:"] = "\uD83C\uDDE8\uD83C\uDDF4", + [":flag_cp:"] = "\uD83C\uDDE8\uD83C\uDDF5", + [":flag_cr:"] = "\uD83C\uDDE8\uD83C\uDDF7", + [":flag_cu:"] = "\uD83C\uDDE8\uD83C\uDDFA", + [":flag_cv:"] = "\uD83C\uDDE8\uD83C\uDDFB", + [":flag_cw:"] = "\uD83C\uDDE8\uD83C\uDDFC", + [":flag_cx:"] = "\uD83C\uDDE8\uD83C\uDDFD", + [":flag_cy:"] = "\uD83C\uDDE8\uD83C\uDDFE", + [":flag_cz:"] = "\uD83C\uDDE8\uD83C\uDDFF", + [":flag_de:"] = "\uD83C\uDDE9\uD83C\uDDEA", + [":flag_dg:"] = "\uD83C\uDDE9\uD83C\uDDEC", + [":flag_dj:"] = "\uD83C\uDDE9\uD83C\uDDEF", + [":flag_dk:"] = "\uD83C\uDDE9\uD83C\uDDF0", + [":flag_dm:"] = "\uD83C\uDDE9\uD83C\uDDF2", + [":flag_do:"] = "\uD83C\uDDE9\uD83C\uDDF4", + [":flag_dz:"] = "\uD83C\uDDE9\uD83C\uDDFF", + [":flag_ea:"] = "\uD83C\uDDEA\uD83C\uDDE6", + [":flag_ec:"] = "\uD83C\uDDEA\uD83C\uDDE8", + [":flag_ee:"] = "\uD83C\uDDEA\uD83C\uDDEA", + [":flag_eg:"] = "\uD83C\uDDEA\uD83C\uDDEC", + [":flag_eh:"] = "\uD83C\uDDEA\uD83C\uDDED", + [":flag_er:"] = "\uD83C\uDDEA\uD83C\uDDF7", + [":flag_es:"] = "\uD83C\uDDEA\uD83C\uDDF8", + [":flag_et:"] = "\uD83C\uDDEA\uD83C\uDDF9", + [":flag_eu:"] = "\uD83C\uDDEA\uD83C\uDDFA", + [":flag_fi:"] = "\uD83C\uDDEB\uD83C\uDDEE", + [":flag_fj:"] = "\uD83C\uDDEB\uD83C\uDDEF", + [":flag_fk:"] = "\uD83C\uDDEB\uD83C\uDDF0", + [":flag_fm:"] = "\uD83C\uDDEB\uD83C\uDDF2", + [":flag_fo:"] = "\uD83C\uDDEB\uD83C\uDDF4", + [":flag_fr:"] = "\uD83C\uDDEB\uD83C\uDDF7", + [":flag_ga:"] = "\uD83C\uDDEC\uD83C\uDDE6", + [":flag_gb:"] = "\uD83C\uDDEC\uD83C\uDDE7", + [":flag_gd:"] = "\uD83C\uDDEC\uD83C\uDDE9", + [":flag_ge:"] = "\uD83C\uDDEC\uD83C\uDDEA", + [":flag_gf:"] = "\uD83C\uDDEC\uD83C\uDDEB", + [":flag_gg:"] = "\uD83C\uDDEC\uD83C\uDDEC", + [":flag_gh:"] = "\uD83C\uDDEC\uD83C\uDDED", + [":flag_gi:"] = "\uD83C\uDDEC\uD83C\uDDEE", + [":flag_gl:"] = "\uD83C\uDDEC\uD83C\uDDF1", + [":flag_gm:"] = "\uD83C\uDDEC\uD83C\uDDF2", + [":flag_gn:"] = "\uD83C\uDDEC\uD83C\uDDF3", + [":flag_gp:"] = "\uD83C\uDDEC\uD83C\uDDF5", + [":flag_gq:"] = "\uD83C\uDDEC\uD83C\uDDF6", + [":flag_gr:"] = "\uD83C\uDDEC\uD83C\uDDF7", + [":flag_gs:"] = "\uD83C\uDDEC\uD83C\uDDF8", + [":flag_gt:"] = "\uD83C\uDDEC\uD83C\uDDF9", + [":flag_gu:"] = "\uD83C\uDDEC\uD83C\uDDFA", + [":flag_gw:"] = "\uD83C\uDDEC\uD83C\uDDFC", + [":flag_gy:"] = "\uD83C\uDDEC\uD83C\uDDFE", + [":flag_hk:"] = "\uD83C\uDDED\uD83C\uDDF0", + [":flag_hm:"] = "\uD83C\uDDED\uD83C\uDDF2", + [":flag_hn:"] = "\uD83C\uDDED\uD83C\uDDF3", + [":flag_hr:"] = "\uD83C\uDDED\uD83C\uDDF7", + [":flag_ht:"] = "\uD83C\uDDED\uD83C\uDDF9", + [":flag_hu:"] = "\uD83C\uDDED\uD83C\uDDFA", + [":flag_ic:"] = "\uD83C\uDDEE\uD83C\uDDE8", + [":flag_id:"] = "\uD83C\uDDEE\uD83C\uDDE9", + [":flag_ie:"] = "\uD83C\uDDEE\uD83C\uDDEA", + [":flag_il:"] = "\uD83C\uDDEE\uD83C\uDDF1", + [":flag_im:"] = "\uD83C\uDDEE\uD83C\uDDF2", + [":flag_in:"] = "\uD83C\uDDEE\uD83C\uDDF3", + [":flag_io:"] = "\uD83C\uDDEE\uD83C\uDDF4", + [":flag_iq:"] = "\uD83C\uDDEE\uD83C\uDDF6", + [":flag_ir:"] = "\uD83C\uDDEE\uD83C\uDDF7", + [":flag_is:"] = "\uD83C\uDDEE\uD83C\uDDF8", + [":flag_it:"] = "\uD83C\uDDEE\uD83C\uDDF9", + [":flag_je:"] = "\uD83C\uDDEF\uD83C\uDDEA", + [":flag_jm:"] = "\uD83C\uDDEF\uD83C\uDDF2", + [":flag_jo:"] = "\uD83C\uDDEF\uD83C\uDDF4", + [":flag_jp:"] = "\uD83C\uDDEF\uD83C\uDDF5", + [":flag_ke:"] = "\uD83C\uDDF0\uD83C\uDDEA", + [":flag_kg:"] = "\uD83C\uDDF0\uD83C\uDDEC", + [":flag_kh:"] = "\uD83C\uDDF0\uD83C\uDDED", + [":flag_ki:"] = "\uD83C\uDDF0\uD83C\uDDEE", + [":flag_km:"] = "\uD83C\uDDF0\uD83C\uDDF2", + [":flag_kn:"] = "\uD83C\uDDF0\uD83C\uDDF3", + [":flag_kp:"] = "\uD83C\uDDF0\uD83C\uDDF5", + [":flag_kr:"] = "\uD83C\uDDF0\uD83C\uDDF7", + [":flag_kw:"] = "\uD83C\uDDF0\uD83C\uDDFC", + [":flag_ky:"] = "\uD83C\uDDF0\uD83C\uDDFE", + [":flag_kz:"] = "\uD83C\uDDF0\uD83C\uDDFF", + [":flag_la:"] = "\uD83C\uDDF1\uD83C\uDDE6", + [":flag_lb:"] = "\uD83C\uDDF1\uD83C\uDDE7", + [":flag_lc:"] = "\uD83C\uDDF1\uD83C\uDDE8", + [":flag_li:"] = "\uD83C\uDDF1\uD83C\uDDEE", + [":flag_lk:"] = "\uD83C\uDDF1\uD83C\uDDF0", + [":flag_lr:"] = "\uD83C\uDDF1\uD83C\uDDF7", + [":flag_ls:"] = "\uD83C\uDDF1\uD83C\uDDF8", + [":flag_lt:"] = "\uD83C\uDDF1\uD83C\uDDF9", + [":flag_lu:"] = "\uD83C\uDDF1\uD83C\uDDFA", + [":flag_lv:"] = "\uD83C\uDDF1\uD83C\uDDFB", + [":flag_ly:"] = "\uD83C\uDDF1\uD83C\uDDFE", + [":flag_ma:"] = "\uD83C\uDDF2\uD83C\uDDE6", + [":flag_mc:"] = "\uD83C\uDDF2\uD83C\uDDE8", + [":flag_md:"] = "\uD83C\uDDF2\uD83C\uDDE9", + [":flag_me:"] = "\uD83C\uDDF2\uD83C\uDDEA", + [":flag_mf:"] = "\uD83C\uDDF2\uD83C\uDDEB", + [":flag_mg:"] = "\uD83C\uDDF2\uD83C\uDDEC", + [":flag_mh:"] = "\uD83C\uDDF2\uD83C\uDDED", + [":flag_mk:"] = "\uD83C\uDDF2\uD83C\uDDF0", + [":flag_ml:"] = "\uD83C\uDDF2\uD83C\uDDF1", + [":flag_mm:"] = "\uD83C\uDDF2\uD83C\uDDF2", + [":flag_mn:"] = "\uD83C\uDDF2\uD83C\uDDF3", + [":flag_mo:"] = "\uD83C\uDDF2\uD83C\uDDF4", + [":flag_mp:"] = "\uD83C\uDDF2\uD83C\uDDF5", + [":flag_mq:"] = "\uD83C\uDDF2\uD83C\uDDF6", + [":flag_mr:"] = "\uD83C\uDDF2\uD83C\uDDF7", + [":flag_ms:"] = "\uD83C\uDDF2\uD83C\uDDF8", + [":flag_mt:"] = "\uD83C\uDDF2\uD83C\uDDF9", + [":flag_mu:"] = "\uD83C\uDDF2\uD83C\uDDFA", + [":flag_mv:"] = "\uD83C\uDDF2\uD83C\uDDFB", + [":flag_mw:"] = "\uD83C\uDDF2\uD83C\uDDFC", + [":flag_mx:"] = "\uD83C\uDDF2\uD83C\uDDFD", + [":flag_my:"] = "\uD83C\uDDF2\uD83C\uDDFE", + [":flag_mz:"] = "\uD83C\uDDF2\uD83C\uDDFF", + [":flag_na:"] = "\uD83C\uDDF3\uD83C\uDDE6", + [":flag_nc:"] = "\uD83C\uDDF3\uD83C\uDDE8", + [":flag_ne:"] = "\uD83C\uDDF3\uD83C\uDDEA", + [":flag_nf:"] = "\uD83C\uDDF3\uD83C\uDDEB", + [":flag_ng:"] = "\uD83C\uDDF3\uD83C\uDDEC", + [":flag_ni:"] = "\uD83C\uDDF3\uD83C\uDDEE", + [":flag_nl:"] = "\uD83C\uDDF3\uD83C\uDDF1", + [":flag_no:"] = "\uD83C\uDDF3\uD83C\uDDF4", + [":flag_np:"] = "\uD83C\uDDF3\uD83C\uDDF5", + [":flag_nr:"] = "\uD83C\uDDF3\uD83C\uDDF7", + [":flag_nu:"] = "\uD83C\uDDF3\uD83C\uDDFA", + [":flag_nz:"] = "\uD83C\uDDF3\uD83C\uDDFF", + [":flag_om:"] = "\uD83C\uDDF4\uD83C\uDDF2", + [":flag_pa:"] = "\uD83C\uDDF5\uD83C\uDDE6", + [":flag_pe:"] = "\uD83C\uDDF5\uD83C\uDDEA", + [":flag_pf:"] = "\uD83C\uDDF5\uD83C\uDDEB", + [":flag_pg:"] = "\uD83C\uDDF5\uD83C\uDDEC", + [":flag_ph:"] = "\uD83C\uDDF5\uD83C\uDDED", + [":flag_pk:"] = "\uD83C\uDDF5\uD83C\uDDF0", + [":flag_pl:"] = "\uD83C\uDDF5\uD83C\uDDF1", + [":flag_pm:"] = "\uD83C\uDDF5\uD83C\uDDF2", + [":flag_pn:"] = "\uD83C\uDDF5\uD83C\uDDF3", + [":flag_pr:"] = "\uD83C\uDDF5\uD83C\uDDF7", + [":flag_ps:"] = "\uD83C\uDDF5\uD83C\uDDF8", + [":flag_pt:"] = "\uD83C\uDDF5\uD83C\uDDF9", + [":flag_pw:"] = "\uD83C\uDDF5\uD83C\uDDFC", + [":flag_py:"] = "\uD83C\uDDF5\uD83C\uDDFE", + [":flag_qa:"] = "\uD83C\uDDF6\uD83C\uDDE6", + [":flag_re:"] = "\uD83C\uDDF7\uD83C\uDDEA", + [":flag_ro:"] = "\uD83C\uDDF7\uD83C\uDDF4", + [":flag_rs:"] = "\uD83C\uDDF7\uD83C\uDDF8", + [":flag_ru:"] = "\uD83C\uDDF7\uD83C\uDDFA", + [":flag_rw:"] = "\uD83C\uDDF7\uD83C\uDDFC", + [":flag_sa:"] = "\uD83C\uDDF8\uD83C\uDDE6", + [":flag_sb:"] = "\uD83C\uDDF8\uD83C\uDDE7", + [":flag_sc:"] = "\uD83C\uDDF8\uD83C\uDDE8", + [":flag_sd:"] = "\uD83C\uDDF8\uD83C\uDDE9", + [":flag_se:"] = "\uD83C\uDDF8\uD83C\uDDEA", + [":flag_sg:"] = "\uD83C\uDDF8\uD83C\uDDEC", + [":flag_sh:"] = "\uD83C\uDDF8\uD83C\uDDED", + [":flag_si:"] = "\uD83C\uDDF8\uD83C\uDDEE", + [":flag_sj:"] = "\uD83C\uDDF8\uD83C\uDDEF", + [":flag_sk:"] = "\uD83C\uDDF8\uD83C\uDDF0", + [":flag_sl:"] = "\uD83C\uDDF8\uD83C\uDDF1", + [":flag_sm:"] = "\uD83C\uDDF8\uD83C\uDDF2", + [":flag_sn:"] = "\uD83C\uDDF8\uD83C\uDDF3", + [":flag_so:"] = "\uD83C\uDDF8\uD83C\uDDF4", + [":flag_sr:"] = "\uD83C\uDDF8\uD83C\uDDF7", + [":flag_ss:"] = "\uD83C\uDDF8\uD83C\uDDF8", + [":flag_st:"] = "\uD83C\uDDF8\uD83C\uDDF9", + [":flag_sv:"] = "\uD83C\uDDF8\uD83C\uDDFB", + [":flag_sx:"] = "\uD83C\uDDF8\uD83C\uDDFD", + [":flag_sy:"] = "\uD83C\uDDF8\uD83C\uDDFE", + [":flag_sz:"] = "\uD83C\uDDF8\uD83C\uDDFF", + [":flag_ta:"] = "\uD83C\uDDF9\uD83C\uDDE6", + [":flag_tc:"] = "\uD83C\uDDF9\uD83C\uDDE8", + [":flag_td:"] = "\uD83C\uDDF9\uD83C\uDDE9", + [":flag_tf:"] = "\uD83C\uDDF9\uD83C\uDDEB", + [":flag_tg:"] = "\uD83C\uDDF9\uD83C\uDDEC", + [":flag_th:"] = "\uD83C\uDDF9\uD83C\uDDED", + [":flag_tj:"] = "\uD83C\uDDF9\uD83C\uDDEF", + [":flag_tk:"] = "\uD83C\uDDF9\uD83C\uDDF0", + [":flag_tl:"] = "\uD83C\uDDF9\uD83C\uDDF1", + [":flag_tm:"] = "\uD83C\uDDF9\uD83C\uDDF2", + [":flag_tn:"] = "\uD83C\uDDF9\uD83C\uDDF3", + [":flag_to:"] = "\uD83C\uDDF9\uD83C\uDDF4", + [":flag_tr:"] = "\uD83C\uDDF9\uD83C\uDDF7", + [":flag_tt:"] = "\uD83C\uDDF9\uD83C\uDDF9", + [":flag_tv:"] = "\uD83C\uDDF9\uD83C\uDDFB", + [":flag_tw:"] = "\uD83C\uDDF9\uD83C\uDDFC", + [":flag_tz:"] = "\uD83C\uDDF9\uD83C\uDDFF", + [":flag_ua:"] = "\uD83C\uDDFA\uD83C\uDDE6", + [":flag_ug:"] = "\uD83C\uDDFA\uD83C\uDDEC", + [":flag_um:"] = "\uD83C\uDDFA\uD83C\uDDF2", + [":flag_us:"] = "\uD83C\uDDFA\uD83C\uDDF8", + [":flag_uy:"] = "\uD83C\uDDFA\uD83C\uDDFE", + [":flag_uz:"] = "\uD83C\uDDFA\uD83C\uDDFF", + [":flag_va:"] = "\uD83C\uDDFB\uD83C\uDDE6", + [":flag_vc:"] = "\uD83C\uDDFB\uD83C\uDDE8", + [":flag_ve:"] = "\uD83C\uDDFB\uD83C\uDDEA", + [":flag_vg:"] = "\uD83C\uDDFB\uD83C\uDDEC", + [":flag_vi:"] = "\uD83C\uDDFB\uD83C\uDDEE", + [":flag_vn:"] = "\uD83C\uDDFB\uD83C\uDDF3", + [":flag_vu:"] = "\uD83C\uDDFB\uD83C\uDDFA", + [":flag_wf:"] = "\uD83C\uDDFC\uD83C\uDDEB", + [":flag_white:"] = "\uD83C\uDFF3️", + [":flag_ws:"] = "\uD83C\uDDFC\uD83C\uDDF8", + [":flag_xk:"] = "\uD83C\uDDFD\uD83C\uDDF0", + [":flag_ye:"] = "\uD83C\uDDFE\uD83C\uDDEA", + [":flag_yt:"] = "\uD83C\uDDFE\uD83C\uDDF9", + [":flag_za:"] = "\uD83C\uDDFF\uD83C\uDDE6", + [":flag_zm:"] = "\uD83C\uDDFF\uD83C\uDDF2", + [":flag_zw:"] = "\uD83C\uDDFF\uD83C\uDDFC", + [":flags:"] = "\uD83C\uDF8F", + [":flame:"] = "\uD83D\uDD25", + [":flamingo:"] = "\uD83E\uDDA9", + [":flan:"] = "\uD83C\uDF6E", + [":flashlight:"] = "\uD83D\uDD26", + [":flatbread:"] = "\uD83E\uDED3", + [":fleur_de_lis:"] = "⚜️", + [":floppy_disk:"] = "\uD83D\uDCBE", + [":flower_playing_cards:"] = "\uD83C\uDFB4", + [":flushed:"] = "\uD83D\uDE33", + [":fly:"] = "\uD83E\uDEB0", + [":flying_disc:"] = "\uD83E\uDD4F", + [":flying_saucer:"] = "\uD83D\uDEF8", + [":fog:"] = "\uD83C\uDF2B️", + [":foggy:"] = "\uD83C\uDF01", + [":fondue:"] = "\uD83E\uDED5", + [":foot:"] = "\uD83E\uDDB6", + [":foot::skin-tone-1:"] = "\uD83E\uDDB6\uD83C\uDFFB", + [":foot::skin-tone-2:"] = "\uD83E\uDDB6\uD83C\uDFFC", + [":foot::skin-tone-3:"] = "\uD83E\uDDB6\uD83C\uDFFD", + [":foot::skin-tone-4:"] = "\uD83E\uDDB6\uD83C\uDFFE", + [":foot::skin-tone-5:"] = "\uD83E\uDDB6\uD83C\uDFFF", + [":foot_dark_skin_tone:"] = "\uD83E\uDDB6\uD83C\uDFFF", + [":foot_light_skin_tone:"] = "\uD83E\uDDB6\uD83C\uDFFB", + [":foot_medium_dark_skin_tone:"] = "\uD83E\uDDB6\uD83C\uDFFE", + [":foot_medium_light_skin_tone:"] = "\uD83E\uDDB6\uD83C\uDFFC", + [":foot_medium_skin_tone:"] = "\uD83E\uDDB6\uD83C\uDFFD", + [":foot_tone1:"] = "\uD83E\uDDB6\uD83C\uDFFB", + [":foot_tone2:"] = "\uD83E\uDDB6\uD83C\uDFFC", + [":foot_tone3:"] = "\uD83E\uDDB6\uD83C\uDFFD", + [":foot_tone4:"] = "\uD83E\uDDB6\uD83C\uDFFE", + [":foot_tone5:"] = "\uD83E\uDDB6\uD83C\uDFFF", + [":football:"] = "\uD83C\uDFC8", + [":footprints:"] = "\uD83D\uDC63", + [":fork_and_knife:"] = "\uD83C\uDF74", + [":fork_and_knife_with_plate:"] = "\uD83C\uDF7D️", + [":fork_knife_plate:"] = "\uD83C\uDF7D️", + [":fortune_cookie:"] = "\uD83E\uDD60", + [":fountain:"] = "⛲", + [":four:"] = "4️⃣", + [":four_leaf_clover:"] = "\uD83C\uDF40", + [":fox:"] = "\uD83E\uDD8A", + [":fox_face:"] = "\uD83E\uDD8A", + [":frame_photo:"] = "\uD83D\uDDBC️", + [":frame_with_picture:"] = "\uD83D\uDDBC️", + [":free:"] = "\uD83C\uDD93", + [":french_bread:"] = "\uD83E\uDD56", + [":fried_shrimp:"] = "\uD83C\uDF64", + [":fries:"] = "\uD83C\uDF5F", + [":frog:"] = "\uD83D\uDC38", + [":frowning2:"] = "☹️", + [":frowning:"] = "\uD83D\uDE26", + [":fuelpump:"] = "⛽", + [":full_moon:"] = "\uD83C\uDF15", + [":full_moon_with_face:"] = "\uD83C\uDF1D", + [":funeral_urn:"] = "⚱️", + [":game_die:"] = "\uD83C\uDFB2", + [":garlic:"] = "\uD83E\uDDC4", + [":gay_pride_flag:"] = "\uD83C\uDFF3️\u200D\uD83C\uDF08", + [":gear:"] = "⚙️", + [":gem:"] = "\uD83D\uDC8E", + [":gemini:"] = "♊", + [":genie:"] = "\uD83E\uDDDE", + [":ghost:"] = "\uD83D\uDC7B", + [":gift:"] = "\uD83C\uDF81", + [":gift_heart:"] = "\uD83D\uDC9D", + [":giraffe:"] = "\uD83E\uDD92", + [":girl:"] = "\uD83D\uDC67", + [":girl::skin-tone-1:"] = "\uD83D\uDC67\uD83C\uDFFB", + [":girl::skin-tone-2:"] = "\uD83D\uDC67\uD83C\uDFFC", + [":girl::skin-tone-3:"] = "\uD83D\uDC67\uD83C\uDFFD", + [":girl::skin-tone-4:"] = "\uD83D\uDC67\uD83C\uDFFE", + [":girl::skin-tone-5:"] = "\uD83D\uDC67\uD83C\uDFFF", + [":girl_tone1:"] = "\uD83D\uDC67\uD83C\uDFFB", + [":girl_tone2:"] = "\uD83D\uDC67\uD83C\uDFFC", + [":girl_tone3:"] = "\uD83D\uDC67\uD83C\uDFFD", + [":girl_tone4:"] = "\uD83D\uDC67\uD83C\uDFFE", + [":girl_tone5:"] = "\uD83D\uDC67\uD83C\uDFFF", + [":glass_of_milk:"] = "\uD83E\uDD5B", + [":globe_with_meridians:"] = "\uD83C\uDF10", + [":gloves:"] = "\uD83E\uDDE4", + [":goal:"] = "\uD83E\uDD45", + [":goal_net:"] = "\uD83E\uDD45", + [":goat:"] = "\uD83D\uDC10", + [":goggles:"] = "\uD83E\uDD7D", + [":golf:"] = "⛳", + [":golfer:"] = "\uD83C\uDFCC️", + [":golfer::skin-tone-1:"] = "\uD83C\uDFCC\uD83C\uDFFB", + [":golfer::skin-tone-2:"] = "\uD83C\uDFCC\uD83C\uDFFC", + [":golfer::skin-tone-3:"] = "\uD83C\uDFCC\uD83C\uDFFD", + [":golfer::skin-tone-4:"] = "\uD83C\uDFCC\uD83C\uDFFE", + [":golfer::skin-tone-5:"] = "\uD83C\uDFCC\uD83C\uDFFF", + [":gorilla:"] = "\uD83E\uDD8D", + [":grandma:"] = "\uD83D\uDC75", + [":grandma::skin-tone-1:"] = "\uD83D\uDC75\uD83C\uDFFB", + [":grandma::skin-tone-2:"] = "\uD83D\uDC75\uD83C\uDFFC", + [":grandma::skin-tone-3:"] = "\uD83D\uDC75\uD83C\uDFFD", + [":grandma::skin-tone-4:"] = "\uD83D\uDC75\uD83C\uDFFE", + [":grandma::skin-tone-5:"] = "\uD83D\uDC75\uD83C\uDFFF", + [":grandma_tone1:"] = "\uD83D\uDC75\uD83C\uDFFB", + [":grandma_tone2:"] = "\uD83D\uDC75\uD83C\uDFFC", + [":grandma_tone3:"] = "\uD83D\uDC75\uD83C\uDFFD", + [":grandma_tone4:"] = "\uD83D\uDC75\uD83C\uDFFE", + [":grandma_tone5:"] = "\uD83D\uDC75\uD83C\uDFFF", + [":grapes:"] = "\uD83C\uDF47", + [":green_apple:"] = "\uD83C\uDF4F", + [":green_book:"] = "\uD83D\uDCD7", + [":green_circle:"] = "\uD83D\uDFE2", + [":green_heart:"] = "\uD83D\uDC9A", + [":green_salad:"] = "\uD83E\uDD57", + [":green_square:"] = "\uD83D\uDFE9", + [":grey_exclamation:"] = "❕", + [":grey_question:"] = "❔", + [":grimacing:"] = "\uD83D\uDE2C", + [":grin:"] = "\uD83D\uDE01", + [":grinning:"] = "\uD83D\uDE00", + [":guard:"] = "\uD83D\uDC82", + [":guard::skin-tone-1:"] = "\uD83D\uDC82\uD83C\uDFFB", + [":guard::skin-tone-2:"] = "\uD83D\uDC82\uD83C\uDFFC", + [":guard::skin-tone-3:"] = "\uD83D\uDC82\uD83C\uDFFD", + [":guard::skin-tone-4:"] = "\uD83D\uDC82\uD83C\uDFFE", + [":guard::skin-tone-5:"] = "\uD83D\uDC82\uD83C\uDFFF", + [":guard_tone1:"] = "\uD83D\uDC82\uD83C\uDFFB", + [":guard_tone2:"] = "\uD83D\uDC82\uD83C\uDFFC", + [":guard_tone3:"] = "\uD83D\uDC82\uD83C\uDFFD", + [":guard_tone4:"] = "\uD83D\uDC82\uD83C\uDFFE", + [":guard_tone5:"] = "\uD83D\uDC82\uD83C\uDFFF", + [":guardsman:"] = "\uD83D\uDC82", + [":guardsman::skin-tone-1:"] = "\uD83D\uDC82\uD83C\uDFFB", + [":guardsman::skin-tone-2:"] = "\uD83D\uDC82\uD83C\uDFFC", + [":guardsman::skin-tone-3:"] = "\uD83D\uDC82\uD83C\uDFFD", + [":guardsman::skin-tone-4:"] = "\uD83D\uDC82\uD83C\uDFFE", + [":guardsman::skin-tone-5:"] = "\uD83D\uDC82\uD83C\uDFFF", + [":guardsman_tone1:"] = "\uD83D\uDC82\uD83C\uDFFB", + [":guardsman_tone2:"] = "\uD83D\uDC82\uD83C\uDFFC", + [":guardsman_tone3:"] = "\uD83D\uDC82\uD83C\uDFFD", + [":guardsman_tone4:"] = "\uD83D\uDC82\uD83C\uDFFE", + [":guardsman_tone5:"] = "\uD83D\uDC82\uD83C\uDFFF", + [":guide_dog:"] = "\uD83E\uDDAE", + [":guitar:"] = "\uD83C\uDFB8", + [":gun:"] = "\uD83D\uDD2B", + [":haircut:"] = "\uD83D\uDC87", + [":haircut::skin-tone-1:"] = "\uD83D\uDC87\uD83C\uDFFB", + [":haircut::skin-tone-2:"] = "\uD83D\uDC87\uD83C\uDFFC", + [":haircut::skin-tone-3:"] = "\uD83D\uDC87\uD83C\uDFFD", + [":haircut::skin-tone-4:"] = "\uD83D\uDC87\uD83C\uDFFE", + [":haircut::skin-tone-5:"] = "\uD83D\uDC87\uD83C\uDFFF", + [":haircut_tone1:"] = "\uD83D\uDC87\uD83C\uDFFB", + [":haircut_tone2:"] = "\uD83D\uDC87\uD83C\uDFFC", + [":haircut_tone3:"] = "\uD83D\uDC87\uD83C\uDFFD", + [":haircut_tone4:"] = "\uD83D\uDC87\uD83C\uDFFE", + [":haircut_tone5:"] = "\uD83D\uDC87\uD83C\uDFFF", + [":hamburger:"] = "\uD83C\uDF54", + [":hammer:"] = "\uD83D\uDD28", + [":hammer_and_pick:"] = "⚒️", + [":hammer_and_wrench:"] = "\uD83D\uDEE0️", + [":hammer_pick:"] = "⚒️", + [":hamsa:"] = "\uD83E\uDEAC", + [":hamster:"] = "\uD83D\uDC39", + [":hand_splayed:"] = "\uD83D\uDD90️", + [":hand_splayed::skin-tone-1:"] = "\uD83D\uDD90\uD83C\uDFFB", + [":hand_splayed::skin-tone-2:"] = "\uD83D\uDD90\uD83C\uDFFC", + [":hand_splayed::skin-tone-3:"] = "\uD83D\uDD90\uD83C\uDFFD", + [":hand_splayed::skin-tone-4:"] = "\uD83D\uDD90\uD83C\uDFFE", + [":hand_splayed::skin-tone-5:"] = "\uD83D\uDD90\uD83C\uDFFF", + [":hand_splayed_tone1:"] = "\uD83D\uDD90\uD83C\uDFFB", + [":hand_splayed_tone2:"] = "\uD83D\uDD90\uD83C\uDFFC", + [":hand_splayed_tone3:"] = "\uD83D\uDD90\uD83C\uDFFD", + [":hand_splayed_tone4:"] = "\uD83D\uDD90\uD83C\uDFFE", + [":hand_splayed_tone5:"] = "\uD83D\uDD90\uD83C\uDFFF", + [":hand_with_index_and_middle_finger_crossed:"] = "\uD83E\uDD1E", + [":hand_with_index_and_middle_finger_crossed::skin-tone-1:"] = "\uD83E\uDD1E\uD83C\uDFFB", + [":hand_with_index_and_middle_finger_crossed::skin-tone-2:"] = "\uD83E\uDD1E\uD83C\uDFFC", + [":hand_with_index_and_middle_finger_crossed::skin-tone-3:"] = "\uD83E\uDD1E\uD83C\uDFFD", + [":hand_with_index_and_middle_finger_crossed::skin-tone-4:"] = "\uD83E\uDD1E\uD83C\uDFFE", + [":hand_with_index_and_middle_finger_crossed::skin-tone-5:"] = "\uD83E\uDD1E\uD83C\uDFFF", + [":hand_with_index_and_middle_fingers_crossed_tone1:"] = "\uD83E\uDD1E\uD83C\uDFFB", + [":hand_with_index_and_middle_fingers_crossed_tone2:"] = "\uD83E\uDD1E\uD83C\uDFFC", + [":hand_with_index_and_middle_fingers_crossed_tone3:"] = "\uD83E\uDD1E\uD83C\uDFFD", + [":hand_with_index_and_middle_fingers_crossed_tone4:"] = "\uD83E\uDD1E\uD83C\uDFFE", + [":hand_with_index_and_middle_fingers_crossed_tone5:"] = "\uD83E\uDD1E\uD83C\uDFFF", + [":hand_with_index_finger_and_thumb_crossed:"] = "\uD83E\uDEF4", + [":hand_with_index_finger_and_thumb_crossed::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hand_with_index_finger_and_thumb_crossed_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handbag:"] = "\uD83D\uDC5C", + [":handball:"] = "\uD83E\uDD3E", + [":handball::skin-tone-1:"] = "\uD83E\uDD3E\uD83C\uDFFB", + [":handball::skin-tone-2:"] = "\uD83E\uDD3E\uD83C\uDFFC", + [":handball::skin-tone-3:"] = "\uD83E\uDD3E\uD83C\uDFFD", + [":handball::skin-tone-4:"] = "\uD83E\uDD3E\uD83C\uDFFE", + [":handball::skin-tone-5:"] = "\uD83E\uDD3E\uD83C\uDFFF", + [":handball_tone1:"] = "\uD83E\uDD3E\uD83C\uDFFB", + [":handball_tone2:"] = "\uD83E\uDD3E\uD83C\uDFFC", + [":handball_tone3:"] = "\uD83E\uDD3E\uD83C\uDFFD", + [":handball_tone4:"] = "\uD83E\uDD3E\uD83C\uDFFE", + [":handball_tone5:"] = "\uD83E\uDD3E\uD83C\uDFFF", + [":handshake:"] = "\uD83E\uDD1D", + [":handshake::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_dark_skin_tone_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_light_skin_tone_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_dark_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_light_skin_tone_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_light_skin_tone_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_light_skin_tone_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_medium_skin_tone_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":handshake_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":hankey:"] = "\uD83D\uDCA9", + [":hash:"] = "#️⃣", + [":hatched_chick:"] = "\uD83D\uDC25", + [":hatching_chick:"] = "\uD83D\uDC23", + [":head_bandage:"] = "\uD83E\uDD15", + [":headphones:"] = "\uD83C\uDFA7", + [":headstone:"] = "\uD83E\uDEA6", + [":hear_no_evil:"] = "\uD83D\uDE49", + [":heart:"] = "❤️", + [":heart_decoration:"] = "\uD83D\uDC9F", + [":heart_exclamation:"] = "❣️", + [":heart_eyes:"] = "\uD83D\uDE0D", + [":heart_eyes_cat:"] = "\uD83D\uDE3B", + [":heart_hands:"] = "\uD83E\uDEF6", + [":heart_hands::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_hands_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":heart_on_fire:"] = "\u2764\uFE0F\u200D\uD83D\uDD25", + [":heartbeat:"] = "\uD83D\uDC93", + [":heartpulse:"] = "\uD83D\uDC97", + [":hearts:"] = "♥️", + [":heavy_check_mark:"] = "✔️", + [":heavy_division_sign:"] = "➗", + [":heavy_dollar_sign:"] = "\uD83D\uDCB2", + [":heavy_equals_sign:"] = "\uD83D\uDFF0", + [":heavy_heart_exclamation_mark_ornament:"] = "❣️", + [":heavy_minus_sign:"] = "➖", + [":heavy_multiplication_x:"] = "✖️", + [":heavy_plus_sign:"] = "➕", + [":hedgehog:"] = "\uD83E\uDD94", + [":helicopter:"] = "\uD83D\uDE81", + [":helmet_with_cross:"] = "⛑️", + [":helmet_with_white_cross:"] = "⛑️", + [":herb:"] = "\uD83C\uDF3F", + [":hibiscus:"] = "\uD83C\uDF3A", + [":high_brightness:"] = "\uD83D\uDD06", + [":high_heel:"] = "\uD83D\uDC60", + [":hiking_boot:"] = "\uD83E\uDD7E", + [":hindu_temple:"] = "\uD83D\uDED5", + [":hippopotamus:"] = "\uD83E\uDD9B", + [":hockey:"] = "\uD83C\uDFD2", + [":hole:"] = "\uD83D\uDD73️", + [":homes:"] = "\uD83C\uDFD8️", + [":honey_pot:"] = "\uD83C\uDF6F", + [":hook:"] = "\uD83E\uDE9D", + [":horse:"] = "\uD83D\uDC34", + [":horse_racing:"] = "\uD83C\uDFC7", + [":horse_racing::skin-tone-1:"] = "\uD83C\uDFC7\uD83C\uDFFB", + [":horse_racing::skin-tone-2:"] = "\uD83C\uDFC7\uD83C\uDFFC", + [":horse_racing::skin-tone-3:"] = "\uD83C\uDFC7\uD83C\uDFFD", + [":horse_racing::skin-tone-4:"] = "\uD83C\uDFC7\uD83C\uDFFE", + [":horse_racing::skin-tone-5:"] = "\uD83C\uDFC7\uD83C\uDFFF", + [":horse_racing_tone1:"] = "\uD83C\uDFC7\uD83C\uDFFB", + [":horse_racing_tone2:"] = "\uD83C\uDFC7\uD83C\uDFFC", + [":horse_racing_tone3:"] = "\uD83C\uDFC7\uD83C\uDFFD", + [":horse_racing_tone4:"] = "\uD83C\uDFC7\uD83C\uDFFE", + [":horse_racing_tone5:"] = "\uD83C\uDFC7\uD83C\uDFFF", + [":hospital:"] = "\uD83C\uDFE5", + [":hot_dog:"] = "\uD83C\uDF2D", + [":hot_face:"] = "\uD83E\uDD75", + [":hot_pepper:"] = "\uD83C\uDF36️", + [":hotdog:"] = "\uD83C\uDF2D", + [":hotel:"] = "\uD83C\uDFE8", + [":hotsprings:"] = "♨️", + [":hourglass:"] = "⌛", + [":hourglass_flowing_sand:"] = "⏳", + [":house:"] = "\uD83C\uDFE0", + [":house_abandoned:"] = "\uD83C\uDFDA️", + [":house_buildings:"] = "\uD83C\uDFD8️", + [":house_with_garden:"] = "\uD83C\uDFE1", + [":hugging:"] = "\uD83E\uDD17", + [":hugging_face:"] = "\uD83E\uDD17", + [":hushed:"] = "\uD83D\uDE2F", + [":hut:"] = "\uD83D\uDED6", + [":ice_cream:"] = "\uD83C\uDF68", + [":ice_cube:"] = "\uD83E\uDDCA", + [":ice_skate:"] = "⛸️", + [":icecream:"] = "\uD83C\uDF66", + [":id:"] = "\uD83C\uDD94", + [":identification_card:"] = "\uD83E\uDEAA", + [":ideograph_advantage:"] = "\uD83C\uDE50", + [":imp:"] = "\uD83D\uDC7F", + [":inbox_tray:"] = "\uD83D\uDCE5", + [":incoming_envelope:"] = "\uD83D\uDCE8", + [":index_pointing_at_the_viewer:"] = "\uD83E\uDEF4", + [":index_pointing_at_the_viewer::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":index_pointing_at_the_viewer_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":infinity:"] = "♾️", + [":information_desk_person:"] = "\uD83D\uDC81", + [":information_desk_person::skin-tone-1:"] = "\uD83D\uDC81\uD83C\uDFFB", + [":information_desk_person::skin-tone-2:"] = "\uD83D\uDC81\uD83C\uDFFC", + [":information_desk_person::skin-tone-3:"] = "\uD83D\uDC81\uD83C\uDFFD", + [":information_desk_person::skin-tone-4:"] = "\uD83D\uDC81\uD83C\uDFFE", + [":information_desk_person::skin-tone-5:"] = "\uD83D\uDC81\uD83C\uDFFF", + [":information_desk_person_tone1:"] = "\uD83D\uDC81\uD83C\uDFFB", + [":information_desk_person_tone2:"] = "\uD83D\uDC81\uD83C\uDFFC", + [":information_desk_person_tone3:"] = "\uD83D\uDC81\uD83C\uDFFD", + [":information_desk_person_tone4:"] = "\uD83D\uDC81\uD83C\uDFFE", + [":information_desk_person_tone5:"] = "\uD83D\uDC81\uD83C\uDFFF", + [":information_source:"] = "ℹ️", + [":innocent:"] = "\uD83D\uDE07", + [":interrobang:"] = "⁉️", + [":iphone:"] = "\uD83D\uDCF1", + [":island:"] = "\uD83C\uDFDD️", + [":izakaya_lantern:"] = "\uD83C\uDFEE", + [":jack_o_lantern:"] = "\uD83C\uDF83", + [":japan:"] = "\uD83D\uDDFE", + [":japanese_castle:"] = "\uD83C\uDFEF", + [":japanese_goblin:"] = "\uD83D\uDC7A", + [":japanese_ogre:"] = "\uD83D\uDC79", + [":jar:"] = "\uD83E\uDED9", + [":jeans:"] = "\uD83D\uDC56", + [":jigsaw:"] = "\uD83E\uDDE9", + [":joy:"] = "\uD83D\uDE02", + [":joy_cat:"] = "\uD83D\uDE39", + [":joystick:"] = "\uD83D\uDD79️", + [":juggler:"] = "\uD83E\uDD39", + [":juggler::skin-tone-1:"] = "\uD83E\uDD39\uD83C\uDFFB", + [":juggler::skin-tone-2:"] = "\uD83E\uDD39\uD83C\uDFFC", + [":juggler::skin-tone-3:"] = "\uD83E\uDD39\uD83C\uDFFD", + [":juggler::skin-tone-4:"] = "\uD83E\uDD39\uD83C\uDFFE", + [":juggler::skin-tone-5:"] = "\uD83E\uDD39\uD83C\uDFFF", + [":juggler_tone1:"] = "\uD83E\uDD39\uD83C\uDFFB", + [":juggler_tone2:"] = "\uD83E\uDD39\uD83C\uDFFC", + [":juggler_tone3:"] = "\uD83E\uDD39\uD83C\uDFFD", + [":juggler_tone4:"] = "\uD83E\uDD39\uD83C\uDFFE", + [":juggler_tone5:"] = "\uD83E\uDD39\uD83C\uDFFF", + [":juggling:"] = "\uD83E\uDD39", + [":juggling::skin-tone-1:"] = "\uD83E\uDD39\uD83C\uDFFB", + [":juggling::skin-tone-2:"] = "\uD83E\uDD39\uD83C\uDFFC", + [":juggling::skin-tone-3:"] = "\uD83E\uDD39\uD83C\uDFFD", + [":juggling::skin-tone-4:"] = "\uD83E\uDD39\uD83C\uDFFE", + [":juggling::skin-tone-5:"] = "\uD83E\uDD39\uD83C\uDFFF", + [":juggling_tone1:"] = "\uD83E\uDD39\uD83C\uDFFB", + [":juggling_tone2:"] = "\uD83E\uDD39\uD83C\uDFFC", + [":juggling_tone3:"] = "\uD83E\uDD39\uD83C\uDFFD", + [":juggling_tone4:"] = "\uD83E\uDD39\uD83C\uDFFE", + [":juggling_tone5:"] = "\uD83E\uDD39\uD83C\uDFFF", + [":kaaba:"] = "\uD83D\uDD4B", + [":kangaroo:"] = "\uD83E\uDD98", + [":karate_uniform:"] = "\uD83E\uDD4B", + [":kayak:"] = "\uD83D\uDEF6", + [":key2:"] = "\uD83D\uDDDD️", + [":key:"] = "\uD83D\uDD11", + [":keyboard:"] = "⌨️", + [":keycap_asterisk:"] = "*️⃣", + [":keycap_ten:"] = "\uD83D\uDD1F", + [":kimono:"] = "\uD83D\uDC58", + [":kiss:"] = "\uD83D\uDC8B", + [":kiss::skin-tone-1:"] = "\uD83D\uDC8F\uD83C\uDFFB", + [":kiss::skin-tone-2:"] = "\uD83D\uDC8F\uD83C\uDFFC", + [":kiss::skin-tone-3:"] = "\uD83D\uDC8F\uD83C\uDFFD", + [":kiss::skin-tone-4:"] = "\uD83D\uDC8F\uD83C\uDFFE", + [":kiss::skin-tone-5:"] = "\uD83D\uDC8F\uD83C\uDFFF", + [":kiss_dark_skin_tone:"] = "\uD83D\uDC8F\uD83C\uDFFF", + [":kiss_light_skin_tone:"] = "\uD83D\uDC8F\uD83C\uDFFB", + [":kiss_man_man::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_dark_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_dark_skin_tone_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone_tone3:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_dark_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_dark_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_light_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_light_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_light_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_light_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_light_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_medium_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_medium_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_medium_light_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_medium_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_skin_tone_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_man_man_medium_skin_tone_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_medium_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_medium_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_skin_tone_tone1:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_medium_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_medium_skin_tone_tone2:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_medium_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_medium_skin_tone_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_man_man_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_man_man_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_man_man_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_man_man_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_medium_dark_skin_tone:"] = "\uD83D\uDC8F\uD83C\uDFFE", + [":kiss_medium_light_skin_tone:"] = "\uD83D\uDC8F\uD83C\uDFFC", + [":kiss_medium_skin_tone:"] = "\uD83D\uDC8F\uD83C\uDFFD", + [":kiss_mm:"] = "\uD83D\uDC68\u200D❤️\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", + [":kiss_person_person_dark_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_dark_skin_tone::skin-tone-3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone::skin-tone-3:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_dark_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_dark_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_dark_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_dark_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_dark_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_dark_skin_tone_tone3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone_tone3:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_dark_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_dark_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_light_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_light_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_light_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_light_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_light_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_light_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_light_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_light_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_light_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_light_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_light_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_light_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_medium_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_medium_dark_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_medium_dark_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_medium_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_medium_dark_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_medium_light_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_medium_light_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_medium_light_skin_tone_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_medium_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_medium_skin_tone::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_medium_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_medium_skin_tone::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_medium_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_medium_skin_tone::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_medium_skin_tone_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFF", + [":kiss_person_person_medium_skin_tone_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_medium_skin_tone_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_medium_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_medium_skin_tone_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFB", + [":kiss_person_person_medium_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_person_person_medium_skin_tone_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFC", + [":kiss_person_person_medium_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFE", + [":kiss_person_person_medium_skin_tone_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83E\uDDD1\uD83C\uDFFD", + [":kiss_tone1:"] = "\uD83D\uDC8F\uD83C\uDFFB", + [":kiss_tone2:"] = "\uD83D\uDC8F\uD83C\uDFFC", + [":kiss_tone3:"] = "\uD83D\uDC8F\uD83C\uDFFD", + [":kiss_tone4:"] = "\uD83D\uDC8F\uD83C\uDFFE", + [":kiss_tone5:"] = "\uD83D\uDC8F\uD83C\uDFFF", + [":kiss_woman_man:"] = "\uD83D\uDC69\u200D❤️\u200D\uD83D\uDC8B\u200D\uD83D\uDC68", + [":kiss_woman_man::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_light_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_medium_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_medium_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_medium_light_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_medium_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_man_medium_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_medium_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFB", + [":kiss_woman_man_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFC", + [":kiss_woman_man_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFD", + [":kiss_woman_man_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFE", + [":kiss_woman_man_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC68\uD83C\uDFFF", + [":kiss_woman_woman::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_dark_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone_tone3:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_dark_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_light_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_light_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_light_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_light_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_light_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_medium_dark_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_medium_dark_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_medium_dark_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_medium_dark_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_medium_dark_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_medium_dark_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_medium_light_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_medium_light_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_medium_light_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_medium_light_skin_tone_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_skin_tone::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_skin_tone::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_medium_skin_tone::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_skin_tone_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_woman_woman_medium_skin_tone_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_medium_skin_tone_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_medium_skin_tone_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_skin_tone_tone1:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_medium_skin_tone_tone2:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_medium_skin_tone_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFB", + [":kiss_woman_woman_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFC", + [":kiss_woman_woman_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFD", + [":kiss_woman_woman_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFE", + [":kiss_woman_woman_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\u2764\uFE0F\u200D\uD83D\uDC8B\u200D\uD83D\uDC69\uD83C\uDFFF", + [":kiss_ww:"] = "\uD83D\uDC69\u200D❤️\u200D\uD83D\uDC8B\u200D\uD83D\uDC69", + [":kissing:"] = "\uD83D\uDE17", + [":kissing_cat:"] = "\uD83D\uDE3D", + [":kissing_closed_eyes:"] = "\uD83D\uDE1A", + [":kissing_heart:"] = "\uD83D\uDE18", + [":kissing_smiling_eyes:"] = "\uD83D\uDE19", + [":kite:"] = "\uD83E\uDE81", + [":kiwi:"] = "\uD83E\uDD5D", + [":kiwifruit:"] = "\uD83E\uDD5D", + [":knife:"] = "\uD83D\uDD2A", + [":knot:"] = "\uD83E\uDEA2", + [":koala:"] = "\uD83D\uDC28", + [":koko:"] = "\uD83C\uDE01", + [":lab_coat:"] = "\uD83E\uDD7C", + [":label:"] = "\uD83C\uDFF7️", + [":lacrosse:"] = "\uD83E\uDD4D", + [":ladder:"] = "\uD83E\uDE9C", + [":large_blue_diamond:"] = "\uD83D\uDD37", + [":large_orange_diamond:"] = "\uD83D\uDD36", + [":last_quarter_moon:"] = "\uD83C\uDF17", + [":last_quarter_moon_with_face:"] = "\uD83C\uDF1C", + [":latin_cross:"] = "✝️", + [":laughing:"] = "\uD83D\uDE06", + [":leafy_green:"] = "\uD83E\uDD6C", + [":leaves:"] = "\uD83C\uDF43", + [":ledger:"] = "\uD83D\uDCD2", + [":left_facing_fist:"] = "\uD83E\uDD1B", + [":left_facing_fist::skin-tone-1:"] = "\uD83E\uDD1B\uD83C\uDFFB", + [":left_facing_fist::skin-tone-2:"] = "\uD83E\uDD1B\uD83C\uDFFC", + [":left_facing_fist::skin-tone-3:"] = "\uD83E\uDD1B\uD83C\uDFFD", + [":left_facing_fist::skin-tone-4:"] = "\uD83E\uDD1B\uD83C\uDFFE", + [":left_facing_fist::skin-tone-5:"] = "\uD83E\uDD1B\uD83C\uDFFF", + [":left_facing_fist_tone1:"] = "\uD83E\uDD1B\uD83C\uDFFB", + [":left_facing_fist_tone2:"] = "\uD83E\uDD1B\uD83C\uDFFC", + [":left_facing_fist_tone3:"] = "\uD83E\uDD1B\uD83C\uDFFD", + [":left_facing_fist_tone4:"] = "\uD83E\uDD1B\uD83C\uDFFE", + [":left_facing_fist_tone5:"] = "\uD83E\uDD1B\uD83C\uDFFF", + [":left_fist:"] = "\uD83E\uDD1B", + [":left_fist::skin-tone-1:"] = "\uD83E\uDD1B\uD83C\uDFFB", + [":left_fist::skin-tone-2:"] = "\uD83E\uDD1B\uD83C\uDFFC", + [":left_fist::skin-tone-3:"] = "\uD83E\uDD1B\uD83C\uDFFD", + [":left_fist::skin-tone-4:"] = "\uD83E\uDD1B\uD83C\uDFFE", + [":left_fist::skin-tone-5:"] = "\uD83E\uDD1B\uD83C\uDFFF", + [":left_fist_tone1:"] = "\uD83E\uDD1B\uD83C\uDFFB", + [":left_fist_tone2:"] = "\uD83E\uDD1B\uD83C\uDFFC", + [":left_fist_tone3:"] = "\uD83E\uDD1B\uD83C\uDFFD", + [":left_fist_tone4:"] = "\uD83E\uDD1B\uD83C\uDFFE", + [":left_fist_tone5:"] = "\uD83E\uDD1B\uD83C\uDFFF", + [":left_luggage:"] = "\uD83D\uDEC5", + [":left_right_arrow:"] = "↔️", + [":left_speech_bubble:"] = "\uD83D\uDDE8️", + [":leftwards_arrow_with_hook:"] = "↩️", + [":leftwards_hand:"] = "\uD83E\uDEF2", + [":leftwards_hand::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leftwards_hand_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":leg:"] = "\uD83E\uDDB5", + [":leg::skin-tone-1:"] = "\uD83E\uDDB5\uD83C\uDFFB", + [":leg::skin-tone-2:"] = "\uD83E\uDDB5\uD83C\uDFFC", + [":leg::skin-tone-3:"] = "\uD83E\uDDB5\uD83C\uDFFD", + [":leg::skin-tone-4:"] = "\uD83E\uDDB5\uD83C\uDFFE", + [":leg::skin-tone-5:"] = "\uD83E\uDDB5\uD83C\uDFFF", + [":leg_dark_skin_tone:"] = "\uD83E\uDDB5\uD83C\uDFFF", + [":leg_light_skin_tone:"] = "\uD83E\uDDB5\uD83C\uDFFB", + [":leg_medium_dark_skin_tone:"] = "\uD83E\uDDB5\uD83C\uDFFE", + [":leg_medium_light_skin_tone:"] = "\uD83E\uDDB5\uD83C\uDFFC", + [":leg_medium_skin_tone:"] = "\uD83E\uDDB5\uD83C\uDFFD", + [":leg_tone1:"] = "\uD83E\uDDB5\uD83C\uDFFB", + [":leg_tone2:"] = "\uD83E\uDDB5\uD83C\uDFFC", + [":leg_tone3:"] = "\uD83E\uDDB5\uD83C\uDFFD", + [":leg_tone4:"] = "\uD83E\uDDB5\uD83C\uDFFE", + [":leg_tone5:"] = "\uD83E\uDDB5\uD83C\uDFFF", + [":lemon:"] = "\uD83C\uDF4B", + [":leo:"] = "♌", + [":leopard:"] = "\uD83D\uDC06", + [":level_slider:"] = "\uD83C\uDF9A️", + [":levitate:"] = "\uD83D\uDD74️", + [":levitate::skin-tone-1:"] = "\uD83D\uDD74\uD83C\uDFFB", + [":levitate::skin-tone-2:"] = "\uD83D\uDD74\uD83C\uDFFC", + [":levitate::skin-tone-3:"] = "\uD83D\uDD74\uD83C\uDFFD", + [":levitate::skin-tone-4:"] = "\uD83D\uDD74\uD83C\uDFFE", + [":levitate::skin-tone-5:"] = "\uD83D\uDD74\uD83C\uDFFF", + [":levitate_tone1:"] = "\uD83D\uDD74\uD83C\uDFFB", + [":levitate_tone2:"] = "\uD83D\uDD74\uD83C\uDFFC", + [":levitate_tone3:"] = "\uD83D\uDD74\uD83C\uDFFD", + [":levitate_tone4:"] = "\uD83D\uDD74\uD83C\uDFFE", + [":levitate_tone5:"] = "\uD83D\uDD74\uD83C\uDFFF", + [":liar:"] = "\uD83E\uDD25", + [":libra:"] = "♎", + [":lifter:"] = "\uD83C\uDFCB️", + [":lifter::skin-tone-1:"] = "\uD83C\uDFCB\uD83C\uDFFB", + [":lifter::skin-tone-2:"] = "\uD83C\uDFCB\uD83C\uDFFC", + [":lifter::skin-tone-3:"] = "\uD83C\uDFCB\uD83C\uDFFD", + [":lifter::skin-tone-4:"] = "\uD83C\uDFCB\uD83C\uDFFE", + [":lifter::skin-tone-5:"] = "\uD83C\uDFCB\uD83C\uDFFF", + [":lifter_tone1:"] = "\uD83C\uDFCB\uD83C\uDFFB", + [":lifter_tone2:"] = "\uD83C\uDFCB\uD83C\uDFFC", + [":lifter_tone3:"] = "\uD83C\uDFCB\uD83C\uDFFD", + [":lifter_tone4:"] = "\uD83C\uDFCB\uD83C\uDFFE", + [":lifter_tone5:"] = "\uD83C\uDFCB\uD83C\uDFFF", + [":light_rail:"] = "\uD83D\uDE88", + [":link:"] = "\uD83D\uDD17", + [":linked_paperclips:"] = "\uD83D\uDD87️", + [":lion:"] = "\uD83E\uDD81", + [":lion_face:"] = "\uD83E\uDD81", + [":lips:"] = "\uD83D\uDC44", + [":lipstick:"] = "\uD83D\uDC84", + [":lizard:"] = "\uD83E\uDD8E", + [":llama:"] = "\uD83E\uDD99", + [":lobster:"] = "\uD83E\uDD9E", + [":lock:"] = "\uD83D\uDD12", + [":lock_with_ink_pen:"] = "\uD83D\uDD0F", + [":lollipop:"] = "\uD83C\uDF6D", + [":long_drum:"] = "\uD83E\uDE98", + [":loop:"] = "➿", + [":lotus:"] = "\uD83E\uDEB7", + [":loud_sound:"] = "\uD83D\uDD0A", + [":loudspeaker:"] = "\uD83D\uDCE2", + [":love_hotel:"] = "\uD83C\uDFE9", + [":love_letter:"] = "\uD83D\uDC8C", + [":love_you_gesture:"] = "\uD83E\uDD1F", + [":love_you_gesture::skin-tone-1:"] = "\uD83E\uDD1F\uD83C\uDFFB", + [":love_you_gesture::skin-tone-2:"] = "\uD83E\uDD1F\uD83C\uDFFC", + [":love_you_gesture::skin-tone-3:"] = "\uD83E\uDD1F\uD83C\uDFFD", + [":love_you_gesture::skin-tone-4:"] = "\uD83E\uDD1F\uD83C\uDFFE", + [":love_you_gesture::skin-tone-5:"] = "\uD83E\uDD1F\uD83C\uDFFF", + [":love_you_gesture_dark_skin_tone:"] = "\uD83E\uDD1F\uD83C\uDFFF", + [":love_you_gesture_light_skin_tone:"] = "\uD83E\uDD1F\uD83C\uDFFB", + [":love_you_gesture_medium_dark_skin_tone:"] = "\uD83E\uDD1F\uD83C\uDFFE", + [":love_you_gesture_medium_light_skin_tone:"] = "\uD83E\uDD1F\uD83C\uDFFC", + [":love_you_gesture_medium_skin_tone:"] = "\uD83E\uDD1F\uD83C\uDFFD", + [":love_you_gesture_tone1:"] = "\uD83E\uDD1F\uD83C\uDFFB", + [":love_you_gesture_tone2:"] = "\uD83E\uDD1F\uD83C\uDFFC", + [":love_you_gesture_tone3:"] = "\uD83E\uDD1F\uD83C\uDFFD", + [":love_you_gesture_tone4:"] = "\uD83E\uDD1F\uD83C\uDFFE", + [":love_you_gesture_tone5:"] = "\uD83E\uDD1F\uD83C\uDFFF", + [":low_battery:"] = "\uD83E\uDEAB", + [":low_brightness:"] = "\uD83D\uDD05", + [":lower_left_ballpoint_pen:"] = "\uD83D\uDD8A️", + [":lower_left_crayon:"] = "\uD83D\uDD8D️", + [":lower_left_fountain_pen:"] = "\uD83D\uDD8B️", + [":lower_left_paintbrush:"] = "\uD83D\uDD8C️", + [":luggage:"] = "\uD83E\uDDF3", + [":lungs:"] = "\uD83E\uDEC1", + [":lying_face:"] = "\uD83E\uDD25", + [":m:"] = "Ⓜ️", + [":mag:"] = "\uD83D\uDD0D", + [":mag_right:"] = "\uD83D\uDD0E", + [":mage:"] = "\uD83E\uDDD9", + [":mage::skin-tone-1:"] = "\uD83E\uDDD9\uD83C\uDFFB", + [":mage::skin-tone-2:"] = "\uD83E\uDDD9\uD83C\uDFFC", + [":mage::skin-tone-3:"] = "\uD83E\uDDD9\uD83C\uDFFD", + [":mage::skin-tone-4:"] = "\uD83E\uDDD9\uD83C\uDFFE", + [":mage::skin-tone-5:"] = "\uD83E\uDDD9\uD83C\uDFFF", + [":mage_dark_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFF", + [":mage_light_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFB", + [":mage_medium_dark_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFE", + [":mage_medium_light_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFC", + [":mage_medium_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFD", + [":mage_tone1:"] = "\uD83E\uDDD9\uD83C\uDFFB", + [":mage_tone2:"] = "\uD83E\uDDD9\uD83C\uDFFC", + [":mage_tone3:"] = "\uD83E\uDDD9\uD83C\uDFFD", + [":mage_tone4:"] = "\uD83E\uDDD9\uD83C\uDFFE", + [":mage_tone5:"] = "\uD83E\uDDD9\uD83C\uDFFF", + [":magic_wand:"] = "\uD83E\uDE84", + [":magnet:"] = "\uD83E\uDDF2", + [":mahjong:"] = "\uD83C\uDC04", + [":mailbox:"] = "\uD83D\uDCEB", + [":mailbox_closed:"] = "\uD83D\uDCEA", + [":mailbox_with_mail:"] = "\uD83D\uDCEC", + [":mailbox_with_no_mail:"] = "\uD83D\uDCED", + [":male_dancer:"] = "\uD83D\uDD7A", + [":male_dancer::skin-tone-1:"] = "\uD83D\uDD7A\uD83C\uDFFB", + [":male_dancer::skin-tone-2:"] = "\uD83D\uDD7A\uD83C\uDFFC", + [":male_dancer::skin-tone-3:"] = "\uD83D\uDD7A\uD83C\uDFFD", + [":male_dancer::skin-tone-4:"] = "\uD83D\uDD7A\uD83C\uDFFE", + [":male_dancer::skin-tone-5:"] = "\uD83D\uDD7A\uD83C\uDFFF", + [":male_dancer_tone1:"] = "\uD83D\uDD7A\uD83C\uDFFB", + [":male_dancer_tone2:"] = "\uD83D\uDD7A\uD83C\uDFFC", + [":male_dancer_tone3:"] = "\uD83D\uDD7A\uD83C\uDFFD", + [":male_dancer_tone4:"] = "\uD83D\uDD7A\uD83C\uDFFE", + [":male_dancer_tone5:"] = "\uD83D\uDD7A\uD83C\uDFFF", + [":male_sign:"] = "♂️", + [":mammoth:"] = "\uD83E\uDDA3", + [":man:"] = "\uD83D\uDC68", + [":man::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB", + [":man::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC", + [":man::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD", + [":man::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE", + [":man::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF", + [":man_artist:"] = "\uD83D\uDC68\u200D\uD83C\uDFA8", + [":man_artist::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFA8", + [":man_artist::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFA8", + [":man_artist::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFA8", + [":man_artist::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFA8", + [":man_artist::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFA8", + [":man_artist_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFA8", + [":man_artist_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFA8", + [":man_artist_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFA8", + [":man_artist_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFA8", + [":man_artist_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFA8", + [":man_artist_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFA8", + [":man_artist_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFA8", + [":man_artist_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFA8", + [":man_artist_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFA8", + [":man_artist_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFA8", + [":man_astronaut:"] = "\uD83D\uDC68\u200D\uD83D\uDE80", + [":man_astronaut::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDE80", + [":man_astronaut::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDE80", + [":man_astronaut::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDE80", + [":man_astronaut::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDE80", + [":man_astronaut::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDE80", + [":man_astronaut_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDE80", + [":man_astronaut_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDE80", + [":man_astronaut_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDE80", + [":man_astronaut_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDE80", + [":man_astronaut_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDE80", + [":man_astronaut_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDE80", + [":man_astronaut_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDE80", + [":man_astronaut_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDE80", + [":man_astronaut_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDE80", + [":man_astronaut_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDE80", + [":man_bald:"] = "\uD83D\uDC68\u200D\uD83E\uDDB2", + [":man_bald::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB2", + [":man_bald::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB2", + [":man_bald::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB2", + [":man_bald::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB2", + [":man_bald::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB2", + [":man_bald_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB2", + [":man_bald_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB2", + [":man_bald_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB2", + [":man_bald_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB2", + [":man_bald_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB2", + [":man_bald_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB2", + [":man_bald_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB2", + [":man_bald_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB2", + [":man_bald_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB2", + [":man_bald_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB2", + [":man_beard:"] = "\uD83E\uDDD4\u200D\u2642\uFE0F", + [":man_beard::skin-tone-1:"] = "\uD83E\uDDD4\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_beard::skin-tone-2:"] = "\uD83E\uDDD4\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_beard::skin-tone-3:"] = "\uD83E\uDDD4\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_beard::skin-tone-4:"] = "\uD83E\uDDD4\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_beard::skin-tone-5:"] = "\uD83E\uDDD4\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_beard_tone1:"] = "\uD83E\uDDD4\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_beard_tone2:"] = "\uD83E\uDDD4\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_beard_tone3:"] = "\uD83E\uDDD4\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_beard_tone4:"] = "\uD83E\uDDD4\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_beard_tone5:"] = "\uD83E\uDDD4\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_biking:"] = "\uD83D\uDEB4\u200D♂️", + [":man_biking::skin-tone-1:"] = "\uD83D\uDEB4\uD83C\uDFFB\u200D♂️", + [":man_biking::skin-tone-2:"] = "\uD83D\uDEB4\uD83C\uDFFC\u200D♂️", + [":man_biking::skin-tone-3:"] = "\uD83D\uDEB4\uD83C\uDFFD\u200D♂️", + [":man_biking::skin-tone-4:"] = "\uD83D\uDEB4\uD83C\uDFFE\u200D♂️", + [":man_biking::skin-tone-5:"] = "\uD83D\uDEB4\uD83C\uDFFF\u200D♂️", + [":man_biking_dark_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFF\u200D♂️", + [":man_biking_light_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFB\u200D♂️", + [":man_biking_medium_dark_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFE\u200D♂️", + [":man_biking_medium_light_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFC\u200D♂️", + [":man_biking_medium_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFD\u200D♂️", + [":man_biking_tone1:"] = "\uD83D\uDEB4\uD83C\uDFFB\u200D♂️", + [":man_biking_tone2:"] = "\uD83D\uDEB4\uD83C\uDFFC\u200D♂️", + [":man_biking_tone3:"] = "\uD83D\uDEB4\uD83C\uDFFD\u200D♂️", + [":man_biking_tone4:"] = "\uD83D\uDEB4\uD83C\uDFFE\u200D♂️", + [":man_biking_tone5:"] = "\uD83D\uDEB4\uD83C\uDFFF\u200D♂️", + [":man_bouncing_ball:"] = "⛹️\u200D♂️", + [":man_bouncing_ball::skin-tone-1:"] = "⛹\uD83C\uDFFB\u200D♂️", + [":man_bouncing_ball::skin-tone-2:"] = "⛹\uD83C\uDFFC\u200D♂️", + [":man_bouncing_ball::skin-tone-3:"] = "⛹\uD83C\uDFFD\u200D♂️", + [":man_bouncing_ball::skin-tone-4:"] = "⛹\uD83C\uDFFE\u200D♂️", + [":man_bouncing_ball::skin-tone-5:"] = "⛹\uD83C\uDFFF\u200D♂️", + [":man_bouncing_ball_dark_skin_tone:"] = "⛹\uD83C\uDFFF\u200D♂️", + [":man_bouncing_ball_light_skin_tone:"] = "⛹\uD83C\uDFFB\u200D♂️", + [":man_bouncing_ball_medium_dark_skin_tone:"] = "⛹\uD83C\uDFFE\u200D♂️", + [":man_bouncing_ball_medium_light_skin_tone:"] = "⛹\uD83C\uDFFC\u200D♂️", + [":man_bouncing_ball_medium_skin_tone:"] = "⛹\uD83C\uDFFD\u200D♂️", + [":man_bouncing_ball_tone1:"] = "⛹\uD83C\uDFFB\u200D♂️", + [":man_bouncing_ball_tone2:"] = "⛹\uD83C\uDFFC\u200D♂️", + [":man_bouncing_ball_tone3:"] = "⛹\uD83C\uDFFD\u200D♂️", + [":man_bouncing_ball_tone4:"] = "⛹\uD83C\uDFFE\u200D♂️", + [":man_bouncing_ball_tone5:"] = "⛹\uD83C\uDFFF\u200D♂️", + [":man_bowing:"] = "\uD83D\uDE47\u200D♂️", + [":man_bowing::skin-tone-1:"] = "\uD83D\uDE47\uD83C\uDFFB\u200D♂️", + [":man_bowing::skin-tone-2:"] = "\uD83D\uDE47\uD83C\uDFFC\u200D♂️", + [":man_bowing::skin-tone-3:"] = "\uD83D\uDE47\uD83C\uDFFD\u200D♂️", + [":man_bowing::skin-tone-4:"] = "\uD83D\uDE47\uD83C\uDFFE\u200D♂️", + [":man_bowing::skin-tone-5:"] = "\uD83D\uDE47\uD83C\uDFFF\u200D♂️", + [":man_bowing_dark_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFF\u200D♂️", + [":man_bowing_light_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFB\u200D♂️", + [":man_bowing_medium_dark_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFE\u200D♂️", + [":man_bowing_medium_light_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFC\u200D♂️", + [":man_bowing_medium_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFD\u200D♂️", + [":man_bowing_tone1:"] = "\uD83D\uDE47\uD83C\uDFFB\u200D♂️", + [":man_bowing_tone2:"] = "\uD83D\uDE47\uD83C\uDFFC\u200D♂️", + [":man_bowing_tone3:"] = "\uD83D\uDE47\uD83C\uDFFD\u200D♂️", + [":man_bowing_tone4:"] = "\uD83D\uDE47\uD83C\uDFFE\u200D♂️", + [":man_bowing_tone5:"] = "\uD83D\uDE47\uD83C\uDFFF\u200D♂️", + [":man_cartwheeling:"] = "\uD83E\uDD38\u200D♂️", + [":man_cartwheeling::skin-tone-1:"] = "\uD83E\uDD38\uD83C\uDFFB\u200D♂️", + [":man_cartwheeling::skin-tone-2:"] = "\uD83E\uDD38\uD83C\uDFFC\u200D♂️", + [":man_cartwheeling::skin-tone-3:"] = "\uD83E\uDD38\uD83C\uDFFD\u200D♂️", + [":man_cartwheeling::skin-tone-4:"] = "\uD83E\uDD38\uD83C\uDFFE\u200D♂️", + [":man_cartwheeling::skin-tone-5:"] = "\uD83E\uDD38\uD83C\uDFFF\u200D♂️", + [":man_cartwheeling_dark_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFF\u200D♂️", + [":man_cartwheeling_light_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFB\u200D♂️", + [":man_cartwheeling_medium_dark_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFE\u200D♂️", + [":man_cartwheeling_medium_light_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFC\u200D♂️", + [":man_cartwheeling_medium_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFD\u200D♂️", + [":man_cartwheeling_tone1:"] = "\uD83E\uDD38\uD83C\uDFFB\u200D♂️", + [":man_cartwheeling_tone2:"] = "\uD83E\uDD38\uD83C\uDFFC\u200D♂️", + [":man_cartwheeling_tone3:"] = "\uD83E\uDD38\uD83C\uDFFD\u200D♂️", + [":man_cartwheeling_tone4:"] = "\uD83E\uDD38\uD83C\uDFFE\u200D♂️", + [":man_cartwheeling_tone5:"] = "\uD83E\uDD38\uD83C\uDFFF\u200D♂️", + [":man_climbing:"] = "\uD83E\uDDD7\u200D♂️", + [":man_climbing::skin-tone-1:"] = "\uD83E\uDDD7\uD83C\uDFFB\u200D♂️", + [":man_climbing::skin-tone-2:"] = "\uD83E\uDDD7\uD83C\uDFFC\u200D♂️", + [":man_climbing::skin-tone-3:"] = "\uD83E\uDDD7\uD83C\uDFFD\u200D♂️", + [":man_climbing::skin-tone-4:"] = "\uD83E\uDDD7\uD83C\uDFFE\u200D♂️", + [":man_climbing::skin-tone-5:"] = "\uD83E\uDDD7\uD83C\uDFFF\u200D♂️", + [":man_climbing_dark_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFF\u200D♂️", + [":man_climbing_light_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFB\u200D♂️", + [":man_climbing_medium_dark_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFE\u200D♂️", + [":man_climbing_medium_light_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFC\u200D♂️", + [":man_climbing_medium_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFD\u200D♂️", + [":man_climbing_tone1:"] = "\uD83E\uDDD7\uD83C\uDFFB\u200D♂️", + [":man_climbing_tone2:"] = "\uD83E\uDDD7\uD83C\uDFFC\u200D♂️", + [":man_climbing_tone3:"] = "\uD83E\uDDD7\uD83C\uDFFD\u200D♂️", + [":man_climbing_tone4:"] = "\uD83E\uDDD7\uD83C\uDFFE\u200D♂️", + [":man_climbing_tone5:"] = "\uD83E\uDDD7\uD83C\uDFFF\u200D♂️", + [":man_construction_worker:"] = "\uD83D\uDC77\u200D♂️", + [":man_construction_worker::skin-tone-1:"] = "\uD83D\uDC77\uD83C\uDFFB\u200D♂️", + [":man_construction_worker::skin-tone-2:"] = "\uD83D\uDC77\uD83C\uDFFC\u200D♂️", + [":man_construction_worker::skin-tone-3:"] = "\uD83D\uDC77\uD83C\uDFFD\u200D♂️", + [":man_construction_worker::skin-tone-4:"] = "\uD83D\uDC77\uD83C\uDFFE\u200D♂️", + [":man_construction_worker::skin-tone-5:"] = "\uD83D\uDC77\uD83C\uDFFF\u200D♂️", + [":man_construction_worker_dark_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFF\u200D♂️", + [":man_construction_worker_light_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFB\u200D♂️", + [":man_construction_worker_medium_dark_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFE\u200D♂️", + [":man_construction_worker_medium_light_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFC\u200D♂️", + [":man_construction_worker_medium_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFD\u200D♂️", + [":man_construction_worker_tone1:"] = "\uD83D\uDC77\uD83C\uDFFB\u200D♂️", + [":man_construction_worker_tone2:"] = "\uD83D\uDC77\uD83C\uDFFC\u200D♂️", + [":man_construction_worker_tone3:"] = "\uD83D\uDC77\uD83C\uDFFD\u200D♂️", + [":man_construction_worker_tone4:"] = "\uD83D\uDC77\uD83C\uDFFE\u200D♂️", + [":man_construction_worker_tone5:"] = "\uD83D\uDC77\uD83C\uDFFF\u200D♂️", + [":man_cook:"] = "\uD83D\uDC68\u200D\uD83C\uDF73", + [":man_cook::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF73", + [":man_cook::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF73", + [":man_cook::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF73", + [":man_cook::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF73", + [":man_cook::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF73", + [":man_cook_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF73", + [":man_cook_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF73", + [":man_cook_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF73", + [":man_cook_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF73", + [":man_cook_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF73", + [":man_cook_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF73", + [":man_cook_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF73", + [":man_cook_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF73", + [":man_cook_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF73", + [":man_cook_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF73", + [":man_curly_haired:"] = "\uD83D\uDC68\u200D\uD83E\uDDB1", + [":man_curly_haired::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB1", + [":man_curly_haired::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB1", + [":man_curly_haired::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB1", + [":man_curly_haired::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB1", + [":man_curly_haired::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB1", + [":man_curly_haired_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB1", + [":man_curly_haired_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB1", + [":man_curly_haired_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB1", + [":man_curly_haired_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB1", + [":man_curly_haired_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB1", + [":man_curly_haired_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB1", + [":man_curly_haired_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB1", + [":man_curly_haired_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB1", + [":man_curly_haired_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB1", + [":man_curly_haired_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB1", + [":man_dancing:"] = "\uD83D\uDD7A", + [":man_dancing::skin-tone-1:"] = "\uD83D\uDD7A\uD83C\uDFFB", + [":man_dancing::skin-tone-2:"] = "\uD83D\uDD7A\uD83C\uDFFC", + [":man_dancing::skin-tone-3:"] = "\uD83D\uDD7A\uD83C\uDFFD", + [":man_dancing::skin-tone-4:"] = "\uD83D\uDD7A\uD83C\uDFFE", + [":man_dancing::skin-tone-5:"] = "\uD83D\uDD7A\uD83C\uDFFF", + [":man_dancing_tone1:"] = "\uD83D\uDD7A\uD83C\uDFFB", + [":man_dancing_tone2:"] = "\uD83D\uDD7A\uD83C\uDFFC", + [":man_dancing_tone3:"] = "\uD83D\uDD7A\uD83C\uDFFD", + [":man_dancing_tone4:"] = "\uD83D\uDD7A\uD83C\uDFFE", + [":man_dancing_tone5:"] = "\uD83D\uDD7A\uD83C\uDFFF", + [":man_dark_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_detective:"] = "\uD83D\uDD75️\u200D♂️", + [":man_detective::skin-tone-1:"] = "\uD83D\uDD75\uD83C\uDFFB\u200D♂️", + [":man_detective::skin-tone-2:"] = "\uD83D\uDD75\uD83C\uDFFC\u200D♂️", + [":man_detective::skin-tone-3:"] = "\uD83D\uDD75\uD83C\uDFFD\u200D♂️", + [":man_detective::skin-tone-4:"] = "\uD83D\uDD75\uD83C\uDFFE\u200D♂️", + [":man_detective::skin-tone-5:"] = "\uD83D\uDD75\uD83C\uDFFF\u200D♂️", + [":man_detective_dark_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFF\u200D♂️", + [":man_detective_light_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFB\u200D♂️", + [":man_detective_medium_dark_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFE\u200D♂️", + [":man_detective_medium_light_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFC\u200D♂️", + [":man_detective_medium_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFD\u200D♂️", + [":man_detective_tone1:"] = "\uD83D\uDD75\uD83C\uDFFB\u200D♂️", + [":man_detective_tone2:"] = "\uD83D\uDD75\uD83C\uDFFC\u200D♂️", + [":man_detective_tone3:"] = "\uD83D\uDD75\uD83C\uDFFD\u200D♂️", + [":man_detective_tone4:"] = "\uD83D\uDD75\uD83C\uDFFE\u200D♂️", + [":man_detective_tone5:"] = "\uD83D\uDD75\uD83C\uDFFF\u200D♂️", + [":man_elf:"] = "\uD83E\uDDDD\u200D♂️", + [":man_elf::skin-tone-1:"] = "\uD83E\uDDDD\uD83C\uDFFB\u200D♂️", + [":man_elf::skin-tone-2:"] = "\uD83E\uDDDD\uD83C\uDFFC\u200D♂️", + [":man_elf::skin-tone-3:"] = "\uD83E\uDDDD\uD83C\uDFFD\u200D♂️", + [":man_elf::skin-tone-4:"] = "\uD83E\uDDDD\uD83C\uDFFE\u200D♂️", + [":man_elf::skin-tone-5:"] = "\uD83E\uDDDD\uD83C\uDFFF\u200D♂️", + [":man_elf_dark_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFF\u200D♂️", + [":man_elf_light_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFB\u200D♂️", + [":man_elf_medium_dark_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFE\u200D♂️", + [":man_elf_medium_light_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFC\u200D♂️", + [":man_elf_medium_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFD\u200D♂️", + [":man_elf_tone1:"] = "\uD83E\uDDDD\uD83C\uDFFB\u200D♂️", + [":man_elf_tone2:"] = "\uD83E\uDDDD\uD83C\uDFFC\u200D♂️", + [":man_elf_tone3:"] = "\uD83E\uDDDD\uD83C\uDFFD\u200D♂️", + [":man_elf_tone4:"] = "\uD83E\uDDDD\uD83C\uDFFE\u200D♂️", + [":man_elf_tone5:"] = "\uD83E\uDDDD\uD83C\uDFFF\u200D♂️", + [":man_facepalming:"] = "\uD83E\uDD26\u200D♂️", + [":man_facepalming::skin-tone-1:"] = "\uD83E\uDD26\uD83C\uDFFB\u200D♂️", + [":man_facepalming::skin-tone-2:"] = "\uD83E\uDD26\uD83C\uDFFC\u200D♂️", + [":man_facepalming::skin-tone-3:"] = "\uD83E\uDD26\uD83C\uDFFD\u200D♂️", + [":man_facepalming::skin-tone-4:"] = "\uD83E\uDD26\uD83C\uDFFE\u200D♂️", + [":man_facepalming::skin-tone-5:"] = "\uD83E\uDD26\uD83C\uDFFF\u200D♂️", + [":man_facepalming_dark_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFF\u200D♂️", + [":man_facepalming_light_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFB\u200D♂️", + [":man_facepalming_medium_dark_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFE\u200D♂️", + [":man_facepalming_medium_light_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFC\u200D♂️", + [":man_facepalming_medium_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFD\u200D♂️", + [":man_facepalming_tone1:"] = "\uD83E\uDD26\uD83C\uDFFB\u200D♂️", + [":man_facepalming_tone2:"] = "\uD83E\uDD26\uD83C\uDFFC\u200D♂️", + [":man_facepalming_tone3:"] = "\uD83E\uDD26\uD83C\uDFFD\u200D♂️", + [":man_facepalming_tone4:"] = "\uD83E\uDD26\uD83C\uDFFE\u200D♂️", + [":man_facepalming_tone5:"] = "\uD83E\uDD26\uD83C\uDFFF\u200D♂️", + [":man_factory_worker:"] = "\uD83D\uDC68\u200D\uD83C\uDFED", + [":man_factory_worker::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFED", + [":man_factory_worker::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFED", + [":man_factory_worker::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFED", + [":man_factory_worker::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFED", + [":man_factory_worker::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFED", + [":man_factory_worker_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFED", + [":man_factory_worker_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFED", + [":man_factory_worker_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFED", + [":man_factory_worker_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFED", + [":man_factory_worker_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFED", + [":man_factory_worker_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFED", + [":man_factory_worker_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFED", + [":man_factory_worker_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFED", + [":man_factory_worker_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFED", + [":man_factory_worker_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFED", + [":man_fairy:"] = "\uD83E\uDDDA\u200D♂️", + [":man_fairy::skin-tone-1:"] = "\uD83E\uDDDA\uD83C\uDFFB\u200D♂️", + [":man_fairy::skin-tone-2:"] = "\uD83E\uDDDA\uD83C\uDFFC\u200D♂️", + [":man_fairy::skin-tone-3:"] = "\uD83E\uDDDA\uD83C\uDFFD\u200D♂️", + [":man_fairy::skin-tone-4:"] = "\uD83E\uDDDA\uD83C\uDFFE\u200D♂️", + [":man_fairy::skin-tone-5:"] = "\uD83E\uDDDA\uD83C\uDFFF\u200D♂️", + [":man_fairy_dark_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFF\u200D♂️", + [":man_fairy_light_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFB\u200D♂️", + [":man_fairy_medium_dark_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFE\u200D♂️", + [":man_fairy_medium_light_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFC\u200D♂️", + [":man_fairy_medium_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFD\u200D♂️", + [":man_fairy_tone1:"] = "\uD83E\uDDDA\uD83C\uDFFB\u200D♂️", + [":man_fairy_tone2:"] = "\uD83E\uDDDA\uD83C\uDFFC\u200D♂️", + [":man_fairy_tone3:"] = "\uD83E\uDDDA\uD83C\uDFFD\u200D♂️", + [":man_fairy_tone4:"] = "\uD83E\uDDDA\uD83C\uDFFE\u200D♂️", + [":man_fairy_tone5:"] = "\uD83E\uDDDA\uD83C\uDFFF\u200D♂️", + [":man_farmer:"] = "\uD83D\uDC68\u200D\uD83C\uDF3E", + [":man_farmer::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF3E", + [":man_farmer::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF3E", + [":man_farmer::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF3E", + [":man_farmer::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF3E", + [":man_farmer::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF3E", + [":man_farmer_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF3E", + [":man_farmer_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF3E", + [":man_farmer_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF3E", + [":man_farmer_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF3E", + [":man_farmer_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF3E", + [":man_farmer_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF3E", + [":man_farmer_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF3E", + [":man_farmer_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF3E", + [":man_farmer_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF3E", + [":man_farmer_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF3E", + [":man_feeding_baby:"] = "\uD83D\uDC68\u200D\uD83C\uDF7C", + [":man_feeding_baby::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":man_feeding_baby::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":man_feeding_baby::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":man_feeding_baby::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":man_feeding_baby::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":man_feeding_baby_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":man_feeding_baby_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":man_feeding_baby_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":man_feeding_baby_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":man_feeding_baby_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":man_feeding_baby_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":man_feeding_baby_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":man_feeding_baby_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":man_feeding_baby_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":man_feeding_baby_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":man_firefighter:"] = "\uD83D\uDC68\u200D\uD83D\uDE92", + [":man_firefighter::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDE92", + [":man_firefighter::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDE92", + [":man_firefighter::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDE92", + [":man_firefighter::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDE92", + [":man_firefighter::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDE92", + [":man_firefighter_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDE92", + [":man_firefighter_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDE92", + [":man_firefighter_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDE92", + [":man_firefighter_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDE92", + [":man_firefighter_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDE92", + [":man_firefighter_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDE92", + [":man_firefighter_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDE92", + [":man_firefighter_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDE92", + [":man_firefighter_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDE92", + [":man_firefighter_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDE92", + [":man_frowning:"] = "\uD83D\uDE4D\u200D♂️", + [":man_frowning::skin-tone-1:"] = "\uD83D\uDE4D\uD83C\uDFFB\u200D♂️", + [":man_frowning::skin-tone-2:"] = "\uD83D\uDE4D\uD83C\uDFFC\u200D♂️", + [":man_frowning::skin-tone-3:"] = "\uD83D\uDE4D\uD83C\uDFFD\u200D♂️", + [":man_frowning::skin-tone-4:"] = "\uD83D\uDE4D\uD83C\uDFFE\u200D♂️", + [":man_frowning::skin-tone-5:"] = "\uD83D\uDE4D\uD83C\uDFFF\u200D♂️", + [":man_frowning_dark_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFF\u200D♂️", + [":man_frowning_light_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFB\u200D♂️", + [":man_frowning_medium_dark_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFE\u200D♂️", + [":man_frowning_medium_light_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFC\u200D♂️", + [":man_frowning_medium_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFD\u200D♂️", + [":man_frowning_tone1:"] = "\uD83D\uDE4D\uD83C\uDFFB\u200D♂️", + [":man_frowning_tone2:"] = "\uD83D\uDE4D\uD83C\uDFFC\u200D♂️", + [":man_frowning_tone3:"] = "\uD83D\uDE4D\uD83C\uDFFD\u200D♂️", + [":man_frowning_tone4:"] = "\uD83D\uDE4D\uD83C\uDFFE\u200D♂️", + [":man_frowning_tone5:"] = "\uD83D\uDE4D\uD83C\uDFFF\u200D♂️", + [":man_genie:"] = "\uD83E\uDDDE\u200D♂️", + [":man_gesturing_no:"] = "\uD83D\uDE45\u200D♂️", + [":man_gesturing_no::skin-tone-1:"] = "\uD83D\uDE45\uD83C\uDFFB\u200D♂️", + [":man_gesturing_no::skin-tone-2:"] = "\uD83D\uDE45\uD83C\uDFFC\u200D♂️", + [":man_gesturing_no::skin-tone-3:"] = "\uD83D\uDE45\uD83C\uDFFD\u200D♂️", + [":man_gesturing_no::skin-tone-4:"] = "\uD83D\uDE45\uD83C\uDFFE\u200D♂️", + [":man_gesturing_no::skin-tone-5:"] = "\uD83D\uDE45\uD83C\uDFFF\u200D♂️", + [":man_gesturing_no_dark_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFF\u200D♂️", + [":man_gesturing_no_light_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFB\u200D♂️", + [":man_gesturing_no_medium_dark_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFE\u200D♂️", + [":man_gesturing_no_medium_light_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFC\u200D♂️", + [":man_gesturing_no_medium_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFD\u200D♂️", + [":man_gesturing_no_tone1:"] = "\uD83D\uDE45\uD83C\uDFFB\u200D♂️", + [":man_gesturing_no_tone2:"] = "\uD83D\uDE45\uD83C\uDFFC\u200D♂️", + [":man_gesturing_no_tone3:"] = "\uD83D\uDE45\uD83C\uDFFD\u200D♂️", + [":man_gesturing_no_tone4:"] = "\uD83D\uDE45\uD83C\uDFFE\u200D♂️", + [":man_gesturing_no_tone5:"] = "\uD83D\uDE45\uD83C\uDFFF\u200D♂️", + [":man_gesturing_ok:"] = "\uD83D\uDE46\u200D♂️", + [":man_gesturing_ok::skin-tone-1:"] = "\uD83D\uDE46\uD83C\uDFFB\u200D♂️", + [":man_gesturing_ok::skin-tone-2:"] = "\uD83D\uDE46\uD83C\uDFFC\u200D♂️", + [":man_gesturing_ok::skin-tone-3:"] = "\uD83D\uDE46\uD83C\uDFFD\u200D♂️", + [":man_gesturing_ok::skin-tone-4:"] = "\uD83D\uDE46\uD83C\uDFFE\u200D♂️", + [":man_gesturing_ok::skin-tone-5:"] = "\uD83D\uDE46\uD83C\uDFFF\u200D♂️", + [":man_gesturing_ok_dark_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFF\u200D♂️", + [":man_gesturing_ok_light_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFB\u200D♂️", + [":man_gesturing_ok_medium_dark_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFE\u200D♂️", + [":man_gesturing_ok_medium_light_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFC\u200D♂️", + [":man_gesturing_ok_medium_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFD\u200D♂️", + [":man_gesturing_ok_tone1:"] = "\uD83D\uDE46\uD83C\uDFFB\u200D♂️", + [":man_gesturing_ok_tone2:"] = "\uD83D\uDE46\uD83C\uDFFC\u200D♂️", + [":man_gesturing_ok_tone3:"] = "\uD83D\uDE46\uD83C\uDFFD\u200D♂️", + [":man_gesturing_ok_tone4:"] = "\uD83D\uDE46\uD83C\uDFFE\u200D♂️", + [":man_gesturing_ok_tone5:"] = "\uD83D\uDE46\uD83C\uDFFF\u200D♂️", + [":man_getting_face_massage:"] = "\uD83D\uDC86\u200D♂️", + [":man_getting_face_massage::skin-tone-1:"] = "\uD83D\uDC86\uD83C\uDFFB\u200D♂️", + [":man_getting_face_massage::skin-tone-2:"] = "\uD83D\uDC86\uD83C\uDFFC\u200D♂️", + [":man_getting_face_massage::skin-tone-3:"] = "\uD83D\uDC86\uD83C\uDFFD\u200D♂️", + [":man_getting_face_massage::skin-tone-4:"] = "\uD83D\uDC86\uD83C\uDFFE\u200D♂️", + [":man_getting_face_massage::skin-tone-5:"] = "\uD83D\uDC86\uD83C\uDFFF\u200D♂️", + [":man_getting_face_massage_dark_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFF\u200D♂️", + [":man_getting_face_massage_light_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFB\u200D♂️", + [":man_getting_face_massage_medium_dark_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFE\u200D♂️", + [":man_getting_face_massage_medium_light_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFC\u200D♂️", + [":man_getting_face_massage_medium_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFD\u200D♂️", + [":man_getting_face_massage_tone1:"] = "\uD83D\uDC86\uD83C\uDFFB\u200D♂️", + [":man_getting_face_massage_tone2:"] = "\uD83D\uDC86\uD83C\uDFFC\u200D♂️", + [":man_getting_face_massage_tone3:"] = "\uD83D\uDC86\uD83C\uDFFD\u200D♂️", + [":man_getting_face_massage_tone4:"] = "\uD83D\uDC86\uD83C\uDFFE\u200D♂️", + [":man_getting_face_massage_tone5:"] = "\uD83D\uDC86\uD83C\uDFFF\u200D♂️", + [":man_getting_haircut:"] = "\uD83D\uDC87\u200D♂️", + [":man_getting_haircut::skin-tone-1:"] = "\uD83D\uDC87\uD83C\uDFFB\u200D♂️", + [":man_getting_haircut::skin-tone-2:"] = "\uD83D\uDC87\uD83C\uDFFC\u200D♂️", + [":man_getting_haircut::skin-tone-3:"] = "\uD83D\uDC87\uD83C\uDFFD\u200D♂️", + [":man_getting_haircut::skin-tone-4:"] = "\uD83D\uDC87\uD83C\uDFFE\u200D♂️", + [":man_getting_haircut::skin-tone-5:"] = "\uD83D\uDC87\uD83C\uDFFF\u200D♂️", + [":man_getting_haircut_dark_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFF\u200D♂️", + [":man_getting_haircut_light_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFB\u200D♂️", + [":man_getting_haircut_medium_dark_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFE\u200D♂️", + [":man_getting_haircut_medium_light_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFC\u200D♂️", + [":man_getting_haircut_medium_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFD\u200D♂️", + [":man_getting_haircut_tone1:"] = "\uD83D\uDC87\uD83C\uDFFB\u200D♂️", + [":man_getting_haircut_tone2:"] = "\uD83D\uDC87\uD83C\uDFFC\u200D♂️", + [":man_getting_haircut_tone3:"] = "\uD83D\uDC87\uD83C\uDFFD\u200D♂️", + [":man_getting_haircut_tone4:"] = "\uD83D\uDC87\uD83C\uDFFE\u200D♂️", + [":man_getting_haircut_tone5:"] = "\uD83D\uDC87\uD83C\uDFFF\u200D♂️", + [":man_golfing:"] = "\uD83C\uDFCC️\u200D♂️", + [":man_golfing::skin-tone-1:"] = "\uD83C\uDFCC\uD83C\uDFFB\u200D♂️", + [":man_golfing::skin-tone-2:"] = "\uD83C\uDFCC\uD83C\uDFFC\u200D♂️", + [":man_golfing::skin-tone-3:"] = "\uD83C\uDFCC\uD83C\uDFFD\u200D♂️", + [":man_golfing::skin-tone-4:"] = "\uD83C\uDFCC\uD83C\uDFFE\u200D♂️", + [":man_golfing::skin-tone-5:"] = "\uD83C\uDFCC\uD83C\uDFFF\u200D♂️", + [":man_golfing_dark_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFF\u200D♂️", + [":man_golfing_light_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFB\u200D♂️", + [":man_golfing_medium_dark_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFE\u200D♂️", + [":man_golfing_medium_light_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFC\u200D♂️", + [":man_golfing_medium_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFD\u200D♂️", + [":man_golfing_tone1:"] = "\uD83C\uDFCC\uD83C\uDFFB\u200D♂️", + [":man_golfing_tone2:"] = "\uD83C\uDFCC\uD83C\uDFFC\u200D♂️", + [":man_golfing_tone3:"] = "\uD83C\uDFCC\uD83C\uDFFD\u200D♂️", + [":man_golfing_tone4:"] = "\uD83C\uDFCC\uD83C\uDFFE\u200D♂️", + [":man_golfing_tone5:"] = "\uD83C\uDFCC\uD83C\uDFFF\u200D♂️", + [":man_guard:"] = "\uD83D\uDC82\u200D♂️", + [":man_guard::skin-tone-1:"] = "\uD83D\uDC82\uD83C\uDFFB\u200D♂️", + [":man_guard::skin-tone-2:"] = "\uD83D\uDC82\uD83C\uDFFC\u200D♂️", + [":man_guard::skin-tone-3:"] = "\uD83D\uDC82\uD83C\uDFFD\u200D♂️", + [":man_guard::skin-tone-4:"] = "\uD83D\uDC82\uD83C\uDFFE\u200D♂️", + [":man_guard::skin-tone-5:"] = "\uD83D\uDC82\uD83C\uDFFF\u200D♂️", + [":man_guard_dark_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFF\u200D♂️", + [":man_guard_light_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFB\u200D♂️", + [":man_guard_medium_dark_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFE\u200D♂️", + [":man_guard_medium_light_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFC\u200D♂️", + [":man_guard_medium_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFD\u200D♂️", + [":man_guard_tone1:"] = "\uD83D\uDC82\uD83C\uDFFB\u200D♂️", + [":man_guard_tone2:"] = "\uD83D\uDC82\uD83C\uDFFC\u200D♂️", + [":man_guard_tone3:"] = "\uD83D\uDC82\uD83C\uDFFD\u200D♂️", + [":man_guard_tone4:"] = "\uD83D\uDC82\uD83C\uDFFE\u200D♂️", + [":man_guard_tone5:"] = "\uD83D\uDC82\uD83C\uDFFF\u200D♂️", + [":man_health_worker:"] = "\uD83D\uDC68\u200D⚕️", + [":man_health_worker::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D⚕️", + [":man_health_worker::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D⚕️", + [":man_health_worker::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D⚕️", + [":man_health_worker::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D⚕️", + [":man_health_worker::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D⚕️", + [":man_health_worker_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D⚕️", + [":man_health_worker_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D⚕️", + [":man_health_worker_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D⚕️", + [":man_health_worker_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D⚕️", + [":man_health_worker_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D⚕️", + [":man_health_worker_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D⚕️", + [":man_health_worker_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D⚕️", + [":man_health_worker_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D⚕️", + [":man_health_worker_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D⚕️", + [":man_health_worker_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D⚕️", + [":man_in_business_suit_levitating:"] = "\uD83D\uDD74️", + [":man_in_business_suit_levitating::skin-tone-1:"] = "\uD83D\uDD74\uD83C\uDFFB", + [":man_in_business_suit_levitating::skin-tone-2:"] = "\uD83D\uDD74\uD83C\uDFFC", + [":man_in_business_suit_levitating::skin-tone-3:"] = "\uD83D\uDD74\uD83C\uDFFD", + [":man_in_business_suit_levitating::skin-tone-4:"] = "\uD83D\uDD74\uD83C\uDFFE", + [":man_in_business_suit_levitating::skin-tone-5:"] = "\uD83D\uDD74\uD83C\uDFFF", + [":man_in_business_suit_levitating_dark_skin_tone:"] = "\uD83D\uDD74\uD83C\uDFFF", + [":man_in_business_suit_levitating_light_skin_tone:"] = "\uD83D\uDD74\uD83C\uDFFB", + [":man_in_business_suit_levitating_medium_dark_skin_tone:"] = "\uD83D\uDD74\uD83C\uDFFE", + [":man_in_business_suit_levitating_medium_light_skin_tone:"] = "\uD83D\uDD74\uD83C\uDFFC", + [":man_in_business_suit_levitating_medium_skin_tone:"] = "\uD83D\uDD74\uD83C\uDFFD", + [":man_in_business_suit_levitating_tone1:"] = "\uD83D\uDD74\uD83C\uDFFB", + [":man_in_business_suit_levitating_tone2:"] = "\uD83D\uDD74\uD83C\uDFFC", + [":man_in_business_suit_levitating_tone3:"] = "\uD83D\uDD74\uD83C\uDFFD", + [":man_in_business_suit_levitating_tone4:"] = "\uD83D\uDD74\uD83C\uDFFE", + [":man_in_business_suit_levitating_tone5:"] = "\uD83D\uDD74\uD83C\uDFFF", + [":man_in_lotus_position:"] = "\uD83E\uDDD8\u200D♂️", + [":man_in_lotus_position::skin-tone-1:"] = "\uD83E\uDDD8\uD83C\uDFFB\u200D♂️", + [":man_in_lotus_position::skin-tone-2:"] = "\uD83E\uDDD8\uD83C\uDFFC\u200D♂️", + [":man_in_lotus_position::skin-tone-3:"] = "\uD83E\uDDD8\uD83C\uDFFD\u200D♂️", + [":man_in_lotus_position::skin-tone-4:"] = "\uD83E\uDDD8\uD83C\uDFFE\u200D♂️", + [":man_in_lotus_position::skin-tone-5:"] = "\uD83E\uDDD8\uD83C\uDFFF\u200D♂️", + [":man_in_lotus_position_dark_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFF\u200D♂️", + [":man_in_lotus_position_light_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFB\u200D♂️", + [":man_in_lotus_position_medium_dark_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFE\u200D♂️", + [":man_in_lotus_position_medium_light_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFC\u200D♂️", + [":man_in_lotus_position_medium_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFD\u200D♂️", + [":man_in_lotus_position_tone1:"] = "\uD83E\uDDD8\uD83C\uDFFB\u200D♂️", + [":man_in_lotus_position_tone2:"] = "\uD83E\uDDD8\uD83C\uDFFC\u200D♂️", + [":man_in_lotus_position_tone3:"] = "\uD83E\uDDD8\uD83C\uDFFD\u200D♂️", + [":man_in_lotus_position_tone4:"] = "\uD83E\uDDD8\uD83C\uDFFE\u200D♂️", + [":man_in_lotus_position_tone5:"] = "\uD83E\uDDD8\uD83C\uDFFF\u200D♂️", + [":man_in_manual_wheelchair:"] = "\uD83D\uDC68\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDBD", + [":man_in_manual_wheelchair_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDBD", + [":man_in_motorized_wheelchair:"] = "\uD83D\uDC68\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDBC", + [":man_in_motorized_wheelchair_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDBC", + [":man_in_steamy_room:"] = "\uD83E\uDDD6\u200D♂️", + [":man_in_steamy_room::skin-tone-1:"] = "\uD83E\uDDD6\uD83C\uDFFB\u200D♂️", + [":man_in_steamy_room::skin-tone-2:"] = "\uD83E\uDDD6\uD83C\uDFFC\u200D♂️", + [":man_in_steamy_room::skin-tone-3:"] = "\uD83E\uDDD6\uD83C\uDFFD\u200D♂️", + [":man_in_steamy_room::skin-tone-4:"] = "\uD83E\uDDD6\uD83C\uDFFE\u200D♂️", + [":man_in_steamy_room::skin-tone-5:"] = "\uD83E\uDDD6\uD83C\uDFFF\u200D♂️", + [":man_in_steamy_room_dark_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFF\u200D♂️", + [":man_in_steamy_room_light_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFB\u200D♂️", + [":man_in_steamy_room_medium_dark_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFE\u200D♂️", + [":man_in_steamy_room_medium_light_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFC\u200D♂️", + [":man_in_steamy_room_medium_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFD\u200D♂️", + [":man_in_steamy_room_tone1:"] = "\uD83E\uDDD6\uD83C\uDFFB\u200D♂️", + [":man_in_steamy_room_tone2:"] = "\uD83E\uDDD6\uD83C\uDFFC\u200D♂️", + [":man_in_steamy_room_tone3:"] = "\uD83E\uDDD6\uD83C\uDFFD\u200D♂️", + [":man_in_steamy_room_tone4:"] = "\uD83E\uDDD6\uD83C\uDFFE\u200D♂️", + [":man_in_steamy_room_tone5:"] = "\uD83E\uDDD6\uD83C\uDFFF\u200D♂️", + [":man_in_tuxedo:"] = "\uD83E\uDD35", + [":man_in_tuxedo:"] = "\uD83E\uDD35\u200D\u2642\uFE0F", + [":man_in_tuxedo::skin-tone-1:"] = "\uD83E\uDD35\uD83C\uDFFB", + [":man_in_tuxedo::skin-tone-1:"] = "\uD83E\uDD35\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_in_tuxedo::skin-tone-2:"] = "\uD83E\uDD35\uD83C\uDFFC", + [":man_in_tuxedo::skin-tone-2:"] = "\uD83E\uDD35\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_in_tuxedo::skin-tone-3:"] = "\uD83E\uDD35\uD83C\uDFFD", + [":man_in_tuxedo::skin-tone-3:"] = "\uD83E\uDD35\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_in_tuxedo::skin-tone-4:"] = "\uD83E\uDD35\uD83C\uDFFE", + [":man_in_tuxedo::skin-tone-4:"] = "\uD83E\uDD35\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_in_tuxedo::skin-tone-5:"] = "\uD83E\uDD35\uD83C\uDFFF", + [":man_in_tuxedo::skin-tone-5:"] = "\uD83E\uDD35\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_in_tuxedo_dark_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_in_tuxedo_light_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_in_tuxedo_medium_dark_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_in_tuxedo_medium_light_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_in_tuxedo_medium_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_in_tuxedo_tone1:"] = "\uD83E\uDD35\uD83C\uDFFB", + [":man_in_tuxedo_tone1:"] = "\uD83E\uDD35\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_in_tuxedo_tone2:"] = "\uD83E\uDD35\uD83C\uDFFC", + [":man_in_tuxedo_tone2:"] = "\uD83E\uDD35\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_in_tuxedo_tone3:"] = "\uD83E\uDD35\uD83C\uDFFD", + [":man_in_tuxedo_tone3:"] = "\uD83E\uDD35\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_in_tuxedo_tone4:"] = "\uD83E\uDD35\uD83C\uDFFE", + [":man_in_tuxedo_tone4:"] = "\uD83E\uDD35\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_in_tuxedo_tone5:"] = "\uD83E\uDD35\uD83C\uDFFF", + [":man_in_tuxedo_tone5:"] = "\uD83E\uDD35\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_judge:"] = "\uD83D\uDC68\u200D⚖️", + [":man_judge::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D⚖️", + [":man_judge::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D⚖️", + [":man_judge::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D⚖️", + [":man_judge::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D⚖️", + [":man_judge::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D⚖️", + [":man_judge_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D⚖️", + [":man_judge_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D⚖️", + [":man_judge_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D⚖️", + [":man_judge_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D⚖️", + [":man_judge_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D⚖️", + [":man_judge_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D⚖️", + [":man_judge_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D⚖️", + [":man_judge_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D⚖️", + [":man_judge_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D⚖️", + [":man_judge_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D⚖️", + [":man_juggling:"] = "\uD83E\uDD39\u200D♂️", + [":man_juggling::skin-tone-1:"] = "\uD83E\uDD39\uD83C\uDFFB\u200D♂️", + [":man_juggling::skin-tone-2:"] = "\uD83E\uDD39\uD83C\uDFFC\u200D♂️", + [":man_juggling::skin-tone-3:"] = "\uD83E\uDD39\uD83C\uDFFD\u200D♂️", + [":man_juggling::skin-tone-4:"] = "\uD83E\uDD39\uD83C\uDFFE\u200D♂️", + [":man_juggling::skin-tone-5:"] = "\uD83E\uDD39\uD83C\uDFFF\u200D♂️", + [":man_juggling_dark_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFF\u200D♂️", + [":man_juggling_light_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFB\u200D♂️", + [":man_juggling_medium_dark_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFE\u200D♂️", + [":man_juggling_medium_light_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFC\u200D♂️", + [":man_juggling_medium_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFD\u200D♂️", + [":man_juggling_tone1:"] = "\uD83E\uDD39\uD83C\uDFFB\u200D♂️", + [":man_juggling_tone2:"] = "\uD83E\uDD39\uD83C\uDFFC\u200D♂️", + [":man_juggling_tone3:"] = "\uD83E\uDD39\uD83C\uDFFD\u200D♂️", + [":man_juggling_tone4:"] = "\uD83E\uDD39\uD83C\uDFFE\u200D♂️", + [":man_juggling_tone5:"] = "\uD83E\uDD39\uD83C\uDFFF\u200D♂️", + [":man_kneeling:"] = "\uD83E\uDDCE\u200D♂️", + [":man_kneeling::skin-tone-1:"] = "\uD83E\uDDCE\uD83C\uDFFB\u200D♂️", + [":man_kneeling::skin-tone-2:"] = "\uD83E\uDDCE\uD83C\uDFFC\u200D♂️", + [":man_kneeling::skin-tone-3:"] = "\uD83E\uDDCE\uD83C\uDFFD\u200D♂️", + [":man_kneeling::skin-tone-4:"] = "\uD83E\uDDCE\uD83C\uDFFE\u200D♂️", + [":man_kneeling::skin-tone-5:"] = "\uD83E\uDDCE\uD83C\uDFFF\u200D♂️", + [":man_kneeling_dark_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFF\u200D♂️", + [":man_kneeling_light_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFB\u200D♂️", + [":man_kneeling_medium_dark_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFE\u200D♂️", + [":man_kneeling_medium_light_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFC\u200D♂️", + [":man_kneeling_medium_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFD\u200D♂️", + [":man_kneeling_tone1:"] = "\uD83E\uDDCE\uD83C\uDFFB\u200D♂️", + [":man_kneeling_tone2:"] = "\uD83E\uDDCE\uD83C\uDFFC\u200D♂️", + [":man_kneeling_tone3:"] = "\uD83E\uDDCE\uD83C\uDFFD\u200D♂️", + [":man_kneeling_tone4:"] = "\uD83E\uDDCE\uD83C\uDFFE\u200D♂️", + [":man_kneeling_tone5:"] = "\uD83E\uDDCE\uD83C\uDFFF\u200D♂️", + [":man_lifting_weights:"] = "\uD83C\uDFCB️\u200D♂️", + [":man_lifting_weights::skin-tone-1:"] = "\uD83C\uDFCB\uD83C\uDFFB\u200D♂️", + [":man_lifting_weights::skin-tone-2:"] = "\uD83C\uDFCB\uD83C\uDFFC\u200D♂️", + [":man_lifting_weights::skin-tone-3:"] = "\uD83C\uDFCB\uD83C\uDFFD\u200D♂️", + [":man_lifting_weights::skin-tone-4:"] = "\uD83C\uDFCB\uD83C\uDFFE\u200D♂️", + [":man_lifting_weights::skin-tone-5:"] = "\uD83C\uDFCB\uD83C\uDFFF\u200D♂️", + [":man_lifting_weights_dark_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFF\u200D♂️", + [":man_lifting_weights_light_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFB\u200D♂️", + [":man_lifting_weights_medium_dark_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFE\u200D♂️", + [":man_lifting_weights_medium_light_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFC\u200D♂️", + [":man_lifting_weights_medium_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFD\u200D♂️", + [":man_lifting_weights_tone1:"] = "\uD83C\uDFCB\uD83C\uDFFB\u200D♂️", + [":man_lifting_weights_tone2:"] = "\uD83C\uDFCB\uD83C\uDFFC\u200D♂️", + [":man_lifting_weights_tone3:"] = "\uD83C\uDFCB\uD83C\uDFFD\u200D♂️", + [":man_lifting_weights_tone4:"] = "\uD83C\uDFCB\uD83C\uDFFE\u200D♂️", + [":man_lifting_weights_tone5:"] = "\uD83C\uDFCB\uD83C\uDFFF\u200D♂️", + [":man_light_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_mage:"] = "\uD83E\uDDD9\u200D♂️", + [":man_mage::skin-tone-1:"] = "\uD83E\uDDD9\uD83C\uDFFB\u200D♂️", + [":man_mage::skin-tone-2:"] = "\uD83E\uDDD9\uD83C\uDFFC\u200D♂️", + [":man_mage::skin-tone-3:"] = "\uD83E\uDDD9\uD83C\uDFFD\u200D♂️", + [":man_mage::skin-tone-4:"] = "\uD83E\uDDD9\uD83C\uDFFE\u200D♂️", + [":man_mage::skin-tone-5:"] = "\uD83E\uDDD9\uD83C\uDFFF\u200D♂️", + [":man_mage_dark_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFF\u200D♂️", + [":man_mage_light_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFB\u200D♂️", + [":man_mage_medium_dark_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFE\u200D♂️", + [":man_mage_medium_light_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFC\u200D♂️", + [":man_mage_medium_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFD\u200D♂️", + [":man_mage_tone1:"] = "\uD83E\uDDD9\uD83C\uDFFB\u200D♂️", + [":man_mage_tone2:"] = "\uD83E\uDDD9\uD83C\uDFFC\u200D♂️", + [":man_mage_tone3:"] = "\uD83E\uDDD9\uD83C\uDFFD\u200D♂️", + [":man_mage_tone4:"] = "\uD83E\uDDD9\uD83C\uDFFE\u200D♂️", + [":man_mage_tone5:"] = "\uD83E\uDDD9\uD83C\uDFFF\u200D♂️", + [":man_mechanic:"] = "\uD83D\uDC68\u200D\uD83D\uDD27", + [":man_mechanic::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDD27", + [":man_mechanic::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDD27", + [":man_mechanic::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDD27", + [":man_mechanic::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDD27", + [":man_mechanic::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDD27", + [":man_mechanic_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDD27", + [":man_mechanic_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDD27", + [":man_mechanic_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDD27", + [":man_mechanic_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDD27", + [":man_mechanic_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDD27", + [":man_mechanic_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDD27", + [":man_mechanic_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDD27", + [":man_mechanic_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDD27", + [":man_mechanic_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDD27", + [":man_mechanic_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDD27", + [":man_medium_dark_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_medium_light_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_medium_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_mountain_biking:"] = "\uD83D\uDEB5\u200D♂️", + [":man_mountain_biking::skin-tone-1:"] = "\uD83D\uDEB5\uD83C\uDFFB\u200D♂️", + [":man_mountain_biking::skin-tone-2:"] = "\uD83D\uDEB5\uD83C\uDFFC\u200D♂️", + [":man_mountain_biking::skin-tone-3:"] = "\uD83D\uDEB5\uD83C\uDFFD\u200D♂️", + [":man_mountain_biking::skin-tone-4:"] = "\uD83D\uDEB5\uD83C\uDFFE\u200D♂️", + [":man_mountain_biking::skin-tone-5:"] = "\uD83D\uDEB5\uD83C\uDFFF\u200D♂️", + [":man_mountain_biking_dark_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFF\u200D♂️", + [":man_mountain_biking_light_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFB\u200D♂️", + [":man_mountain_biking_medium_dark_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFE\u200D♂️", + [":man_mountain_biking_medium_light_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFC\u200D♂️", + [":man_mountain_biking_medium_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFD\u200D♂️", + [":man_mountain_biking_tone1:"] = "\uD83D\uDEB5\uD83C\uDFFB\u200D♂️", + [":man_mountain_biking_tone2:"] = "\uD83D\uDEB5\uD83C\uDFFC\u200D♂️", + [":man_mountain_biking_tone3:"] = "\uD83D\uDEB5\uD83C\uDFFD\u200D♂️", + [":man_mountain_biking_tone4:"] = "\uD83D\uDEB5\uD83C\uDFFE\u200D♂️", + [":man_mountain_biking_tone5:"] = "\uD83D\uDEB5\uD83C\uDFFF\u200D♂️", + [":man_office_worker:"] = "\uD83D\uDC68\u200D\uD83D\uDCBC", + [":man_office_worker::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDCBC", + [":man_office_worker::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDCBC", + [":man_office_worker::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDCBC", + [":man_office_worker::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDCBC", + [":man_office_worker::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDCBC", + [":man_office_worker_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDCBC", + [":man_office_worker_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDCBC", + [":man_office_worker_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDCBC", + [":man_office_worker_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDCBC", + [":man_office_worker_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDCBC", + [":man_office_worker_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDCBC", + [":man_office_worker_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDCBC", + [":man_office_worker_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDCBC", + [":man_office_worker_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDCBC", + [":man_office_worker_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDCBC", + [":man_pilot:"] = "\uD83D\uDC68\u200D✈️", + [":man_pilot::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D✈️", + [":man_pilot::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D✈️", + [":man_pilot::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D✈️", + [":man_pilot::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D✈️", + [":man_pilot::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D✈️", + [":man_pilot_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D✈️", + [":man_pilot_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D✈️", + [":man_pilot_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D✈️", + [":man_pilot_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D✈️", + [":man_pilot_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D✈️", + [":man_pilot_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D✈️", + [":man_pilot_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D✈️", + [":man_pilot_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D✈️", + [":man_pilot_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D✈️", + [":man_pilot_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D✈️", + [":man_playing_handball:"] = "\uD83E\uDD3E\u200D♂️", + [":man_playing_handball::skin-tone-1:"] = "\uD83E\uDD3E\uD83C\uDFFB\u200D♂️", + [":man_playing_handball::skin-tone-2:"] = "\uD83E\uDD3E\uD83C\uDFFC\u200D♂️", + [":man_playing_handball::skin-tone-3:"] = "\uD83E\uDD3E\uD83C\uDFFD\u200D♂️", + [":man_playing_handball::skin-tone-4:"] = "\uD83E\uDD3E\uD83C\uDFFE\u200D♂️", + [":man_playing_handball::skin-tone-5:"] = "\uD83E\uDD3E\uD83C\uDFFF\u200D♂️", + [":man_playing_handball_dark_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFF\u200D♂️", + [":man_playing_handball_light_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFB\u200D♂️", + [":man_playing_handball_medium_dark_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFE\u200D♂️", + [":man_playing_handball_medium_light_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFC\u200D♂️", + [":man_playing_handball_medium_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFD\u200D♂️", + [":man_playing_handball_tone1:"] = "\uD83E\uDD3E\uD83C\uDFFB\u200D♂️", + [":man_playing_handball_tone2:"] = "\uD83E\uDD3E\uD83C\uDFFC\u200D♂️", + [":man_playing_handball_tone3:"] = "\uD83E\uDD3E\uD83C\uDFFD\u200D♂️", + [":man_playing_handball_tone4:"] = "\uD83E\uDD3E\uD83C\uDFFE\u200D♂️", + [":man_playing_handball_tone5:"] = "\uD83E\uDD3E\uD83C\uDFFF\u200D♂️", + [":man_playing_water_polo:"] = "\uD83E\uDD3D\u200D♂️", + [":man_playing_water_polo::skin-tone-1:"] = "\uD83E\uDD3D\uD83C\uDFFB\u200D♂️", + [":man_playing_water_polo::skin-tone-2:"] = "\uD83E\uDD3D\uD83C\uDFFC\u200D♂️", + [":man_playing_water_polo::skin-tone-3:"] = "\uD83E\uDD3D\uD83C\uDFFD\u200D♂️", + [":man_playing_water_polo::skin-tone-4:"] = "\uD83E\uDD3D\uD83C\uDFFE\u200D♂️", + [":man_playing_water_polo::skin-tone-5:"] = "\uD83E\uDD3D\uD83C\uDFFF\u200D♂️", + [":man_playing_water_polo_dark_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFF\u200D♂️", + [":man_playing_water_polo_light_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFB\u200D♂️", + [":man_playing_water_polo_medium_dark_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFE\u200D♂️", + [":man_playing_water_polo_medium_light_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFC\u200D♂️", + [":man_playing_water_polo_medium_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFD\u200D♂️", + [":man_playing_water_polo_tone1:"] = "\uD83E\uDD3D\uD83C\uDFFB\u200D♂️", + [":man_playing_water_polo_tone2:"] = "\uD83E\uDD3D\uD83C\uDFFC\u200D♂️", + [":man_playing_water_polo_tone3:"] = "\uD83E\uDD3D\uD83C\uDFFD\u200D♂️", + [":man_playing_water_polo_tone4:"] = "\uD83E\uDD3D\uD83C\uDFFE\u200D♂️", + [":man_playing_water_polo_tone5:"] = "\uD83E\uDD3D\uD83C\uDFFF\u200D♂️", + [":man_police_officer:"] = "\uD83D\uDC6E\u200D♂️", + [":man_police_officer::skin-tone-1:"] = "\uD83D\uDC6E\uD83C\uDFFB\u200D♂️", + [":man_police_officer::skin-tone-2:"] = "\uD83D\uDC6E\uD83C\uDFFC\u200D♂️", + [":man_police_officer::skin-tone-3:"] = "\uD83D\uDC6E\uD83C\uDFFD\u200D♂️", + [":man_police_officer::skin-tone-4:"] = "\uD83D\uDC6E\uD83C\uDFFE\u200D♂️", + [":man_police_officer::skin-tone-5:"] = "\uD83D\uDC6E\uD83C\uDFFF\u200D♂️", + [":man_police_officer_dark_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFF\u200D♂️", + [":man_police_officer_light_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFB\u200D♂️", + [":man_police_officer_medium_dark_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFE\u200D♂️", + [":man_police_officer_medium_light_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFC\u200D♂️", + [":man_police_officer_medium_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFD\u200D♂️", + [":man_police_officer_tone1:"] = "\uD83D\uDC6E\uD83C\uDFFB\u200D♂️", + [":man_police_officer_tone2:"] = "\uD83D\uDC6E\uD83C\uDFFC\u200D♂️", + [":man_police_officer_tone3:"] = "\uD83D\uDC6E\uD83C\uDFFD\u200D♂️", + [":man_police_officer_tone4:"] = "\uD83D\uDC6E\uD83C\uDFFE\u200D♂️", + [":man_police_officer_tone5:"] = "\uD83D\uDC6E\uD83C\uDFFF\u200D♂️", + [":man_pouting:"] = "\uD83D\uDE4E\u200D♂️", + [":man_pouting::skin-tone-1:"] = "\uD83D\uDE4E\uD83C\uDFFB\u200D♂️", + [":man_pouting::skin-tone-2:"] = "\uD83D\uDE4E\uD83C\uDFFC\u200D♂️", + [":man_pouting::skin-tone-3:"] = "\uD83D\uDE4E\uD83C\uDFFD\u200D♂️", + [":man_pouting::skin-tone-4:"] = "\uD83D\uDE4E\uD83C\uDFFE\u200D♂️", + [":man_pouting::skin-tone-5:"] = "\uD83D\uDE4E\uD83C\uDFFF\u200D♂️", + [":man_pouting_dark_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFF\u200D♂️", + [":man_pouting_light_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFB\u200D♂️", + [":man_pouting_medium_dark_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFE\u200D♂️", + [":man_pouting_medium_light_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFC\u200D♂️", + [":man_pouting_medium_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFD\u200D♂️", + [":man_pouting_tone1:"] = "\uD83D\uDE4E\uD83C\uDFFB\u200D♂️", + [":man_pouting_tone2:"] = "\uD83D\uDE4E\uD83C\uDFFC\u200D♂️", + [":man_pouting_tone3:"] = "\uD83D\uDE4E\uD83C\uDFFD\u200D♂️", + [":man_pouting_tone4:"] = "\uD83D\uDE4E\uD83C\uDFFE\u200D♂️", + [":man_pouting_tone5:"] = "\uD83D\uDE4E\uD83C\uDFFF\u200D♂️", + [":man_raising_hand:"] = "\uD83D\uDE4B\u200D♂️", + [":man_raising_hand::skin-tone-1:"] = "\uD83D\uDE4B\uD83C\uDFFB\u200D♂️", + [":man_raising_hand::skin-tone-2:"] = "\uD83D\uDE4B\uD83C\uDFFC\u200D♂️", + [":man_raising_hand::skin-tone-3:"] = "\uD83D\uDE4B\uD83C\uDFFD\u200D♂️", + [":man_raising_hand::skin-tone-4:"] = "\uD83D\uDE4B\uD83C\uDFFE\u200D♂️", + [":man_raising_hand::skin-tone-5:"] = "\uD83D\uDE4B\uD83C\uDFFF\u200D♂️", + [":man_raising_hand_dark_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFF\u200D♂️", + [":man_raising_hand_light_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFB\u200D♂️", + [":man_raising_hand_medium_dark_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFE\u200D♂️", + [":man_raising_hand_medium_light_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFC\u200D♂️", + [":man_raising_hand_medium_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFD\u200D♂️", + [":man_raising_hand_tone1:"] = "\uD83D\uDE4B\uD83C\uDFFB\u200D♂️", + [":man_raising_hand_tone2:"] = "\uD83D\uDE4B\uD83C\uDFFC\u200D♂️", + [":man_raising_hand_tone3:"] = "\uD83D\uDE4B\uD83C\uDFFD\u200D♂️", + [":man_raising_hand_tone4:"] = "\uD83D\uDE4B\uD83C\uDFFE\u200D♂️", + [":man_raising_hand_tone5:"] = "\uD83D\uDE4B\uD83C\uDFFF\u200D♂️", + [":man_red_haired:"] = "\uD83D\uDC68\u200D\uD83E\uDDB0", + [":man_red_haired::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB0", + [":man_red_haired::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB0", + [":man_red_haired::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB0", + [":man_red_haired::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB0", + [":man_red_haired::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB0", + [":man_red_haired_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB0", + [":man_red_haired_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB0", + [":man_red_haired_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB0", + [":man_red_haired_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB0", + [":man_red_haired_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB0", + [":man_red_haired_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB0", + [":man_red_haired_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB0", + [":man_red_haired_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB0", + [":man_red_haired_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB0", + [":man_red_haired_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB0", + [":man_rowing_boat:"] = "\uD83D\uDEA3\u200D♂️", + [":man_rowing_boat::skin-tone-1:"] = "\uD83D\uDEA3\uD83C\uDFFB\u200D♂️", + [":man_rowing_boat::skin-tone-2:"] = "\uD83D\uDEA3\uD83C\uDFFC\u200D♂️", + [":man_rowing_boat::skin-tone-3:"] = "\uD83D\uDEA3\uD83C\uDFFD\u200D♂️", + [":man_rowing_boat::skin-tone-4:"] = "\uD83D\uDEA3\uD83C\uDFFE\u200D♂️", + [":man_rowing_boat::skin-tone-5:"] = "\uD83D\uDEA3\uD83C\uDFFF\u200D♂️", + [":man_rowing_boat_dark_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFF\u200D♂️", + [":man_rowing_boat_light_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFB\u200D♂️", + [":man_rowing_boat_medium_dark_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFE\u200D♂️", + [":man_rowing_boat_medium_light_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFC\u200D♂️", + [":man_rowing_boat_medium_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFD\u200D♂️", + [":man_rowing_boat_tone1:"] = "\uD83D\uDEA3\uD83C\uDFFB\u200D♂️", + [":man_rowing_boat_tone2:"] = "\uD83D\uDEA3\uD83C\uDFFC\u200D♂️", + [":man_rowing_boat_tone3:"] = "\uD83D\uDEA3\uD83C\uDFFD\u200D♂️", + [":man_rowing_boat_tone4:"] = "\uD83D\uDEA3\uD83C\uDFFE\u200D♂️", + [":man_rowing_boat_tone5:"] = "\uD83D\uDEA3\uD83C\uDFFF\u200D♂️", + [":man_running:"] = "\uD83C\uDFC3\u200D♂️", + [":man_running::skin-tone-1:"] = "\uD83C\uDFC3\uD83C\uDFFB\u200D♂️", + [":man_running::skin-tone-2:"] = "\uD83C\uDFC3\uD83C\uDFFC\u200D♂️", + [":man_running::skin-tone-3:"] = "\uD83C\uDFC3\uD83C\uDFFD\u200D♂️", + [":man_running::skin-tone-4:"] = "\uD83C\uDFC3\uD83C\uDFFE\u200D♂️", + [":man_running::skin-tone-5:"] = "\uD83C\uDFC3\uD83C\uDFFF\u200D♂️", + [":man_running_dark_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFF\u200D♂️", + [":man_running_light_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFB\u200D♂️", + [":man_running_medium_dark_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFE\u200D♂️", + [":man_running_medium_light_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFC\u200D♂️", + [":man_running_medium_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFD\u200D♂️", + [":man_running_tone1:"] = "\uD83C\uDFC3\uD83C\uDFFB\u200D♂️", + [":man_running_tone2:"] = "\uD83C\uDFC3\uD83C\uDFFC\u200D♂️", + [":man_running_tone3:"] = "\uD83C\uDFC3\uD83C\uDFFD\u200D♂️", + [":man_running_tone4:"] = "\uD83C\uDFC3\uD83C\uDFFE\u200D♂️", + [":man_running_tone5:"] = "\uD83C\uDFC3\uD83C\uDFFF\u200D♂️", + [":man_scientist:"] = "\uD83D\uDC68\u200D\uD83D\uDD2C", + [":man_scientist::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDD2C", + [":man_scientist::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDD2C", + [":man_scientist::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDD2C", + [":man_scientist::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDD2C", + [":man_scientist::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDD2C", + [":man_scientist_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDD2C", + [":man_scientist_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDD2C", + [":man_scientist_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDD2C", + [":man_scientist_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDD2C", + [":man_scientist_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDD2C", + [":man_scientist_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDD2C", + [":man_scientist_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDD2C", + [":man_scientist_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDD2C", + [":man_scientist_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDD2C", + [":man_scientist_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDD2C", + [":man_shrugging:"] = "\uD83E\uDD37\u200D♂️", + [":man_shrugging::skin-tone-1:"] = "\uD83E\uDD37\uD83C\uDFFB\u200D♂️", + [":man_shrugging::skin-tone-2:"] = "\uD83E\uDD37\uD83C\uDFFC\u200D♂️", + [":man_shrugging::skin-tone-3:"] = "\uD83E\uDD37\uD83C\uDFFD\u200D♂️", + [":man_shrugging::skin-tone-4:"] = "\uD83E\uDD37\uD83C\uDFFE\u200D♂️", + [":man_shrugging::skin-tone-5:"] = "\uD83E\uDD37\uD83C\uDFFF\u200D♂️", + [":man_shrugging_dark_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFF\u200D♂️", + [":man_shrugging_light_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFB\u200D♂️", + [":man_shrugging_medium_dark_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFE\u200D♂️", + [":man_shrugging_medium_light_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFC\u200D♂️", + [":man_shrugging_medium_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFD\u200D♂️", + [":man_shrugging_tone1:"] = "\uD83E\uDD37\uD83C\uDFFB\u200D♂️", + [":man_shrugging_tone2:"] = "\uD83E\uDD37\uD83C\uDFFC\u200D♂️", + [":man_shrugging_tone3:"] = "\uD83E\uDD37\uD83C\uDFFD\u200D♂️", + [":man_shrugging_tone4:"] = "\uD83E\uDD37\uD83C\uDFFE\u200D♂️", + [":man_shrugging_tone5:"] = "\uD83E\uDD37\uD83C\uDFFF\u200D♂️", + [":man_singer:"] = "\uD83D\uDC68\u200D\uD83C\uDFA4", + [":man_singer::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFA4", + [":man_singer::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFA4", + [":man_singer::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFA4", + [":man_singer::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFA4", + [":man_singer::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFA4", + [":man_singer_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFA4", + [":man_singer_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFA4", + [":man_singer_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFA4", + [":man_singer_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFA4", + [":man_singer_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFA4", + [":man_singer_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFA4", + [":man_singer_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFA4", + [":man_singer_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFA4", + [":man_singer_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFA4", + [":man_singer_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFA4", + [":man_standing:"] = "\uD83E\uDDCD\u200D♂️", + [":man_standing::skin-tone-1:"] = "\uD83E\uDDCD\uD83C\uDFFB\u200D♂️", + [":man_standing::skin-tone-2:"] = "\uD83E\uDDCD\uD83C\uDFFC\u200D♂️", + [":man_standing::skin-tone-3:"] = "\uD83E\uDDCD\uD83C\uDFFD\u200D♂️", + [":man_standing::skin-tone-4:"] = "\uD83E\uDDCD\uD83C\uDFFE\u200D♂️", + [":man_standing::skin-tone-5:"] = "\uD83E\uDDCD\uD83C\uDFFF\u200D♂️", + [":man_standing_dark_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFF\u200D♂️", + [":man_standing_light_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFB\u200D♂️", + [":man_standing_medium_dark_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFE\u200D♂️", + [":man_standing_medium_light_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFC\u200D♂️", + [":man_standing_medium_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFD\u200D♂️", + [":man_standing_tone1:"] = "\uD83E\uDDCD\uD83C\uDFFB\u200D♂️", + [":man_standing_tone2:"] = "\uD83E\uDDCD\uD83C\uDFFC\u200D♂️", + [":man_standing_tone3:"] = "\uD83E\uDDCD\uD83C\uDFFD\u200D♂️", + [":man_standing_tone4:"] = "\uD83E\uDDCD\uD83C\uDFFE\u200D♂️", + [":man_standing_tone5:"] = "\uD83E\uDDCD\uD83C\uDFFF\u200D♂️", + [":man_student:"] = "\uD83D\uDC68\u200D\uD83C\uDF93", + [":man_student::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF93", + [":man_student::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF93", + [":man_student::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF93", + [":man_student::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF93", + [":man_student::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF93", + [":man_student_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF93", + [":man_student_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF93", + [":man_student_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF93", + [":man_student_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF93", + [":man_student_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF93", + [":man_student_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDF93", + [":man_student_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDF93", + [":man_student_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDF93", + [":man_student_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDF93", + [":man_student_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDF93", + [":man_superhero:"] = "\uD83E\uDDB8\u200D♂️", + [":man_superhero::skin-tone-1:"] = "\uD83E\uDDB8\uD83C\uDFFB\u200D♂️", + [":man_superhero::skin-tone-2:"] = "\uD83E\uDDB8\uD83C\uDFFC\u200D♂️", + [":man_superhero::skin-tone-3:"] = "\uD83E\uDDB8\uD83C\uDFFD\u200D♂️", + [":man_superhero::skin-tone-4:"] = "\uD83E\uDDB8\uD83C\uDFFE\u200D♂️", + [":man_superhero::skin-tone-5:"] = "\uD83E\uDDB8\uD83C\uDFFF\u200D♂️", + [":man_superhero_dark_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFF\u200D♂️", + [":man_superhero_light_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFB\u200D♂️", + [":man_superhero_medium_dark_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFE\u200D♂️", + [":man_superhero_medium_light_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFC\u200D♂️", + [":man_superhero_medium_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFD\u200D♂️", + [":man_superhero_tone1:"] = "\uD83E\uDDB8\uD83C\uDFFB\u200D♂️", + [":man_superhero_tone2:"] = "\uD83E\uDDB8\uD83C\uDFFC\u200D♂️", + [":man_superhero_tone3:"] = "\uD83E\uDDB8\uD83C\uDFFD\u200D♂️", + [":man_superhero_tone4:"] = "\uD83E\uDDB8\uD83C\uDFFE\u200D♂️", + [":man_superhero_tone5:"] = "\uD83E\uDDB8\uD83C\uDFFF\u200D♂️", + [":man_supervillain:"] = "\uD83E\uDDB9\u200D♂️", + [":man_supervillain::skin-tone-1:"] = "\uD83E\uDDB9\uD83C\uDFFB\u200D♂️", + [":man_supervillain::skin-tone-2:"] = "\uD83E\uDDB9\uD83C\uDFFC\u200D♂️", + [":man_supervillain::skin-tone-3:"] = "\uD83E\uDDB9\uD83C\uDFFD\u200D♂️", + [":man_supervillain::skin-tone-4:"] = "\uD83E\uDDB9\uD83C\uDFFE\u200D♂️", + [":man_supervillain::skin-tone-5:"] = "\uD83E\uDDB9\uD83C\uDFFF\u200D♂️", + [":man_supervillain_dark_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFF\u200D♂️", + [":man_supervillain_light_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFB\u200D♂️", + [":man_supervillain_medium_dark_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFE\u200D♂️", + [":man_supervillain_medium_light_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFC\u200D♂️", + [":man_supervillain_medium_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFD\u200D♂️", + [":man_supervillain_tone1:"] = "\uD83E\uDDB9\uD83C\uDFFB\u200D♂️", + [":man_supervillain_tone2:"] = "\uD83E\uDDB9\uD83C\uDFFC\u200D♂️", + [":man_supervillain_tone3:"] = "\uD83E\uDDB9\uD83C\uDFFD\u200D♂️", + [":man_supervillain_tone4:"] = "\uD83E\uDDB9\uD83C\uDFFE\u200D♂️", + [":man_supervillain_tone5:"] = "\uD83E\uDDB9\uD83C\uDFFF\u200D♂️", + [":man_surfing:"] = "\uD83C\uDFC4\u200D♂️", + [":man_surfing::skin-tone-1:"] = "\uD83C\uDFC4\uD83C\uDFFB\u200D♂️", + [":man_surfing::skin-tone-2:"] = "\uD83C\uDFC4\uD83C\uDFFC\u200D♂️", + [":man_surfing::skin-tone-3:"] = "\uD83C\uDFC4\uD83C\uDFFD\u200D♂️", + [":man_surfing::skin-tone-4:"] = "\uD83C\uDFC4\uD83C\uDFFE\u200D♂️", + [":man_surfing::skin-tone-5:"] = "\uD83C\uDFC4\uD83C\uDFFF\u200D♂️", + [":man_surfing_dark_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFF\u200D♂️", + [":man_surfing_light_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFB\u200D♂️", + [":man_surfing_medium_dark_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFE\u200D♂️", + [":man_surfing_medium_light_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFC\u200D♂️", + [":man_surfing_medium_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFD\u200D♂️", + [":man_surfing_tone1:"] = "\uD83C\uDFC4\uD83C\uDFFB\u200D♂️", + [":man_surfing_tone2:"] = "\uD83C\uDFC4\uD83C\uDFFC\u200D♂️", + [":man_surfing_tone3:"] = "\uD83C\uDFC4\uD83C\uDFFD\u200D♂️", + [":man_surfing_tone4:"] = "\uD83C\uDFC4\uD83C\uDFFE\u200D♂️", + [":man_surfing_tone5:"] = "\uD83C\uDFC4\uD83C\uDFFF\u200D♂️", + [":man_swimming:"] = "\uD83C\uDFCA\u200D♂️", + [":man_swimming::skin-tone-1:"] = "\uD83C\uDFCA\uD83C\uDFFB\u200D♂️", + [":man_swimming::skin-tone-2:"] = "\uD83C\uDFCA\uD83C\uDFFC\u200D♂️", + [":man_swimming::skin-tone-3:"] = "\uD83C\uDFCA\uD83C\uDFFD\u200D♂️", + [":man_swimming::skin-tone-4:"] = "\uD83C\uDFCA\uD83C\uDFFE\u200D♂️", + [":man_swimming::skin-tone-5:"] = "\uD83C\uDFCA\uD83C\uDFFF\u200D♂️", + [":man_swimming_dark_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFF\u200D♂️", + [":man_swimming_light_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFB\u200D♂️", + [":man_swimming_medium_dark_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFE\u200D♂️", + [":man_swimming_medium_light_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFC\u200D♂️", + [":man_swimming_medium_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFD\u200D♂️", + [":man_swimming_tone1:"] = "\uD83C\uDFCA\uD83C\uDFFB\u200D♂️", + [":man_swimming_tone2:"] = "\uD83C\uDFCA\uD83C\uDFFC\u200D♂️", + [":man_swimming_tone3:"] = "\uD83C\uDFCA\uD83C\uDFFD\u200D♂️", + [":man_swimming_tone4:"] = "\uD83C\uDFCA\uD83C\uDFFE\u200D♂️", + [":man_swimming_tone5:"] = "\uD83C\uDFCA\uD83C\uDFFF\u200D♂️", + [":man_teacher:"] = "\uD83D\uDC68\u200D\uD83C\uDFEB", + [":man_teacher::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFEB", + [":man_teacher::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFEB", + [":man_teacher::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFEB", + [":man_teacher::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFEB", + [":man_teacher::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFEB", + [":man_teacher_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFEB", + [":man_teacher_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFEB", + [":man_teacher_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFEB", + [":man_teacher_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFEB", + [":man_teacher_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFEB", + [":man_teacher_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83C\uDFEB", + [":man_teacher_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83C\uDFEB", + [":man_teacher_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83C\uDFEB", + [":man_teacher_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83C\uDFEB", + [":man_teacher_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83C\uDFEB", + [":man_technologist:"] = "\uD83D\uDC68\u200D\uD83D\uDCBB", + [":man_technologist::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDCBB", + [":man_technologist::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDCBB", + [":man_technologist::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDCBB", + [":man_technologist::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDCBB", + [":man_technologist::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDCBB", + [":man_technologist_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDCBB", + [":man_technologist_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDCBB", + [":man_technologist_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDCBB", + [":man_technologist_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDCBB", + [":man_technologist_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDCBB", + [":man_technologist_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDCBB", + [":man_technologist_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83D\uDCBB", + [":man_technologist_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83D\uDCBB", + [":man_technologist_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83D\uDCBB", + [":man_technologist_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83D\uDCBB", + [":man_tipping_hand:"] = "\uD83D\uDC81\u200D♂️", + [":man_tipping_hand::skin-tone-1:"] = "\uD83D\uDC81\uD83C\uDFFB\u200D♂️", + [":man_tipping_hand::skin-tone-2:"] = "\uD83D\uDC81\uD83C\uDFFC\u200D♂️", + [":man_tipping_hand::skin-tone-3:"] = "\uD83D\uDC81\uD83C\uDFFD\u200D♂️", + [":man_tipping_hand::skin-tone-4:"] = "\uD83D\uDC81\uD83C\uDFFE\u200D♂️", + [":man_tipping_hand::skin-tone-5:"] = "\uD83D\uDC81\uD83C\uDFFF\u200D♂️", + [":man_tipping_hand_dark_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFF\u200D♂️", + [":man_tipping_hand_light_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFB\u200D♂️", + [":man_tipping_hand_medium_dark_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFE\u200D♂️", + [":man_tipping_hand_medium_light_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFC\u200D♂️", + [":man_tipping_hand_medium_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFD\u200D♂️", + [":man_tipping_hand_tone1:"] = "\uD83D\uDC81\uD83C\uDFFB\u200D♂️", + [":man_tipping_hand_tone2:"] = "\uD83D\uDC81\uD83C\uDFFC\u200D♂️", + [":man_tipping_hand_tone3:"] = "\uD83D\uDC81\uD83C\uDFFD\u200D♂️", + [":man_tipping_hand_tone4:"] = "\uD83D\uDC81\uD83C\uDFFE\u200D♂️", + [":man_tipping_hand_tone5:"] = "\uD83D\uDC81\uD83C\uDFFF\u200D♂️", + [":man_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB", + [":man_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC", + [":man_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD", + [":man_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE", + [":man_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF", + [":man_vampire:"] = "\uD83E\uDDDB\u200D♂️", + [":man_vampire::skin-tone-1:"] = "\uD83E\uDDDB\uD83C\uDFFB\u200D♂️", + [":man_vampire::skin-tone-2:"] = "\uD83E\uDDDB\uD83C\uDFFC\u200D♂️", + [":man_vampire::skin-tone-3:"] = "\uD83E\uDDDB\uD83C\uDFFD\u200D♂️", + [":man_vampire::skin-tone-4:"] = "\uD83E\uDDDB\uD83C\uDFFE\u200D♂️", + [":man_vampire::skin-tone-5:"] = "\uD83E\uDDDB\uD83C\uDFFF\u200D♂️", + [":man_vampire_dark_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFF\u200D♂️", + [":man_vampire_light_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFB\u200D♂️", + [":man_vampire_medium_dark_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFE\u200D♂️", + [":man_vampire_medium_light_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFC\u200D♂️", + [":man_vampire_medium_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFD\u200D♂️", + [":man_vampire_tone1:"] = "\uD83E\uDDDB\uD83C\uDFFB\u200D♂️", + [":man_vampire_tone2:"] = "\uD83E\uDDDB\uD83C\uDFFC\u200D♂️", + [":man_vampire_tone3:"] = "\uD83E\uDDDB\uD83C\uDFFD\u200D♂️", + [":man_vampire_tone4:"] = "\uD83E\uDDDB\uD83C\uDFFE\u200D♂️", + [":man_vampire_tone5:"] = "\uD83E\uDDDB\uD83C\uDFFF\u200D♂️", + [":man_walking:"] = "\uD83D\uDEB6\u200D♂️", + [":man_walking::skin-tone-1:"] = "\uD83D\uDEB6\uD83C\uDFFB\u200D♂️", + [":man_walking::skin-tone-2:"] = "\uD83D\uDEB6\uD83C\uDFFC\u200D♂️", + [":man_walking::skin-tone-3:"] = "\uD83D\uDEB6\uD83C\uDFFD\u200D♂️", + [":man_walking::skin-tone-4:"] = "\uD83D\uDEB6\uD83C\uDFFE\u200D♂️", + [":man_walking::skin-tone-5:"] = "\uD83D\uDEB6\uD83C\uDFFF\u200D♂️", + [":man_walking_dark_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFF\u200D♂️", + [":man_walking_light_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFB\u200D♂️", + [":man_walking_medium_dark_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFE\u200D♂️", + [":man_walking_medium_light_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFC\u200D♂️", + [":man_walking_medium_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFD\u200D♂️", + [":man_walking_tone1:"] = "\uD83D\uDEB6\uD83C\uDFFB\u200D♂️", + [":man_walking_tone2:"] = "\uD83D\uDEB6\uD83C\uDFFC\u200D♂️", + [":man_walking_tone3:"] = "\uD83D\uDEB6\uD83C\uDFFD\u200D♂️", + [":man_walking_tone4:"] = "\uD83D\uDEB6\uD83C\uDFFE\u200D♂️", + [":man_walking_tone5:"] = "\uD83D\uDEB6\uD83C\uDFFF\u200D♂️", + [":man_wearing_turban:"] = "\uD83D\uDC73\u200D♂️", + [":man_wearing_turban::skin-tone-1:"] = "\uD83D\uDC73\uD83C\uDFFB\u200D♂️", + [":man_wearing_turban::skin-tone-2:"] = "\uD83D\uDC73\uD83C\uDFFC\u200D♂️", + [":man_wearing_turban::skin-tone-3:"] = "\uD83D\uDC73\uD83C\uDFFD\u200D♂️", + [":man_wearing_turban::skin-tone-4:"] = "\uD83D\uDC73\uD83C\uDFFE\u200D♂️", + [":man_wearing_turban::skin-tone-5:"] = "\uD83D\uDC73\uD83C\uDFFF\u200D♂️", + [":man_wearing_turban_dark_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFF\u200D♂️", + [":man_wearing_turban_light_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFB\u200D♂️", + [":man_wearing_turban_medium_dark_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFE\u200D♂️", + [":man_wearing_turban_medium_light_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFC\u200D♂️", + [":man_wearing_turban_medium_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFD\u200D♂️", + [":man_wearing_turban_tone1:"] = "\uD83D\uDC73\uD83C\uDFFB\u200D♂️", + [":man_wearing_turban_tone2:"] = "\uD83D\uDC73\uD83C\uDFFC\u200D♂️", + [":man_wearing_turban_tone3:"] = "\uD83D\uDC73\uD83C\uDFFD\u200D♂️", + [":man_wearing_turban_tone4:"] = "\uD83D\uDC73\uD83C\uDFFE\u200D♂️", + [":man_wearing_turban_tone5:"] = "\uD83D\uDC73\uD83C\uDFFF\u200D♂️", + [":man_white_haired:"] = "\uD83D\uDC68\u200D\uD83E\uDDB3", + [":man_white_haired::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB3", + [":man_white_haired::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB3", + [":man_white_haired::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB3", + [":man_white_haired::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB3", + [":man_white_haired::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB3", + [":man_white_haired_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB3", + [":man_white_haired_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB3", + [":man_white_haired_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB3", + [":man_white_haired_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB3", + [":man_white_haired_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB3", + [":man_white_haired_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDB3", + [":man_white_haired_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDB3", + [":man_white_haired_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDB3", + [":man_white_haired_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDB3", + [":man_white_haired_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB3", + [":man_with_chinese_cap:"] = "\uD83D\uDC72", + [":man_with_chinese_cap::skin-tone-1:"] = "\uD83D\uDC72\uD83C\uDFFB", + [":man_with_chinese_cap::skin-tone-2:"] = "\uD83D\uDC72\uD83C\uDFFC", + [":man_with_chinese_cap::skin-tone-3:"] = "\uD83D\uDC72\uD83C\uDFFD", + [":man_with_chinese_cap::skin-tone-4:"] = "\uD83D\uDC72\uD83C\uDFFE", + [":man_with_chinese_cap::skin-tone-5:"] = "\uD83D\uDC72\uD83C\uDFFF", + [":man_with_chinese_cap_tone1:"] = "\uD83D\uDC72\uD83C\uDFFB", + [":man_with_chinese_cap_tone2:"] = "\uD83D\uDC72\uD83C\uDFFC", + [":man_with_chinese_cap_tone3:"] = "\uD83D\uDC72\uD83C\uDFFD", + [":man_with_chinese_cap_tone4:"] = "\uD83D\uDC72\uD83C\uDFFE", + [":man_with_chinese_cap_tone5:"] = "\uD83D\uDC72\uD83C\uDFFF", + [":man_with_gua_pi_mao:"] = "\uD83D\uDC72", + [":man_with_gua_pi_mao::skin-tone-1:"] = "\uD83D\uDC72\uD83C\uDFFB", + [":man_with_gua_pi_mao::skin-tone-2:"] = "\uD83D\uDC72\uD83C\uDFFC", + [":man_with_gua_pi_mao::skin-tone-3:"] = "\uD83D\uDC72\uD83C\uDFFD", + [":man_with_gua_pi_mao::skin-tone-4:"] = "\uD83D\uDC72\uD83C\uDFFE", + [":man_with_gua_pi_mao::skin-tone-5:"] = "\uD83D\uDC72\uD83C\uDFFF", + [":man_with_gua_pi_mao_tone1:"] = "\uD83D\uDC72\uD83C\uDFFB", + [":man_with_gua_pi_mao_tone2:"] = "\uD83D\uDC72\uD83C\uDFFC", + [":man_with_gua_pi_mao_tone3:"] = "\uD83D\uDC72\uD83C\uDFFD", + [":man_with_gua_pi_mao_tone4:"] = "\uD83D\uDC72\uD83C\uDFFE", + [":man_with_gua_pi_mao_tone5:"] = "\uD83D\uDC72\uD83C\uDFFF", + [":man_with_probing_cane:"] = "\uD83D\uDC68\u200D\uD83E\uDDAF", + [":man_with_probing_cane::skin-tone-1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDAF", + [":man_with_probing_cane::skin-tone-2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDAF", + [":man_with_probing_cane::skin-tone-3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDAF", + [":man_with_probing_cane::skin-tone-4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDAF", + [":man_with_probing_cane::skin-tone-5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDAF", + [":man_with_probing_cane_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDAF", + [":man_with_probing_cane_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDAF", + [":man_with_probing_cane_medium_dark_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDAF", + [":man_with_probing_cane_medium_light_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDAF", + [":man_with_probing_cane_medium_skin_tone:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDAF", + [":man_with_probing_cane_tone1:"] = "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83E\uDDAF", + [":man_with_probing_cane_tone2:"] = "\uD83D\uDC68\uD83C\uDFFC\u200D\uD83E\uDDAF", + [":man_with_probing_cane_tone3:"] = "\uD83D\uDC68\uD83C\uDFFD\u200D\uD83E\uDDAF", + [":man_with_probing_cane_tone4:"] = "\uD83D\uDC68\uD83C\uDFFE\u200D\uD83E\uDDAF", + [":man_with_probing_cane_tone5:"] = "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDAF", + [":man_with_turban:"] = "\uD83D\uDC73", + [":man_with_turban::skin-tone-1:"] = "\uD83D\uDC73\uD83C\uDFFB", + [":man_with_turban::skin-tone-2:"] = "\uD83D\uDC73\uD83C\uDFFC", + [":man_with_turban::skin-tone-3:"] = "\uD83D\uDC73\uD83C\uDFFD", + [":man_with_turban::skin-tone-4:"] = "\uD83D\uDC73\uD83C\uDFFE", + [":man_with_turban::skin-tone-5:"] = "\uD83D\uDC73\uD83C\uDFFF", + [":man_with_turban_tone1:"] = "\uD83D\uDC73\uD83C\uDFFB", + [":man_with_turban_tone2:"] = "\uD83D\uDC73\uD83C\uDFFC", + [":man_with_turban_tone3:"] = "\uD83D\uDC73\uD83C\uDFFD", + [":man_with_turban_tone4:"] = "\uD83D\uDC73\uD83C\uDFFE", + [":man_with_turban_tone5:"] = "\uD83D\uDC73\uD83C\uDFFF", + [":man_with_veil:"] = "\uD83D\uDC70\u200D\u2642\uFE0F", + [":man_with_veil::skin-tone-1:"] = "\uD83D\uDC70\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_with_veil::skin-tone-2:"] = "\uD83D\uDC70\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_with_veil::skin-tone-3:"] = "\uD83D\uDC70\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_with_veil::skin-tone-4:"] = "\uD83D\uDC70\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_with_veil::skin-tone-5:"] = "\uD83D\uDC70\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_with_veil_dark_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_with_veil_light_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_with_veil_medium_dark_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_with_veil_medium_light_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_with_veil_medium_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_with_veil_tone1:"] = "\uD83D\uDC70\uD83C\uDFFB\u200D\u2642\uFE0F", + [":man_with_veil_tone2:"] = "\uD83D\uDC70\uD83C\uDFFC\u200D\u2642\uFE0F", + [":man_with_veil_tone3:"] = "\uD83D\uDC70\uD83C\uDFFD\u200D\u2642\uFE0F", + [":man_with_veil_tone4:"] = "\uD83D\uDC70\uD83C\uDFFE\u200D\u2642\uFE0F", + [":man_with_veil_tone5:"] = "\uD83D\uDC70\uD83C\uDFFF\u200D\u2642\uFE0F", + [":man_zombie:"] = "\uD83E\uDDDF\u200D♂️", + [":mango:"] = "\uD83E\uDD6D", + [":mans_shoe:"] = "\uD83D\uDC5E", + [":mantlepiece_clock:"] = "\uD83D\uDD70️", + [":manual_wheelchair:"] = "\uD83E\uDDBD", + [":map:"] = "\uD83D\uDDFA️", + [":maple_leaf:"] = "\uD83C\uDF41", + [":martial_arts_uniform:"] = "\uD83E\uDD4B", + [":mask:"] = "\uD83D\uDE37", + [":massage:"] = "\uD83D\uDC86", + [":massage::skin-tone-1:"] = "\uD83D\uDC86\uD83C\uDFFB", + [":massage::skin-tone-2:"] = "\uD83D\uDC86\uD83C\uDFFC", + [":massage::skin-tone-3:"] = "\uD83D\uDC86\uD83C\uDFFD", + [":massage::skin-tone-4:"] = "\uD83D\uDC86\uD83C\uDFFE", + [":massage::skin-tone-5:"] = "\uD83D\uDC86\uD83C\uDFFF", + [":massage_tone1:"] = "\uD83D\uDC86\uD83C\uDFFB", + [":massage_tone2:"] = "\uD83D\uDC86\uD83C\uDFFC", + [":massage_tone3:"] = "\uD83D\uDC86\uD83C\uDFFD", + [":massage_tone4:"] = "\uD83D\uDC86\uD83C\uDFFE", + [":massage_tone5:"] = "\uD83D\uDC86\uD83C\uDFFF", + [":mate:"] = "\uD83E\uDDC9", + [":meat_on_bone:"] = "\uD83C\uDF56", + [":mechanical_arm:"] = "\uD83E\uDDBE", + [":mechanical_leg:"] = "\uD83E\uDDBF", + [":medal:"] = "\uD83C\uDFC5", + [":medical_symbol:"] = "⚕️", + [":mega:"] = "\uD83D\uDCE3", + [":melon:"] = "\uD83C\uDF48", + [":melting_face:"] = "\uD83E\uDEE0", + [":memo:"] = "\uD83D\uDCDD", + [":men_with_bunny_ears_partying:"] = "\uD83D\uDC6F\u200D♂️", + [":men_wrestling:"] = "\uD83E\uDD3C\u200D♂️", + [":mending_heart:"] = "\u2764\uFE0F\u200D\uD83E\uDE79", + [":menorah:"] = "\uD83D\uDD4E", + [":mens:"] = "\uD83D\uDEB9", + [":mermaid:"] = "\uD83E\uDDDC\u200D♀️", + [":mermaid::skin-tone-1:"] = "\uD83E\uDDDC\uD83C\uDFFB\u200D♀️", + [":mermaid::skin-tone-2:"] = "\uD83E\uDDDC\uD83C\uDFFC\u200D♀️", + [":mermaid::skin-tone-3:"] = "\uD83E\uDDDC\uD83C\uDFFD\u200D♀️", + [":mermaid::skin-tone-4:"] = "\uD83E\uDDDC\uD83C\uDFFE\u200D♀️", + [":mermaid::skin-tone-5:"] = "\uD83E\uDDDC\uD83C\uDFFF\u200D♀️", + [":mermaid_dark_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFF\u200D♀️", + [":mermaid_light_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFB\u200D♀️", + [":mermaid_medium_dark_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFE\u200D♀️", + [":mermaid_medium_light_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFC\u200D♀️", + [":mermaid_medium_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFD\u200D♀️", + [":mermaid_tone1:"] = "\uD83E\uDDDC\uD83C\uDFFB\u200D♀️", + [":mermaid_tone2:"] = "\uD83E\uDDDC\uD83C\uDFFC\u200D♀️", + [":mermaid_tone3:"] = "\uD83E\uDDDC\uD83C\uDFFD\u200D♀️", + [":mermaid_tone4:"] = "\uD83E\uDDDC\uD83C\uDFFE\u200D♀️", + [":mermaid_tone5:"] = "\uD83E\uDDDC\uD83C\uDFFF\u200D♀️", + [":merman:"] = "\uD83E\uDDDC\u200D♂️", + [":merman::skin-tone-1:"] = "\uD83E\uDDDC\uD83C\uDFFB\u200D♂️", + [":merman::skin-tone-2:"] = "\uD83E\uDDDC\uD83C\uDFFC\u200D♂️", + [":merman::skin-tone-3:"] = "\uD83E\uDDDC\uD83C\uDFFD\u200D♂️", + [":merman::skin-tone-4:"] = "\uD83E\uDDDC\uD83C\uDFFE\u200D♂️", + [":merman::skin-tone-5:"] = "\uD83E\uDDDC\uD83C\uDFFF\u200D♂️", + [":merman_dark_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFF\u200D♂️", + [":merman_light_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFB\u200D♂️", + [":merman_medium_dark_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFE\u200D♂️", + [":merman_medium_light_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFC\u200D♂️", + [":merman_medium_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFD\u200D♂️", + [":merman_tone1:"] = "\uD83E\uDDDC\uD83C\uDFFB\u200D♂️", + [":merman_tone2:"] = "\uD83E\uDDDC\uD83C\uDFFC\u200D♂️", + [":merman_tone3:"] = "\uD83E\uDDDC\uD83C\uDFFD\u200D♂️", + [":merman_tone4:"] = "\uD83E\uDDDC\uD83C\uDFFE\u200D♂️", + [":merman_tone5:"] = "\uD83E\uDDDC\uD83C\uDFFF\u200D♂️", + [":merperson:"] = "\uD83E\uDDDC", + [":merperson::skin-tone-1:"] = "\uD83E\uDDDC\uD83C\uDFFB", + [":merperson::skin-tone-2:"] = "\uD83E\uDDDC\uD83C\uDFFC", + [":merperson::skin-tone-3:"] = "\uD83E\uDDDC\uD83C\uDFFD", + [":merperson::skin-tone-4:"] = "\uD83E\uDDDC\uD83C\uDFFE", + [":merperson::skin-tone-5:"] = "\uD83E\uDDDC\uD83C\uDFFF", + [":merperson_dark_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFF", + [":merperson_light_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFB", + [":merperson_medium_dark_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFE", + [":merperson_medium_light_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFC", + [":merperson_medium_skin_tone:"] = "\uD83E\uDDDC\uD83C\uDFFD", + [":merperson_tone1:"] = "\uD83E\uDDDC\uD83C\uDFFB", + [":merperson_tone2:"] = "\uD83E\uDDDC\uD83C\uDFFC", + [":merperson_tone3:"] = "\uD83E\uDDDC\uD83C\uDFFD", + [":merperson_tone4:"] = "\uD83E\uDDDC\uD83C\uDFFE", + [":merperson_tone5:"] = "\uD83E\uDDDC\uD83C\uDFFF", + [":metal:"] = "\uD83E\uDD18", + [":metal::skin-tone-1:"] = "\uD83E\uDD18\uD83C\uDFFB", + [":metal::skin-tone-2:"] = "\uD83E\uDD18\uD83C\uDFFC", + [":metal::skin-tone-3:"] = "\uD83E\uDD18\uD83C\uDFFD", + [":metal::skin-tone-4:"] = "\uD83E\uDD18\uD83C\uDFFE", + [":metal::skin-tone-5:"] = "\uD83E\uDD18\uD83C\uDFFF", + [":metal_tone1:"] = "\uD83E\uDD18\uD83C\uDFFB", + [":metal_tone2:"] = "\uD83E\uDD18\uD83C\uDFFC", + [":metal_tone3:"] = "\uD83E\uDD18\uD83C\uDFFD", + [":metal_tone4:"] = "\uD83E\uDD18\uD83C\uDFFE", + [":metal_tone5:"] = "\uD83E\uDD18\uD83C\uDFFF", + [":metro:"] = "\uD83D\uDE87", + [":microbe:"] = "\uD83E\uDDA0", + [":microphone2:"] = "\uD83C\uDF99️", + [":microphone:"] = "\uD83C\uDFA4", + [":microscope:"] = "\uD83D\uDD2C", + [":middle_finger:"] = "\uD83D\uDD95", + [":middle_finger::skin-tone-1:"] = "\uD83D\uDD95\uD83C\uDFFB", + [":middle_finger::skin-tone-2:"] = "\uD83D\uDD95\uD83C\uDFFC", + [":middle_finger::skin-tone-3:"] = "\uD83D\uDD95\uD83C\uDFFD", + [":middle_finger::skin-tone-4:"] = "\uD83D\uDD95\uD83C\uDFFE", + [":middle_finger::skin-tone-5:"] = "\uD83D\uDD95\uD83C\uDFFF", + [":middle_finger_tone1:"] = "\uD83D\uDD95\uD83C\uDFFB", + [":middle_finger_tone2:"] = "\uD83D\uDD95\uD83C\uDFFC", + [":middle_finger_tone3:"] = "\uD83D\uDD95\uD83C\uDFFD", + [":middle_finger_tone4:"] = "\uD83D\uDD95\uD83C\uDFFE", + [":middle_finger_tone5:"] = "\uD83D\uDD95\uD83C\uDFFF", + [":military_helmet:"] = "\uD83E\uDE96", + [":military_medal:"] = "\uD83C\uDF96️", + [":milk:"] = "\uD83E\uDD5B", + [":milky_way:"] = "\uD83C\uDF0C", + [":minibus:"] = "\uD83D\uDE90", + [":minidisc:"] = "\uD83D\uDCBD", + [":mirror:"] = "\uD83E\uDE9E", + [":mirror_ball:"] = "\uD83E\uDEA9", + [":mobile_phone_off:"] = "\uD83D\uDCF4", + [":money_mouth:"] = "\uD83E\uDD11", + [":money_mouth_face:"] = "\uD83E\uDD11", + [":money_with_wings:"] = "\uD83D\uDCB8", + [":moneybag:"] = "\uD83D\uDCB0", + [":monkey:"] = "\uD83D\uDC12", + [":monkey_face:"] = "\uD83D\uDC35", + [":monorail:"] = "\uD83D\uDE9D", + [":moon_cake:"] = "\uD83E\uDD6E", + [":mortar_board:"] = "\uD83C\uDF93", + [":mosque:"] = "\uD83D\uDD4C", + [":mosquito:"] = "\uD83E\uDD9F", + [":mother_christmas:"] = "\uD83E\uDD36", + [":mother_christmas::skin-tone-1:"] = "\uD83E\uDD36\uD83C\uDFFB", + [":mother_christmas::skin-tone-2:"] = "\uD83E\uDD36\uD83C\uDFFC", + [":mother_christmas::skin-tone-3:"] = "\uD83E\uDD36\uD83C\uDFFD", + [":mother_christmas::skin-tone-4:"] = "\uD83E\uDD36\uD83C\uDFFE", + [":mother_christmas::skin-tone-5:"] = "\uD83E\uDD36\uD83C\uDFFF", + [":mother_christmas_tone1:"] = "\uD83E\uDD36\uD83C\uDFFB", + [":mother_christmas_tone2:"] = "\uD83E\uDD36\uD83C\uDFFC", + [":mother_christmas_tone3:"] = "\uD83E\uDD36\uD83C\uDFFD", + [":mother_christmas_tone4:"] = "\uD83E\uDD36\uD83C\uDFFE", + [":mother_christmas_tone5:"] = "\uD83E\uDD36\uD83C\uDFFF", + [":motor_scooter:"] = "\uD83D\uDEF5", + [":motorbike:"] = "\uD83D\uDEF5", + [":motorboat:"] = "\uD83D\uDEE5️", + [":motorcycle:"] = "\uD83C\uDFCD️", + [":motorized_wheelchair:"] = "\uD83E\uDDBC", + [":motorway:"] = "\uD83D\uDEE3️", + [":mount_fuji:"] = "\uD83D\uDDFB", + [":mountain:"] = "⛰️", + [":mountain_bicyclist:"] = "\uD83D\uDEB5", + [":mountain_bicyclist::skin-tone-1:"] = "\uD83D\uDEB5\uD83C\uDFFB", + [":mountain_bicyclist::skin-tone-2:"] = "\uD83D\uDEB5\uD83C\uDFFC", + [":mountain_bicyclist::skin-tone-3:"] = "\uD83D\uDEB5\uD83C\uDFFD", + [":mountain_bicyclist::skin-tone-4:"] = "\uD83D\uDEB5\uD83C\uDFFE", + [":mountain_bicyclist::skin-tone-5:"] = "\uD83D\uDEB5\uD83C\uDFFF", + [":mountain_bicyclist_tone1:"] = "\uD83D\uDEB5\uD83C\uDFFB", + [":mountain_bicyclist_tone2:"] = "\uD83D\uDEB5\uD83C\uDFFC", + [":mountain_bicyclist_tone3:"] = "\uD83D\uDEB5\uD83C\uDFFD", + [":mountain_bicyclist_tone4:"] = "\uD83D\uDEB5\uD83C\uDFFE", + [":mountain_bicyclist_tone5:"] = "\uD83D\uDEB5\uD83C\uDFFF", + [":mountain_cableway:"] = "\uD83D\uDEA0", + [":mountain_railway:"] = "\uD83D\uDE9E", + [":mountain_snow:"] = "\uD83C\uDFD4️", + [":mouse2:"] = "\uD83D\uDC01", + [":mouse:"] = "\uD83D\uDC2D", + [":mouse_three_button:"] = "\uD83D\uDDB1️", + [":mouse_trap:"] = "\uD83E\uDEA4", + [":movie_camera:"] = "\uD83C\uDFA5", + [":moyai:"] = "\uD83D\uDDFF", + [":mrs_claus:"] = "\uD83E\uDD36", + [":mrs_claus::skin-tone-1:"] = "\uD83E\uDD36\uD83C\uDFFB", + [":mrs_claus::skin-tone-2:"] = "\uD83E\uDD36\uD83C\uDFFC", + [":mrs_claus::skin-tone-3:"] = "\uD83E\uDD36\uD83C\uDFFD", + [":mrs_claus::skin-tone-4:"] = "\uD83E\uDD36\uD83C\uDFFE", + [":mrs_claus::skin-tone-5:"] = "\uD83E\uDD36\uD83C\uDFFF", + [":mrs_claus_tone1:"] = "\uD83E\uDD36\uD83C\uDFFB", + [":mrs_claus_tone2:"] = "\uD83E\uDD36\uD83C\uDFFC", + [":mrs_claus_tone3:"] = "\uD83E\uDD36\uD83C\uDFFD", + [":mrs_claus_tone4:"] = "\uD83E\uDD36\uD83C\uDFFE", + [":mrs_claus_tone5:"] = "\uD83E\uDD36\uD83C\uDFFF", + [":muscle:"] = "\uD83D\uDCAA", + [":muscle::skin-tone-1:"] = "\uD83D\uDCAA\uD83C\uDFFB", + [":muscle::skin-tone-2:"] = "\uD83D\uDCAA\uD83C\uDFFC", + [":muscle::skin-tone-3:"] = "\uD83D\uDCAA\uD83C\uDFFD", + [":muscle::skin-tone-4:"] = "\uD83D\uDCAA\uD83C\uDFFE", + [":muscle::skin-tone-5:"] = "\uD83D\uDCAA\uD83C\uDFFF", + [":muscle_tone1:"] = "\uD83D\uDCAA\uD83C\uDFFB", + [":muscle_tone2:"] = "\uD83D\uDCAA\uD83C\uDFFC", + [":muscle_tone3:"] = "\uD83D\uDCAA\uD83C\uDFFD", + [":muscle_tone4:"] = "\uD83D\uDCAA\uD83C\uDFFE", + [":muscle_tone5:"] = "\uD83D\uDCAA\uD83C\uDFFF", + [":mushroom:"] = "\uD83C\uDF44", + [":musical_keyboard:"] = "\uD83C\uDFB9", + [":musical_note:"] = "\uD83C\uDFB5", + [":musical_score:"] = "\uD83C\uDFBC", + [":mute:"] = "\uD83D\uDD07", + [":mx_claus:"] = "\uD83E\uDDD1\u200D\uD83C\uDF84", + [":mx_claus::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83C\uDF84", + [":mx_claus::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83C\uDF84", + [":mx_claus::skin-tone-3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83C\uDF84", + [":mx_claus::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83C\uDF84", + [":mx_claus::skin-tone-5:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDF84", + [":mx_claus_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDF84", + [":mx_claus_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83C\uDF84", + [":mx_claus_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83C\uDF84", + [":mx_claus_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83C\uDF84", + [":mx_claus_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83C\uDF84", + [":mx_claus_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83C\uDF84", + [":mx_claus_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83C\uDF84", + [":mx_claus_tone3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83C\uDF84", + [":mx_claus_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83C\uDF84", + [":mx_claus_tone5:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDF84", + [":nail_care:"] = "\uD83D\uDC85", + [":nail_care::skin-tone-1:"] = "\uD83D\uDC85\uD83C\uDFFB", + [":nail_care::skin-tone-2:"] = "\uD83D\uDC85\uD83C\uDFFC", + [":nail_care::skin-tone-3:"] = "\uD83D\uDC85\uD83C\uDFFD", + [":nail_care::skin-tone-4:"] = "\uD83D\uDC85\uD83C\uDFFE", + [":nail_care::skin-tone-5:"] = "\uD83D\uDC85\uD83C\uDFFF", + [":nail_care_tone1:"] = "\uD83D\uDC85\uD83C\uDFFB", + [":nail_care_tone2:"] = "\uD83D\uDC85\uD83C\uDFFC", + [":nail_care_tone3:"] = "\uD83D\uDC85\uD83C\uDFFD", + [":nail_care_tone4:"] = "\uD83D\uDC85\uD83C\uDFFE", + [":nail_care_tone5:"] = "\uD83D\uDC85\uD83C\uDFFF", + [":name_badge:"] = "\uD83D\uDCDB", + [":national_park:"] = "\uD83C\uDFDE️", + [":nauseated_face:"] = "\uD83E\uDD22", + [":nazar_amulet:"] = "\uD83E\uDDFF", + [":necktie:"] = "\uD83D\uDC54", + [":negative_squared_cross_mark:"] = "❎", + [":nerd:"] = "\uD83E\uDD13", + [":nerd_face:"] = "\uD83E\uDD13", + [":nest_with_eggs:"] = "\uD83E\uDEBA", + [":nesting_dolls:"] = "\uD83E\uDE86", + [":neutral_face:"] = "\uD83D\uDE10", + [":new:"] = "\uD83C\uDD95", + [":new_moon:"] = "\uD83C\uDF11", + [":new_moon_with_face:"] = "\uD83C\uDF1A", + [":newspaper2:"] = "\uD83D\uDDDE️", + [":newspaper:"] = "\uD83D\uDCF0", + [":next_track:"] = "⏭️", + [":ng:"] = "\uD83C\uDD96", + [":night_with_stars:"] = "\uD83C\uDF03", + [":nine:"] = "9️⃣", + [":ninja:"] = "\uD83E\uDD77", + [":ninja::skin-tone-1:"] = "\uD83E\uDD77\uD83C\uDFFB", + [":ninja::skin-tone-2:"] = "\uD83E\uDD77\uD83C\uDFFC", + [":ninja::skin-tone-3:"] = "\uD83E\uDD77\uD83C\uDFFD", + [":ninja::skin-tone-4:"] = "\uD83E\uDD77\uD83C\uDFFE", + [":ninja::skin-tone-5:"] = "\uD83E\uDD77\uD83C\uDFFF", + [":ninja_dark_skin_tone:"] = "\uD83E\uDD77\uD83C\uDFFF", + [":ninja_light_skin_tone:"] = "\uD83E\uDD77\uD83C\uDFFB", + [":ninja_medium_dark_skin_tone:"] = "\uD83E\uDD77\uD83C\uDFFE", + [":ninja_medium_light_skin_tone:"] = "\uD83E\uDD77\uD83C\uDFFC", + [":ninja_medium_skin_tone:"] = "\uD83E\uDD77\uD83C\uDFFD", + [":ninja_tone1:"] = "\uD83E\uDD77\uD83C\uDFFB", + [":ninja_tone2:"] = "\uD83E\uDD77\uD83C\uDFFC", + [":ninja_tone3:"] = "\uD83E\uDD77\uD83C\uDFFD", + [":ninja_tone4:"] = "\uD83E\uDD77\uD83C\uDFFE", + [":ninja_tone5:"] = "\uD83E\uDD77\uD83C\uDFFF", + [":no_bell:"] = "\uD83D\uDD15", + [":no_bicycles:"] = "\uD83D\uDEB3", + [":no_entry:"] = "⛔", + [":no_entry_sign:"] = "\uD83D\uDEAB", + [":no_good:"] = "\uD83D\uDE45", + [":no_good::skin-tone-1:"] = "\uD83D\uDE45\uD83C\uDFFB", + [":no_good::skin-tone-2:"] = "\uD83D\uDE45\uD83C\uDFFC", + [":no_good::skin-tone-3:"] = "\uD83D\uDE45\uD83C\uDFFD", + [":no_good::skin-tone-4:"] = "\uD83D\uDE45\uD83C\uDFFE", + [":no_good::skin-tone-5:"] = "\uD83D\uDE45\uD83C\uDFFF", + [":no_good_tone1:"] = "\uD83D\uDE45\uD83C\uDFFB", + [":no_good_tone2:"] = "\uD83D\uDE45\uD83C\uDFFC", + [":no_good_tone3:"] = "\uD83D\uDE45\uD83C\uDFFD", + [":no_good_tone4:"] = "\uD83D\uDE45\uD83C\uDFFE", + [":no_good_tone5:"] = "\uD83D\uDE45\uD83C\uDFFF", + [":no_mobile_phones:"] = "\uD83D\uDCF5", + [":no_mouth:"] = "\uD83D\uDE36", + [":no_pedestrians:"] = "\uD83D\uDEB7", + [":no_smoking:"] = "\uD83D\uDEAD", + [":non_potable_water:"] = "\uD83D\uDEB1", + [":nose:"] = "\uD83D\uDC43", + [":nose::skin-tone-1:"] = "\uD83D\uDC43\uD83C\uDFFB", + [":nose::skin-tone-2:"] = "\uD83D\uDC43\uD83C\uDFFC", + [":nose::skin-tone-3:"] = "\uD83D\uDC43\uD83C\uDFFD", + [":nose::skin-tone-4:"] = "\uD83D\uDC43\uD83C\uDFFE", + [":nose::skin-tone-5:"] = "\uD83D\uDC43\uD83C\uDFFF", + [":nose_tone1:"] = "\uD83D\uDC43\uD83C\uDFFB", + [":nose_tone2:"] = "\uD83D\uDC43\uD83C\uDFFC", + [":nose_tone3:"] = "\uD83D\uDC43\uD83C\uDFFD", + [":nose_tone4:"] = "\uD83D\uDC43\uD83C\uDFFE", + [":nose_tone5:"] = "\uD83D\uDC43\uD83C\uDFFF", + [":notebook:"] = "\uD83D\uDCD3", + [":notebook_with_decorative_cover:"] = "\uD83D\uDCD4", + [":notepad_spiral:"] = "\uD83D\uDDD2️", + [":notes:"] = "\uD83C\uDFB6", + [":nut_and_bolt:"] = "\uD83D\uDD29", + [":o"] = "\uD83D\uDE2E", + [":o2:"] = "\uD83C\uDD7E️", + [":o:"] = "⭕", + [":ocean:"] = "\uD83C\uDF0A", + [":octagonal_sign:"] = "\uD83D\uDED1", + [":octopus:"] = "\uD83D\uDC19", + [":oden:"] = "\uD83C\uDF62", + [":office:"] = "\uD83C\uDFE2", + [":oil:"] = "\uD83D\uDEE2️", + [":oil_drum:"] = "\uD83D\uDEE2️", + [":ok:"] = "\uD83C\uDD97", + [":ok_hand:"] = "\uD83D\uDC4C", + [":ok_hand::skin-tone-1:"] = "\uD83D\uDC4C\uD83C\uDFFB", + [":ok_hand::skin-tone-2:"] = "\uD83D\uDC4C\uD83C\uDFFC", + [":ok_hand::skin-tone-3:"] = "\uD83D\uDC4C\uD83C\uDFFD", + [":ok_hand::skin-tone-4:"] = "\uD83D\uDC4C\uD83C\uDFFE", + [":ok_hand::skin-tone-5:"] = "\uD83D\uDC4C\uD83C\uDFFF", + [":ok_hand_tone1:"] = "\uD83D\uDC4C\uD83C\uDFFB", + [":ok_hand_tone2:"] = "\uD83D\uDC4C\uD83C\uDFFC", + [":ok_hand_tone3:"] = "\uD83D\uDC4C\uD83C\uDFFD", + [":ok_hand_tone4:"] = "\uD83D\uDC4C\uD83C\uDFFE", + [":ok_hand_tone5:"] = "\uD83D\uDC4C\uD83C\uDFFF", + [":ok_woman:"] = "\uD83D\uDE46", + [":ok_woman::skin-tone-1:"] = "\uD83D\uDE46\uD83C\uDFFB", + [":ok_woman::skin-tone-2:"] = "\uD83D\uDE46\uD83C\uDFFC", + [":ok_woman::skin-tone-3:"] = "\uD83D\uDE46\uD83C\uDFFD", + [":ok_woman::skin-tone-4:"] = "\uD83D\uDE46\uD83C\uDFFE", + [":ok_woman::skin-tone-5:"] = "\uD83D\uDE46\uD83C\uDFFF", + [":ok_woman_tone1:"] = "\uD83D\uDE46\uD83C\uDFFB", + [":ok_woman_tone2:"] = "\uD83D\uDE46\uD83C\uDFFC", + [":ok_woman_tone3:"] = "\uD83D\uDE46\uD83C\uDFFD", + [":ok_woman_tone4:"] = "\uD83D\uDE46\uD83C\uDFFE", + [":ok_woman_tone5:"] = "\uD83D\uDE46\uD83C\uDFFF", + [":old_key:"] = "\uD83D\uDDDD️", + [":older_adult:"] = "\uD83E\uDDD3", + [":older_adult::skin-tone-1:"] = "\uD83E\uDDD3\uD83C\uDFFB", + [":older_adult::skin-tone-2:"] = "\uD83E\uDDD3\uD83C\uDFFC", + [":older_adult::skin-tone-3:"] = "\uD83E\uDDD3\uD83C\uDFFD", + [":older_adult::skin-tone-4:"] = "\uD83E\uDDD3\uD83C\uDFFE", + [":older_adult::skin-tone-5:"] = "\uD83E\uDDD3\uD83C\uDFFF", + [":older_adult_dark_skin_tone:"] = "\uD83E\uDDD3\uD83C\uDFFF", + [":older_adult_light_skin_tone:"] = "\uD83E\uDDD3\uD83C\uDFFB", + [":older_adult_medium_dark_skin_tone:"] = "\uD83E\uDDD3\uD83C\uDFFE", + [":older_adult_medium_light_skin_tone:"] = "\uD83E\uDDD3\uD83C\uDFFC", + [":older_adult_medium_skin_tone:"] = "\uD83E\uDDD3\uD83C\uDFFD", + [":older_adult_tone1:"] = "\uD83E\uDDD3\uD83C\uDFFB", + [":older_adult_tone2:"] = "\uD83E\uDDD3\uD83C\uDFFC", + [":older_adult_tone3:"] = "\uD83E\uDDD3\uD83C\uDFFD", + [":older_adult_tone4:"] = "\uD83E\uDDD3\uD83C\uDFFE", + [":older_adult_tone5:"] = "\uD83E\uDDD3\uD83C\uDFFF", + [":older_man:"] = "\uD83D\uDC74", + [":older_man::skin-tone-1:"] = "\uD83D\uDC74\uD83C\uDFFB", + [":older_man::skin-tone-2:"] = "\uD83D\uDC74\uD83C\uDFFC", + [":older_man::skin-tone-3:"] = "\uD83D\uDC74\uD83C\uDFFD", + [":older_man::skin-tone-4:"] = "\uD83D\uDC74\uD83C\uDFFE", + [":older_man::skin-tone-5:"] = "\uD83D\uDC74\uD83C\uDFFF", + [":older_man_tone1:"] = "\uD83D\uDC74\uD83C\uDFFB", + [":older_man_tone2:"] = "\uD83D\uDC74\uD83C\uDFFC", + [":older_man_tone3:"] = "\uD83D\uDC74\uD83C\uDFFD", + [":older_man_tone4:"] = "\uD83D\uDC74\uD83C\uDFFE", + [":older_man_tone5:"] = "\uD83D\uDC74\uD83C\uDFFF", + [":older_woman:"] = "\uD83D\uDC75", + [":older_woman::skin-tone-1:"] = "\uD83D\uDC75\uD83C\uDFFB", + [":older_woman::skin-tone-2:"] = "\uD83D\uDC75\uD83C\uDFFC", + [":older_woman::skin-tone-3:"] = "\uD83D\uDC75\uD83C\uDFFD", + [":older_woman::skin-tone-4:"] = "\uD83D\uDC75\uD83C\uDFFE", + [":older_woman::skin-tone-5:"] = "\uD83D\uDC75\uD83C\uDFFF", + [":older_woman_tone1:"] = "\uD83D\uDC75\uD83C\uDFFB", + [":older_woman_tone2:"] = "\uD83D\uDC75\uD83C\uDFFC", + [":older_woman_tone3:"] = "\uD83D\uDC75\uD83C\uDFFD", + [":older_woman_tone4:"] = "\uD83D\uDC75\uD83C\uDFFE", + [":older_woman_tone5:"] = "\uD83D\uDC75\uD83C\uDFFF", + [":olive:"] = "\uD83E\uDED2", + [":om_symbol:"] = "\uD83D\uDD49️", + [":on:"] = "\uD83D\uDD1B", + [":oncoming_automobile:"] = "\uD83D\uDE98", + [":oncoming_bus:"] = "\uD83D\uDE8D", + [":oncoming_police_car:"] = "\uD83D\uDE94", + [":oncoming_taxi:"] = "\uD83D\uDE96", + [":one:"] = "1️⃣", + [":one_piece_swimsuit:"] = "\uD83E\uDE71", + [":onion:"] = "\uD83E\uDDC5", + [":open_file_folder:"] = "\uD83D\uDCC2", + [":open_hands:"] = "\uD83D\uDC50", + [":open_hands::skin-tone-1:"] = "\uD83D\uDC50\uD83C\uDFFB", + [":open_hands::skin-tone-2:"] = "\uD83D\uDC50\uD83C\uDFFC", + [":open_hands::skin-tone-3:"] = "\uD83D\uDC50\uD83C\uDFFD", + [":open_hands::skin-tone-4:"] = "\uD83D\uDC50\uD83C\uDFFE", + [":open_hands::skin-tone-5:"] = "\uD83D\uDC50\uD83C\uDFFF", + [":open_hands_tone1:"] = "\uD83D\uDC50\uD83C\uDFFB", + [":open_hands_tone2:"] = "\uD83D\uDC50\uD83C\uDFFC", + [":open_hands_tone3:"] = "\uD83D\uDC50\uD83C\uDFFD", + [":open_hands_tone4:"] = "\uD83D\uDC50\uD83C\uDFFE", + [":open_hands_tone5:"] = "\uD83D\uDC50\uD83C\uDFFF", + [":open_mouth:"] = "\uD83D\uDE2E", + [":ophiuchus:"] = "⛎", + [":orange_book:"] = "\uD83D\uDCD9", + [":orange_circle:"] = "\uD83D\uDFE0", + [":orange_heart:"] = "\uD83E\uDDE1", + [":orange_square:"] = "\uD83D\uDFE7", + [":orangutan:"] = "\uD83E\uDDA7", + [":orthodox_cross:"] = "☦️", + [":otter:"] = "\uD83E\uDDA6", + [":outbox_tray:"] = "\uD83D\uDCE4", + [":owl:"] = "\uD83E\uDD89", + [":ox:"] = "\uD83D\uDC02", + [":oyster:"] = "\uD83E\uDDAA", + [":package:"] = "\uD83D\uDCE6", + [":paella:"] = "\uD83E\uDD58", + [":page_facing_up:"] = "\uD83D\uDCC4", + [":page_with_curl:"] = "\uD83D\uDCC3", + [":pager:"] = "\uD83D\uDCDF", + [":paintbrush:"] = "\uD83D\uDD8C️", + [":palm_down_hand:"] = "\uD83E\uDEF3", + [":palm_down_hand::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_down_hand_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_tree:"] = "\uD83C\uDF34", + [":palm_up_hand:"] = "\uD83E\uDEF4", + [":palm_up_hand::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palm_up_hand_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":palms_up_together:"] = "\uD83E\uDD32", + [":palms_up_together::skin-tone-1:"] = "\uD83E\uDD32\uD83C\uDFFB", + [":palms_up_together::skin-tone-2:"] = "\uD83E\uDD32\uD83C\uDFFC", + [":palms_up_together::skin-tone-3:"] = "\uD83E\uDD32\uD83C\uDFFD", + [":palms_up_together::skin-tone-4:"] = "\uD83E\uDD32\uD83C\uDFFE", + [":palms_up_together::skin-tone-5:"] = "\uD83E\uDD32\uD83C\uDFFF", + [":palms_up_together_dark_skin_tone:"] = "\uD83E\uDD32\uD83C\uDFFF", + [":palms_up_together_light_skin_tone:"] = "\uD83E\uDD32\uD83C\uDFFB", + [":palms_up_together_medium_dark_skin_tone:"] = "\uD83E\uDD32\uD83C\uDFFE", + [":palms_up_together_medium_light_skin_tone:"] = "\uD83E\uDD32\uD83C\uDFFC", + [":palms_up_together_medium_skin_tone:"] = "\uD83E\uDD32\uD83C\uDFFD", + [":palms_up_together_tone1:"] = "\uD83E\uDD32\uD83C\uDFFB", + [":palms_up_together_tone2:"] = "\uD83E\uDD32\uD83C\uDFFC", + [":palms_up_together_tone3:"] = "\uD83E\uDD32\uD83C\uDFFD", + [":palms_up_together_tone4:"] = "\uD83E\uDD32\uD83C\uDFFE", + [":palms_up_together_tone5:"] = "\uD83E\uDD32\uD83C\uDFFF", + [":pancakes:"] = "\uD83E\uDD5E", + [":panda_face:"] = "\uD83D\uDC3C", + [":paperclip:"] = "\uD83D\uDCCE", + [":paperclips:"] = "\uD83D\uDD87️", + [":parachute:"] = "\uD83E\uDE82", + [":park:"] = "\uD83C\uDFDE️", + [":parking:"] = "\uD83C\uDD7F️", + [":parrot:"] = "\uD83E\uDD9C", + [":part_alternation_mark:"] = "〽️", + [":partly_sunny:"] = "⛅", + [":partying_face:"] = "\uD83E\uDD73", + [":passenger_ship:"] = "\uD83D\uDEF3️", + [":passport_control:"] = "\uD83D\uDEC2", + [":pause_button:"] = "⏸️", + [":paw_prints:"] = "\uD83D\uDC3E", + [":peace:"] = "☮️", + [":peace_symbol:"] = "☮️", + [":peach:"] = "\uD83C\uDF51", + [":peacock:"] = "\uD83E\uDD9A", + [":peanuts:"] = "\uD83E\uDD5C", + [":pear:"] = "\uD83C\uDF50", + [":pen_ballpoint:"] = "\uD83D\uDD8A️", + [":pen_fountain:"] = "\uD83D\uDD8B️", + [":pencil2:"] = "✏️", + [":pencil:"] = "\uD83D\uDCDD", + [":penguin:"] = "\uD83D\uDC27", + [":pensive:"] = "\uD83D\uDE14", + [":people_holding_hands:"] = "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", + [":people_hugging:"] = "\uD83E\uDEC2", + [":people_with_bunny_ears_partying:"] = "\uD83D\uDC6F", + [":people_wrestling:"] = "\uD83E\uDD3C", + [":performing_arts:"] = "\uD83C\uDFAD", + [":persevere:"] = "\uD83D\uDE23", + [":person_biking:"] = "\uD83D\uDEB4", + [":person_biking::skin-tone-1:"] = "\uD83D\uDEB4\uD83C\uDFFB", + [":person_biking::skin-tone-2:"] = "\uD83D\uDEB4\uD83C\uDFFC", + [":person_biking::skin-tone-3:"] = "\uD83D\uDEB4\uD83C\uDFFD", + [":person_biking::skin-tone-4:"] = "\uD83D\uDEB4\uD83C\uDFFE", + [":person_biking::skin-tone-5:"] = "\uD83D\uDEB4\uD83C\uDFFF", + [":person_biking_tone1:"] = "\uD83D\uDEB4\uD83C\uDFFB", + [":person_biking_tone2:"] = "\uD83D\uDEB4\uD83C\uDFFC", + [":person_biking_tone3:"] = "\uD83D\uDEB4\uD83C\uDFFD", + [":person_biking_tone4:"] = "\uD83D\uDEB4\uD83C\uDFFE", + [":person_biking_tone5:"] = "\uD83D\uDEB4\uD83C\uDFFF", + [":person_bouncing_ball:"] = "⛹️", + [":person_bouncing_ball::skin-tone-1:"] = "⛹\uD83C\uDFFB", + [":person_bouncing_ball::skin-tone-2:"] = "⛹\uD83C\uDFFC", + [":person_bouncing_ball::skin-tone-3:"] = "⛹\uD83C\uDFFD", + [":person_bouncing_ball::skin-tone-4:"] = "⛹\uD83C\uDFFE", + [":person_bouncing_ball::skin-tone-5:"] = "⛹\uD83C\uDFFF", + [":person_bouncing_ball_tone1:"] = "⛹\uD83C\uDFFB", + [":person_bouncing_ball_tone2:"] = "⛹\uD83C\uDFFC", + [":person_bouncing_ball_tone3:"] = "⛹\uD83C\uDFFD", + [":person_bouncing_ball_tone4:"] = "⛹\uD83C\uDFFE", + [":person_bouncing_ball_tone5:"] = "⛹\uD83C\uDFFF", + [":person_bowing:"] = "\uD83D\uDE47", + [":person_bowing::skin-tone-1:"] = "\uD83D\uDE47\uD83C\uDFFB", + [":person_bowing::skin-tone-2:"] = "\uD83D\uDE47\uD83C\uDFFC", + [":person_bowing::skin-tone-3:"] = "\uD83D\uDE47\uD83C\uDFFD", + [":person_bowing::skin-tone-4:"] = "\uD83D\uDE47\uD83C\uDFFE", + [":person_bowing::skin-tone-5:"] = "\uD83D\uDE47\uD83C\uDFFF", + [":person_bowing_tone1:"] = "\uD83D\uDE47\uD83C\uDFFB", + [":person_bowing_tone2:"] = "\uD83D\uDE47\uD83C\uDFFC", + [":person_bowing_tone3:"] = "\uD83D\uDE47\uD83C\uDFFD", + [":person_bowing_tone4:"] = "\uD83D\uDE47\uD83C\uDFFE", + [":person_bowing_tone5:"] = "\uD83D\uDE47\uD83C\uDFFF", + [":person_climbing:"] = "\uD83E\uDDD7", + [":person_climbing::skin-tone-1:"] = "\uD83E\uDDD7\uD83C\uDFFB", + [":person_climbing::skin-tone-2:"] = "\uD83E\uDDD7\uD83C\uDFFC", + [":person_climbing::skin-tone-3:"] = "\uD83E\uDDD7\uD83C\uDFFD", + [":person_climbing::skin-tone-4:"] = "\uD83E\uDDD7\uD83C\uDFFE", + [":person_climbing::skin-tone-5:"] = "\uD83E\uDDD7\uD83C\uDFFF", + [":person_climbing_dark_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFF", + [":person_climbing_light_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFB", + [":person_climbing_medium_dark_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFE", + [":person_climbing_medium_light_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFC", + [":person_climbing_medium_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFD", + [":person_climbing_tone1:"] = "\uD83E\uDDD7\uD83C\uDFFB", + [":person_climbing_tone2:"] = "\uD83E\uDDD7\uD83C\uDFFC", + [":person_climbing_tone3:"] = "\uD83E\uDDD7\uD83C\uDFFD", + [":person_climbing_tone4:"] = "\uD83E\uDDD7\uD83C\uDFFE", + [":person_climbing_tone5:"] = "\uD83E\uDDD7\uD83C\uDFFF", + [":person_doing_cartwheel:"] = "\uD83E\uDD38", + [":person_doing_cartwheel::skin-tone-1:"] = "\uD83E\uDD38\uD83C\uDFFB", + [":person_doing_cartwheel::skin-tone-2:"] = "\uD83E\uDD38\uD83C\uDFFC", + [":person_doing_cartwheel::skin-tone-3:"] = "\uD83E\uDD38\uD83C\uDFFD", + [":person_doing_cartwheel::skin-tone-4:"] = "\uD83E\uDD38\uD83C\uDFFE", + [":person_doing_cartwheel::skin-tone-5:"] = "\uD83E\uDD38\uD83C\uDFFF", + [":person_doing_cartwheel_tone1:"] = "\uD83E\uDD38\uD83C\uDFFB", + [":person_doing_cartwheel_tone2:"] = "\uD83E\uDD38\uD83C\uDFFC", + [":person_doing_cartwheel_tone3:"] = "\uD83E\uDD38\uD83C\uDFFD", + [":person_doing_cartwheel_tone4:"] = "\uD83E\uDD38\uD83C\uDFFE", + [":person_doing_cartwheel_tone5:"] = "\uD83E\uDD38\uD83C\uDFFF", + [":person_facepalming:"] = "\uD83E\uDD26", + [":person_facepalming::skin-tone-1:"] = "\uD83E\uDD26\uD83C\uDFFB", + [":person_facepalming::skin-tone-2:"] = "\uD83E\uDD26\uD83C\uDFFC", + [":person_facepalming::skin-tone-3:"] = "\uD83E\uDD26\uD83C\uDFFD", + [":person_facepalming::skin-tone-4:"] = "\uD83E\uDD26\uD83C\uDFFE", + [":person_facepalming::skin-tone-5:"] = "\uD83E\uDD26\uD83C\uDFFF", + [":person_facepalming_tone1:"] = "\uD83E\uDD26\uD83C\uDFFB", + [":person_facepalming_tone2:"] = "\uD83E\uDD26\uD83C\uDFFC", + [":person_facepalming_tone3:"] = "\uD83E\uDD26\uD83C\uDFFD", + [":person_facepalming_tone4:"] = "\uD83E\uDD26\uD83C\uDFFE", + [":person_facepalming_tone5:"] = "\uD83E\uDD26\uD83C\uDFFF", + [":person_feeding_baby:"] = "\uD83E\uDDD1\u200D\uD83C\uDF7C", + [":person_feeding_baby::skin-tone-1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":person_feeding_baby::skin-tone-2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":person_feeding_baby::skin-tone-3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":person_feeding_baby::skin-tone-4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":person_feeding_baby::skin-tone-5:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":person_feeding_baby_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":person_feeding_baby_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":person_feeding_baby_medium_dark_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":person_feeding_baby_medium_light_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":person_feeding_baby_medium_skin_tone:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":person_feeding_baby_tone1:"] = "\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":person_feeding_baby_tone2:"] = "\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":person_feeding_baby_tone3:"] = "\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":person_feeding_baby_tone4:"] = "\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":person_feeding_baby_tone5:"] = "\uD83E\uDDD1\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":person_fencing:"] = "\uD83E\uDD3A", + [":person_frowning:"] = "\uD83D\uDE4D", + [":person_frowning::skin-tone-1:"] = "\uD83D\uDE4D\uD83C\uDFFB", + [":person_frowning::skin-tone-2:"] = "\uD83D\uDE4D\uD83C\uDFFC", + [":person_frowning::skin-tone-3:"] = "\uD83D\uDE4D\uD83C\uDFFD", + [":person_frowning::skin-tone-4:"] = "\uD83D\uDE4D\uD83C\uDFFE", + [":person_frowning::skin-tone-5:"] = "\uD83D\uDE4D\uD83C\uDFFF", + [":person_frowning_tone1:"] = "\uD83D\uDE4D\uD83C\uDFFB", + [":person_frowning_tone2:"] = "\uD83D\uDE4D\uD83C\uDFFC", + [":person_frowning_tone3:"] = "\uD83D\uDE4D\uD83C\uDFFD", + [":person_frowning_tone4:"] = "\uD83D\uDE4D\uD83C\uDFFE", + [":person_frowning_tone5:"] = "\uD83D\uDE4D\uD83C\uDFFF", + [":person_gesturing_no:"] = "\uD83D\uDE45", + [":person_gesturing_no::skin-tone-1:"] = "\uD83D\uDE45\uD83C\uDFFB", + [":person_gesturing_no::skin-tone-2:"] = "\uD83D\uDE45\uD83C\uDFFC", + [":person_gesturing_no::skin-tone-3:"] = "\uD83D\uDE45\uD83C\uDFFD", + [":person_gesturing_no::skin-tone-4:"] = "\uD83D\uDE45\uD83C\uDFFE", + [":person_gesturing_no::skin-tone-5:"] = "\uD83D\uDE45\uD83C\uDFFF", + [":person_gesturing_no_tone1:"] = "\uD83D\uDE45\uD83C\uDFFB", + [":person_gesturing_no_tone2:"] = "\uD83D\uDE45\uD83C\uDFFC", + [":person_gesturing_no_tone3:"] = "\uD83D\uDE45\uD83C\uDFFD", + [":person_gesturing_no_tone4:"] = "\uD83D\uDE45\uD83C\uDFFE", + [":person_gesturing_no_tone5:"] = "\uD83D\uDE45\uD83C\uDFFF", + [":person_gesturing_ok:"] = "\uD83D\uDE46", + [":person_gesturing_ok::skin-tone-1:"] = "\uD83D\uDE46\uD83C\uDFFB", + [":person_gesturing_ok::skin-tone-2:"] = "\uD83D\uDE46\uD83C\uDFFC", + [":person_gesturing_ok::skin-tone-3:"] = "\uD83D\uDE46\uD83C\uDFFD", + [":person_gesturing_ok::skin-tone-4:"] = "\uD83D\uDE46\uD83C\uDFFE", + [":person_gesturing_ok::skin-tone-5:"] = "\uD83D\uDE46\uD83C\uDFFF", + [":person_gesturing_ok_tone1:"] = "\uD83D\uDE46\uD83C\uDFFB", + [":person_gesturing_ok_tone2:"] = "\uD83D\uDE46\uD83C\uDFFC", + [":person_gesturing_ok_tone3:"] = "\uD83D\uDE46\uD83C\uDFFD", + [":person_gesturing_ok_tone4:"] = "\uD83D\uDE46\uD83C\uDFFE", + [":person_gesturing_ok_tone5:"] = "\uD83D\uDE46\uD83C\uDFFF", + [":person_getting_haircut:"] = "\uD83D\uDC87", + [":person_getting_haircut::skin-tone-1:"] = "\uD83D\uDC87\uD83C\uDFFB", + [":person_getting_haircut::skin-tone-2:"] = "\uD83D\uDC87\uD83C\uDFFC", + [":person_getting_haircut::skin-tone-3:"] = "\uD83D\uDC87\uD83C\uDFFD", + [":person_getting_haircut::skin-tone-4:"] = "\uD83D\uDC87\uD83C\uDFFE", + [":person_getting_haircut::skin-tone-5:"] = "\uD83D\uDC87\uD83C\uDFFF", + [":person_getting_haircut_tone1:"] = "\uD83D\uDC87\uD83C\uDFFB", + [":person_getting_haircut_tone2:"] = "\uD83D\uDC87\uD83C\uDFFC", + [":person_getting_haircut_tone3:"] = "\uD83D\uDC87\uD83C\uDFFD", + [":person_getting_haircut_tone4:"] = "\uD83D\uDC87\uD83C\uDFFE", + [":person_getting_haircut_tone5:"] = "\uD83D\uDC87\uD83C\uDFFF", + [":person_getting_massage:"] = "\uD83D\uDC86", + [":person_getting_massage::skin-tone-1:"] = "\uD83D\uDC86\uD83C\uDFFB", + [":person_getting_massage::skin-tone-2:"] = "\uD83D\uDC86\uD83C\uDFFC", + [":person_getting_massage::skin-tone-3:"] = "\uD83D\uDC86\uD83C\uDFFD", + [":person_getting_massage::skin-tone-4:"] = "\uD83D\uDC86\uD83C\uDFFE", + [":person_getting_massage::skin-tone-5:"] = "\uD83D\uDC86\uD83C\uDFFF", + [":person_getting_massage_tone1:"] = "\uD83D\uDC86\uD83C\uDFFB", + [":person_getting_massage_tone2:"] = "\uD83D\uDC86\uD83C\uDFFC", + [":person_getting_massage_tone3:"] = "\uD83D\uDC86\uD83C\uDFFD", + [":person_getting_massage_tone4:"] = "\uD83D\uDC86\uD83C\uDFFE", + [":person_getting_massage_tone5:"] = "\uD83D\uDC86\uD83C\uDFFF", + [":person_golfing:"] = "\uD83C\uDFCC️", + [":person_golfing::skin-tone-1:"] = "\uD83C\uDFCC\uD83C\uDFFB", + [":person_golfing::skin-tone-2:"] = "\uD83C\uDFCC\uD83C\uDFFC", + [":person_golfing::skin-tone-3:"] = "\uD83C\uDFCC\uD83C\uDFFD", + [":person_golfing::skin-tone-4:"] = "\uD83C\uDFCC\uD83C\uDFFE", + [":person_golfing::skin-tone-5:"] = "\uD83C\uDFCC\uD83C\uDFFF", + [":person_golfing_dark_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFF", + [":person_golfing_light_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFB", + [":person_golfing_medium_dark_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFE", + [":person_golfing_medium_light_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFC", + [":person_golfing_medium_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFD", + [":person_golfing_tone1:"] = "\uD83C\uDFCC\uD83C\uDFFB", + [":person_golfing_tone2:"] = "\uD83C\uDFCC\uD83C\uDFFC", + [":person_golfing_tone3:"] = "\uD83C\uDFCC\uD83C\uDFFD", + [":person_golfing_tone4:"] = "\uD83C\uDFCC\uD83C\uDFFE", + [":person_golfing_tone5:"] = "\uD83C\uDFCC\uD83C\uDFFF", + [":person_in_bed_dark_skin_tone:"] = "\uD83D\uDECC\uD83C\uDFFF", + [":person_in_bed_light_skin_tone:"] = "\uD83D\uDECC\uD83C\uDFFB", + [":person_in_bed_medium_dark_skin_tone:"] = "\uD83D\uDECC\uD83C\uDFFE", + [":person_in_bed_medium_light_skin_tone:"] = "\uD83D\uDECC\uD83C\uDFFC", + [":person_in_bed_medium_skin_tone:"] = "\uD83D\uDECC\uD83C\uDFFD", + [":person_in_bed_tone1:"] = "\uD83D\uDECC\uD83C\uDFFB", + [":person_in_bed_tone2:"] = "\uD83D\uDECC\uD83C\uDFFC", + [":person_in_bed_tone3:"] = "\uD83D\uDECC\uD83C\uDFFD", + [":person_in_bed_tone4:"] = "\uD83D\uDECC\uD83C\uDFFE", + [":person_in_bed_tone5:"] = "\uD83D\uDECC\uD83C\uDFFF", + [":person_in_lotus_position:"] = "\uD83E\uDDD8", + [":person_in_lotus_position::skin-tone-1:"] = "\uD83E\uDDD8\uD83C\uDFFB", + [":person_in_lotus_position::skin-tone-2:"] = "\uD83E\uDDD8\uD83C\uDFFC", + [":person_in_lotus_position::skin-tone-3:"] = "\uD83E\uDDD8\uD83C\uDFFD", + [":person_in_lotus_position::skin-tone-4:"] = "\uD83E\uDDD8\uD83C\uDFFE", + [":person_in_lotus_position::skin-tone-5:"] = "\uD83E\uDDD8\uD83C\uDFFF", + [":person_in_lotus_position_dark_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFF", + [":person_in_lotus_position_light_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFB", + [":person_in_lotus_position_medium_dark_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFE", + [":person_in_lotus_position_medium_light_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFC", + [":person_in_lotus_position_medium_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFD", + [":person_in_lotus_position_tone1:"] = "\uD83E\uDDD8\uD83C\uDFFB", + [":person_in_lotus_position_tone2:"] = "\uD83E\uDDD8\uD83C\uDFFC", + [":person_in_lotus_position_tone3:"] = "\uD83E\uDDD8\uD83C\uDFFD", + [":person_in_lotus_position_tone4:"] = "\uD83E\uDDD8\uD83C\uDFFE", + [":person_in_lotus_position_tone5:"] = "\uD83E\uDDD8\uD83C\uDFFF", + [":person_in_steamy_room:"] = "\uD83E\uDDD6", + [":person_in_steamy_room::skin-tone-1:"] = "\uD83E\uDDD6\uD83C\uDFFB", + [":person_in_steamy_room::skin-tone-2:"] = "\uD83E\uDDD6\uD83C\uDFFC", + [":person_in_steamy_room::skin-tone-3:"] = "\uD83E\uDDD6\uD83C\uDFFD", + [":person_in_steamy_room::skin-tone-4:"] = "\uD83E\uDDD6\uD83C\uDFFE", + [":person_in_steamy_room::skin-tone-5:"] = "\uD83E\uDDD6\uD83C\uDFFF", + [":person_in_steamy_room_dark_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFF", + [":person_in_steamy_room_light_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFB", + [":person_in_steamy_room_medium_dark_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFE", + [":person_in_steamy_room_medium_light_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFC", + [":person_in_steamy_room_medium_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFD", + [":person_in_steamy_room_tone1:"] = "\uD83E\uDDD6\uD83C\uDFFB", + [":person_in_steamy_room_tone2:"] = "\uD83E\uDDD6\uD83C\uDFFC", + [":person_in_steamy_room_tone3:"] = "\uD83E\uDDD6\uD83C\uDFFD", + [":person_in_steamy_room_tone4:"] = "\uD83E\uDDD6\uD83C\uDFFE", + [":person_in_steamy_room_tone5:"] = "\uD83E\uDDD6\uD83C\uDFFF", + [":person_juggling:"] = "\uD83E\uDD39", + [":person_juggling::skin-tone-1:"] = "\uD83E\uDD39\uD83C\uDFFB", + [":person_juggling::skin-tone-2:"] = "\uD83E\uDD39\uD83C\uDFFC", + [":person_juggling::skin-tone-3:"] = "\uD83E\uDD39\uD83C\uDFFD", + [":person_juggling::skin-tone-4:"] = "\uD83E\uDD39\uD83C\uDFFE", + [":person_juggling::skin-tone-5:"] = "\uD83E\uDD39\uD83C\uDFFF", + [":person_juggling_tone1:"] = "\uD83E\uDD39\uD83C\uDFFB", + [":person_juggling_tone2:"] = "\uD83E\uDD39\uD83C\uDFFC", + [":person_juggling_tone3:"] = "\uD83E\uDD39\uD83C\uDFFD", + [":person_juggling_tone4:"] = "\uD83E\uDD39\uD83C\uDFFE", + [":person_juggling_tone5:"] = "\uD83E\uDD39\uD83C\uDFFF", + [":person_kneeling:"] = "\uD83E\uDDCE", + [":person_kneeling::skin-tone-1:"] = "\uD83E\uDDCE\uD83C\uDFFB", + [":person_kneeling::skin-tone-2:"] = "\uD83E\uDDCE\uD83C\uDFFC", + [":person_kneeling::skin-tone-3:"] = "\uD83E\uDDCE\uD83C\uDFFD", + [":person_kneeling::skin-tone-4:"] = "\uD83E\uDDCE\uD83C\uDFFE", + [":person_kneeling::skin-tone-5:"] = "\uD83E\uDDCE\uD83C\uDFFF", + [":person_kneeling_dark_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFF", + [":person_kneeling_light_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFB", + [":person_kneeling_medium_dark_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFE", + [":person_kneeling_medium_light_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFC", + [":person_kneeling_medium_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFD", + [":person_kneeling_tone1:"] = "\uD83E\uDDCE\uD83C\uDFFB", + [":person_kneeling_tone2:"] = "\uD83E\uDDCE\uD83C\uDFFC", + [":person_kneeling_tone3:"] = "\uD83E\uDDCE\uD83C\uDFFD", + [":person_kneeling_tone4:"] = "\uD83E\uDDCE\uD83C\uDFFE", + [":person_kneeling_tone5:"] = "\uD83E\uDDCE\uD83C\uDFFF", + [":person_lifting_weights:"] = "\uD83C\uDFCB️", + [":person_lifting_weights::skin-tone-1:"] = "\uD83C\uDFCB\uD83C\uDFFB", + [":person_lifting_weights::skin-tone-2:"] = "\uD83C\uDFCB\uD83C\uDFFC", + [":person_lifting_weights::skin-tone-3:"] = "\uD83C\uDFCB\uD83C\uDFFD", + [":person_lifting_weights::skin-tone-4:"] = "\uD83C\uDFCB\uD83C\uDFFE", + [":person_lifting_weights::skin-tone-5:"] = "\uD83C\uDFCB\uD83C\uDFFF", + [":person_lifting_weights_tone1:"] = "\uD83C\uDFCB\uD83C\uDFFB", + [":person_lifting_weights_tone2:"] = "\uD83C\uDFCB\uD83C\uDFFC", + [":person_lifting_weights_tone3:"] = "\uD83C\uDFCB\uD83C\uDFFD", + [":person_lifting_weights_tone4:"] = "\uD83C\uDFCB\uD83C\uDFFE", + [":person_lifting_weights_tone5:"] = "\uD83C\uDFCB\uD83C\uDFFF", + [":person_mountain_biking:"] = "\uD83D\uDEB5", + [":person_mountain_biking::skin-tone-1:"] = "\uD83D\uDEB5\uD83C\uDFFB", + [":person_mountain_biking::skin-tone-2:"] = "\uD83D\uDEB5\uD83C\uDFFC", + [":person_mountain_biking::skin-tone-3:"] = "\uD83D\uDEB5\uD83C\uDFFD", + [":person_mountain_biking::skin-tone-4:"] = "\uD83D\uDEB5\uD83C\uDFFE", + [":person_mountain_biking::skin-tone-5:"] = "\uD83D\uDEB5\uD83C\uDFFF", + [":person_mountain_biking_tone1:"] = "\uD83D\uDEB5\uD83C\uDFFB", + [":person_mountain_biking_tone2:"] = "\uD83D\uDEB5\uD83C\uDFFC", + [":person_mountain_biking_tone3:"] = "\uD83D\uDEB5\uD83C\uDFFD", + [":person_mountain_biking_tone4:"] = "\uD83D\uDEB5\uD83C\uDFFE", + [":person_mountain_biking_tone5:"] = "\uD83D\uDEB5\uD83C\uDFFF", + [":person_playing_handball:"] = "\uD83E\uDD3E", + [":person_playing_handball::skin-tone-1:"] = "\uD83E\uDD3E\uD83C\uDFFB", + [":person_playing_handball::skin-tone-2:"] = "\uD83E\uDD3E\uD83C\uDFFC", + [":person_playing_handball::skin-tone-3:"] = "\uD83E\uDD3E\uD83C\uDFFD", + [":person_playing_handball::skin-tone-4:"] = "\uD83E\uDD3E\uD83C\uDFFE", + [":person_playing_handball::skin-tone-5:"] = "\uD83E\uDD3E\uD83C\uDFFF", + [":person_playing_handball_tone1:"] = "\uD83E\uDD3E\uD83C\uDFFB", + [":person_playing_handball_tone2:"] = "\uD83E\uDD3E\uD83C\uDFFC", + [":person_playing_handball_tone3:"] = "\uD83E\uDD3E\uD83C\uDFFD", + [":person_playing_handball_tone4:"] = "\uD83E\uDD3E\uD83C\uDFFE", + [":person_playing_handball_tone5:"] = "\uD83E\uDD3E\uD83C\uDFFF", + [":person_playing_water_polo:"] = "\uD83E\uDD3D", + [":person_playing_water_polo::skin-tone-1:"] = "\uD83E\uDD3D\uD83C\uDFFB", + [":person_playing_water_polo::skin-tone-2:"] = "\uD83E\uDD3D\uD83C\uDFFC", + [":person_playing_water_polo::skin-tone-3:"] = "\uD83E\uDD3D\uD83C\uDFFD", + [":person_playing_water_polo::skin-tone-4:"] = "\uD83E\uDD3D\uD83C\uDFFE", + [":person_playing_water_polo::skin-tone-5:"] = "\uD83E\uDD3D\uD83C\uDFFF", + [":person_playing_water_polo_tone1:"] = "\uD83E\uDD3D\uD83C\uDFFB", + [":person_playing_water_polo_tone2:"] = "\uD83E\uDD3D\uD83C\uDFFC", + [":person_playing_water_polo_tone3:"] = "\uD83E\uDD3D\uD83C\uDFFD", + [":person_playing_water_polo_tone4:"] = "\uD83E\uDD3D\uD83C\uDFFE", + [":person_playing_water_polo_tone5:"] = "\uD83E\uDD3D\uD83C\uDFFF", + [":person_pouting:"] = "\uD83D\uDE4E", + [":person_pouting::skin-tone-1:"] = "\uD83D\uDE4E\uD83C\uDFFB", + [":person_pouting::skin-tone-2:"] = "\uD83D\uDE4E\uD83C\uDFFC", + [":person_pouting::skin-tone-3:"] = "\uD83D\uDE4E\uD83C\uDFFD", + [":person_pouting::skin-tone-4:"] = "\uD83D\uDE4E\uD83C\uDFFE", + [":person_pouting::skin-tone-5:"] = "\uD83D\uDE4E\uD83C\uDFFF", + [":person_pouting_tone1:"] = "\uD83D\uDE4E\uD83C\uDFFB", + [":person_pouting_tone2:"] = "\uD83D\uDE4E\uD83C\uDFFC", + [":person_pouting_tone3:"] = "\uD83D\uDE4E\uD83C\uDFFD", + [":person_pouting_tone4:"] = "\uD83D\uDE4E\uD83C\uDFFE", + [":person_pouting_tone5:"] = "\uD83D\uDE4E\uD83C\uDFFF", + [":person_raising_hand:"] = "\uD83D\uDE4B", + [":person_raising_hand::skin-tone-1:"] = "\uD83D\uDE4B\uD83C\uDFFB", + [":person_raising_hand::skin-tone-2:"] = "\uD83D\uDE4B\uD83C\uDFFC", + [":person_raising_hand::skin-tone-3:"] = "\uD83D\uDE4B\uD83C\uDFFD", + [":person_raising_hand::skin-tone-4:"] = "\uD83D\uDE4B\uD83C\uDFFE", + [":person_raising_hand::skin-tone-5:"] = "\uD83D\uDE4B\uD83C\uDFFF", + [":person_raising_hand_tone1:"] = "\uD83D\uDE4B\uD83C\uDFFB", + [":person_raising_hand_tone2:"] = "\uD83D\uDE4B\uD83C\uDFFC", + [":person_raising_hand_tone3:"] = "\uD83D\uDE4B\uD83C\uDFFD", + [":person_raising_hand_tone4:"] = "\uD83D\uDE4B\uD83C\uDFFE", + [":person_raising_hand_tone5:"] = "\uD83D\uDE4B\uD83C\uDFFF", + [":person_rowing_boat:"] = "\uD83D\uDEA3", + [":person_rowing_boat::skin-tone-1:"] = "\uD83D\uDEA3\uD83C\uDFFB", + [":person_rowing_boat::skin-tone-2:"] = "\uD83D\uDEA3\uD83C\uDFFC", + [":person_rowing_boat::skin-tone-3:"] = "\uD83D\uDEA3\uD83C\uDFFD", + [":person_rowing_boat::skin-tone-4:"] = "\uD83D\uDEA3\uD83C\uDFFE", + [":person_rowing_boat::skin-tone-5:"] = "\uD83D\uDEA3\uD83C\uDFFF", + [":person_rowing_boat_tone1:"] = "\uD83D\uDEA3\uD83C\uDFFB", + [":person_rowing_boat_tone2:"] = "\uD83D\uDEA3\uD83C\uDFFC", + [":person_rowing_boat_tone3:"] = "\uD83D\uDEA3\uD83C\uDFFD", + [":person_rowing_boat_tone4:"] = "\uD83D\uDEA3\uD83C\uDFFE", + [":person_rowing_boat_tone5:"] = "\uD83D\uDEA3\uD83C\uDFFF", + [":person_running:"] = "\uD83C\uDFC3", + [":person_running::skin-tone-1:"] = "\uD83C\uDFC3\uD83C\uDFFB", + [":person_running::skin-tone-2:"] = "\uD83C\uDFC3\uD83C\uDFFC", + [":person_running::skin-tone-3:"] = "\uD83C\uDFC3\uD83C\uDFFD", + [":person_running::skin-tone-4:"] = "\uD83C\uDFC3\uD83C\uDFFE", + [":person_running::skin-tone-5:"] = "\uD83C\uDFC3\uD83C\uDFFF", + [":person_running_tone1:"] = "\uD83C\uDFC3\uD83C\uDFFB", + [":person_running_tone2:"] = "\uD83C\uDFC3\uD83C\uDFFC", + [":person_running_tone3:"] = "\uD83C\uDFC3\uD83C\uDFFD", + [":person_running_tone4:"] = "\uD83C\uDFC3\uD83C\uDFFE", + [":person_running_tone5:"] = "\uD83C\uDFC3\uD83C\uDFFF", + [":person_shrugging:"] = "\uD83E\uDD37", + [":person_shrugging::skin-tone-1:"] = "\uD83E\uDD37\uD83C\uDFFB", + [":person_shrugging::skin-tone-2:"] = "\uD83E\uDD37\uD83C\uDFFC", + [":person_shrugging::skin-tone-3:"] = "\uD83E\uDD37\uD83C\uDFFD", + [":person_shrugging::skin-tone-4:"] = "\uD83E\uDD37\uD83C\uDFFE", + [":person_shrugging::skin-tone-5:"] = "\uD83E\uDD37\uD83C\uDFFF", + [":person_shrugging_tone1:"] = "\uD83E\uDD37\uD83C\uDFFB", + [":person_shrugging_tone2:"] = "\uD83E\uDD37\uD83C\uDFFC", + [":person_shrugging_tone3:"] = "\uD83E\uDD37\uD83C\uDFFD", + [":person_shrugging_tone4:"] = "\uD83E\uDD37\uD83C\uDFFE", + [":person_shrugging_tone5:"] = "\uD83E\uDD37\uD83C\uDFFF", + [":person_standing:"] = "\uD83E\uDDCD", + [":person_standing::skin-tone-1:"] = "\uD83E\uDDCD\uD83C\uDFFB", + [":person_standing::skin-tone-2:"] = "\uD83E\uDDCD\uD83C\uDFFC", + [":person_standing::skin-tone-3:"] = "\uD83E\uDDCD\uD83C\uDFFD", + [":person_standing::skin-tone-4:"] = "\uD83E\uDDCD\uD83C\uDFFE", + [":person_standing::skin-tone-5:"] = "\uD83E\uDDCD\uD83C\uDFFF", + [":person_standing_dark_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFF", + [":person_standing_light_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFB", + [":person_standing_medium_dark_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFE", + [":person_standing_medium_light_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFC", + [":person_standing_medium_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFD", + [":person_standing_tone1:"] = "\uD83E\uDDCD\uD83C\uDFFB", + [":person_standing_tone2:"] = "\uD83E\uDDCD\uD83C\uDFFC", + [":person_standing_tone3:"] = "\uD83E\uDDCD\uD83C\uDFFD", + [":person_standing_tone4:"] = "\uD83E\uDDCD\uD83C\uDFFE", + [":person_standing_tone5:"] = "\uD83E\uDDCD\uD83C\uDFFF", + [":person_surfing:"] = "\uD83C\uDFC4", + [":person_surfing::skin-tone-1:"] = "\uD83C\uDFC4\uD83C\uDFFB", + [":person_surfing::skin-tone-2:"] = "\uD83C\uDFC4\uD83C\uDFFC", + [":person_surfing::skin-tone-3:"] = "\uD83C\uDFC4\uD83C\uDFFD", + [":person_surfing::skin-tone-4:"] = "\uD83C\uDFC4\uD83C\uDFFE", + [":person_surfing::skin-tone-5:"] = "\uD83C\uDFC4\uD83C\uDFFF", + [":person_surfing_tone1:"] = "\uD83C\uDFC4\uD83C\uDFFB", + [":person_surfing_tone2:"] = "\uD83C\uDFC4\uD83C\uDFFC", + [":person_surfing_tone3:"] = "\uD83C\uDFC4\uD83C\uDFFD", + [":person_surfing_tone4:"] = "\uD83C\uDFC4\uD83C\uDFFE", + [":person_surfing_tone5:"] = "\uD83C\uDFC4\uD83C\uDFFF", + [":person_swimming:"] = "\uD83C\uDFCA", + [":person_swimming::skin-tone-1:"] = "\uD83C\uDFCA\uD83C\uDFFB", + [":person_swimming::skin-tone-2:"] = "\uD83C\uDFCA\uD83C\uDFFC", + [":person_swimming::skin-tone-3:"] = "\uD83C\uDFCA\uD83C\uDFFD", + [":person_swimming::skin-tone-4:"] = "\uD83C\uDFCA\uD83C\uDFFE", + [":person_swimming::skin-tone-5:"] = "\uD83C\uDFCA\uD83C\uDFFF", + [":person_swimming_tone1:"] = "\uD83C\uDFCA\uD83C\uDFFB", + [":person_swimming_tone2:"] = "\uD83C\uDFCA\uD83C\uDFFC", + [":person_swimming_tone3:"] = "\uD83C\uDFCA\uD83C\uDFFD", + [":person_swimming_tone4:"] = "\uD83C\uDFCA\uD83C\uDFFE", + [":person_swimming_tone5:"] = "\uD83C\uDFCA\uD83C\uDFFF", + [":person_tipping_hand:"] = "\uD83D\uDC81", + [":person_tipping_hand::skin-tone-1:"] = "\uD83D\uDC81\uD83C\uDFFB", + [":person_tipping_hand::skin-tone-2:"] = "\uD83D\uDC81\uD83C\uDFFC", + [":person_tipping_hand::skin-tone-3:"] = "\uD83D\uDC81\uD83C\uDFFD", + [":person_tipping_hand::skin-tone-4:"] = "\uD83D\uDC81\uD83C\uDFFE", + [":person_tipping_hand::skin-tone-5:"] = "\uD83D\uDC81\uD83C\uDFFF", + [":person_tipping_hand_tone1:"] = "\uD83D\uDC81\uD83C\uDFFB", + [":person_tipping_hand_tone2:"] = "\uD83D\uDC81\uD83C\uDFFC", + [":person_tipping_hand_tone3:"] = "\uD83D\uDC81\uD83C\uDFFD", + [":person_tipping_hand_tone4:"] = "\uD83D\uDC81\uD83C\uDFFE", + [":person_tipping_hand_tone5:"] = "\uD83D\uDC81\uD83C\uDFFF", + [":person_walking:"] = "\uD83D\uDEB6", + [":person_walking::skin-tone-1:"] = "\uD83D\uDEB6\uD83C\uDFFB", + [":person_walking::skin-tone-2:"] = "\uD83D\uDEB6\uD83C\uDFFC", + [":person_walking::skin-tone-3:"] = "\uD83D\uDEB6\uD83C\uDFFD", + [":person_walking::skin-tone-4:"] = "\uD83D\uDEB6\uD83C\uDFFE", + [":person_walking::skin-tone-5:"] = "\uD83D\uDEB6\uD83C\uDFFF", + [":person_walking_tone1:"] = "\uD83D\uDEB6\uD83C\uDFFB", + [":person_walking_tone2:"] = "\uD83D\uDEB6\uD83C\uDFFC", + [":person_walking_tone3:"] = "\uD83D\uDEB6\uD83C\uDFFD", + [":person_walking_tone4:"] = "\uD83D\uDEB6\uD83C\uDFFE", + [":person_walking_tone5:"] = "\uD83D\uDEB6\uD83C\uDFFF", + [":person_wearing_turban:"] = "\uD83D\uDC73", + [":person_wearing_turban::skin-tone-1:"] = "\uD83D\uDC73\uD83C\uDFFB", + [":person_wearing_turban::skin-tone-2:"] = "\uD83D\uDC73\uD83C\uDFFC", + [":person_wearing_turban::skin-tone-3:"] = "\uD83D\uDC73\uD83C\uDFFD", + [":person_wearing_turban::skin-tone-4:"] = "\uD83D\uDC73\uD83C\uDFFE", + [":person_wearing_turban::skin-tone-5:"] = "\uD83D\uDC73\uD83C\uDFFF", + [":person_wearing_turban_tone1:"] = "\uD83D\uDC73\uD83C\uDFFB", + [":person_wearing_turban_tone2:"] = "\uD83D\uDC73\uD83C\uDFFC", + [":person_wearing_turban_tone3:"] = "\uD83D\uDC73\uD83C\uDFFD", + [":person_wearing_turban_tone4:"] = "\uD83D\uDC73\uD83C\uDFFE", + [":person_wearing_turban_tone5:"] = "\uD83D\uDC73\uD83C\uDFFF", + [":person_with_ball:"] = "⛹️", + [":person_with_ball::skin-tone-1:"] = "⛹\uD83C\uDFFB", + [":person_with_ball::skin-tone-2:"] = "⛹\uD83C\uDFFC", + [":person_with_ball::skin-tone-3:"] = "⛹\uD83C\uDFFD", + [":person_with_ball::skin-tone-4:"] = "⛹\uD83C\uDFFE", + [":person_with_ball::skin-tone-5:"] = "⛹\uD83C\uDFFF", + [":person_with_ball_tone1:"] = "⛹\uD83C\uDFFB", + [":person_with_ball_tone2:"] = "⛹\uD83C\uDFFC", + [":person_with_ball_tone3:"] = "⛹\uD83C\uDFFD", + [":person_with_ball_tone4:"] = "⛹\uD83C\uDFFE", + [":person_with_ball_tone5:"] = "⛹\uD83C\uDFFF", + [":person_with_blond_hair:"] = "\uD83D\uDC71", + [":person_with_blond_hair::skin-tone-1:"] = "\uD83D\uDC71\uD83C\uDFFB", + [":person_with_blond_hair::skin-tone-2:"] = "\uD83D\uDC71\uD83C\uDFFC", + [":person_with_blond_hair::skin-tone-3:"] = "\uD83D\uDC71\uD83C\uDFFD", + [":person_with_blond_hair::skin-tone-4:"] = "\uD83D\uDC71\uD83C\uDFFE", + [":person_with_blond_hair::skin-tone-5:"] = "\uD83D\uDC71\uD83C\uDFFF", + [":person_with_blond_hair_tone1:"] = "\uD83D\uDC71\uD83C\uDFFB", + [":person_with_blond_hair_tone2:"] = "\uD83D\uDC71\uD83C\uDFFC", + [":person_with_blond_hair_tone3:"] = "\uD83D\uDC71\uD83C\uDFFD", + [":person_with_blond_hair_tone4:"] = "\uD83D\uDC71\uD83C\uDFFE", + [":person_with_blond_hair_tone5:"] = "\uD83D\uDC71\uD83C\uDFFF", + [":person_with_crown:"] = "\uD83E\uDEC5", + [":person_with_crown::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown::skin-tone-5:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":person_with_crown_dark_skin_tone:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":person_with_crown_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":person_with_crown_tone5:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":person_with_pouting_face:"] = "\uD83D\uDE4E", + [":person_with_pouting_face::skin-tone-1:"] = "\uD83D\uDE4E\uD83C\uDFFB", + [":person_with_pouting_face::skin-tone-2:"] = "\uD83D\uDE4E\uD83C\uDFFC", + [":person_with_pouting_face::skin-tone-3:"] = "\uD83D\uDE4E\uD83C\uDFFD", + [":person_with_pouting_face::skin-tone-4:"] = "\uD83D\uDE4E\uD83C\uDFFE", + [":person_with_pouting_face::skin-tone-5:"] = "\uD83D\uDE4E\uD83C\uDFFF", + [":person_with_pouting_face_tone1:"] = "\uD83D\uDE4E\uD83C\uDFFB", + [":person_with_pouting_face_tone2:"] = "\uD83D\uDE4E\uD83C\uDFFC", + [":person_with_pouting_face_tone3:"] = "\uD83D\uDE4E\uD83C\uDFFD", + [":person_with_pouting_face_tone4:"] = "\uD83D\uDE4E\uD83C\uDFFE", + [":person_with_pouting_face_tone5:"] = "\uD83D\uDE4E\uD83C\uDFFF", + [":petri_dish:"] = "\uD83E\uDDEB", + [":pick:"] = "⛏️", + [":pickup_truck:"] = "\uD83D\uDEFB", + [":pie:"] = "\uD83E\uDD67", + [":pig2:"] = "\uD83D\uDC16", + [":pig:"] = "\uD83D\uDC37", + [":pig_nose:"] = "\uD83D\uDC3D", + [":pill:"] = "\uD83D\uDC8A", + [":pinata:"] = "\uD83E\uDE85", + [":pinched_fingers:"] = "\uD83E\uDD0C", + [":pinched_fingers::skin-tone-1:"] = "\uD83E\uDD0C\uD83C\uDFFB", + [":pinched_fingers::skin-tone-2:"] = "\uD83E\uDD0C\uD83C\uDFFC", + [":pinched_fingers::skin-tone-3:"] = "\uD83E\uDD0C\uD83C\uDFFD", + [":pinched_fingers::skin-tone-4:"] = "\uD83E\uDD0C\uD83C\uDFFE", + [":pinched_fingers::skin-tone-5:"] = "\uD83E\uDD0C\uD83C\uDFFF", + [":pinched_fingers_dark_skin_tone:"] = "\uD83E\uDD0C\uD83C\uDFFF", + [":pinched_fingers_light_skin_tone:"] = "\uD83E\uDD0C\uD83C\uDFFB", + [":pinched_fingers_medium_dark_skin_tone:"] = "\uD83E\uDD0C\uD83C\uDFFE", + [":pinched_fingers_medium_light_skin_tone:"] = "\uD83E\uDD0C\uD83C\uDFFC", + [":pinched_fingers_medium_skin_tone:"] = "\uD83E\uDD0C\uD83C\uDFFD", + [":pinched_fingers_tone1:"] = "\uD83E\uDD0C\uD83C\uDFFB", + [":pinched_fingers_tone2:"] = "\uD83E\uDD0C\uD83C\uDFFC", + [":pinched_fingers_tone3:"] = "\uD83E\uDD0C\uD83C\uDFFD", + [":pinched_fingers_tone4:"] = "\uD83E\uDD0C\uD83C\uDFFE", + [":pinched_fingers_tone5:"] = "\uD83E\uDD0C\uD83C\uDFFF", + [":pinching_hand:"] = "\uD83E\uDD0F", + [":pinching_hand::skin-tone-1:"] = "\uD83E\uDD0F\uD83C\uDFFB", + [":pinching_hand::skin-tone-2:"] = "\uD83E\uDD0F\uD83C\uDFFC", + [":pinching_hand::skin-tone-3:"] = "\uD83E\uDD0F\uD83C\uDFFD", + [":pinching_hand::skin-tone-4:"] = "\uD83E\uDD0F\uD83C\uDFFE", + [":pinching_hand::skin-tone-5:"] = "\uD83E\uDD0F\uD83C\uDFFF", + [":pinching_hand_dark_skin_tone:"] = "\uD83E\uDD0F\uD83C\uDFFF", + [":pinching_hand_light_skin_tone:"] = "\uD83E\uDD0F\uD83C\uDFFB", + [":pinching_hand_medium_dark_skin_tone:"] = "\uD83E\uDD0F\uD83C\uDFFE", + [":pinching_hand_medium_light_skin_tone:"] = "\uD83E\uDD0F\uD83C\uDFFC", + [":pinching_hand_medium_skin_tone:"] = "\uD83E\uDD0F\uD83C\uDFFD", + [":pinching_hand_tone1:"] = "\uD83E\uDD0F\uD83C\uDFFB", + [":pinching_hand_tone2:"] = "\uD83E\uDD0F\uD83C\uDFFC", + [":pinching_hand_tone3:"] = "\uD83E\uDD0F\uD83C\uDFFD", + [":pinching_hand_tone4:"] = "\uD83E\uDD0F\uD83C\uDFFE", + [":pinching_hand_tone5:"] = "\uD83E\uDD0F\uD83C\uDFFF", + [":pineapple:"] = "\uD83C\uDF4D", + [":ping_pong:"] = "\uD83C\uDFD3", + [":pirate_flag:"] = "\uD83C\uDFF4\u200D☠️", + [":pisces:"] = "♓", + [":pizza:"] = "\uD83C\uDF55", + [":placard:"] = "\uD83E\uDEA7", + [":place_of_worship:"] = "\uD83D\uDED0", + [":play_pause:"] = "⏯️", + [":playground_slide:"] = "\uD83D\uDEDD", + [":pleading_face:"] = "\uD83E\uDD7A", + [":plunger:"] = "\uD83E\uDEA0", + [":point_down:"] = "\uD83D\uDC47", + [":point_down::skin-tone-1:"] = "\uD83D\uDC47\uD83C\uDFFB", + [":point_down::skin-tone-2:"] = "\uD83D\uDC47\uD83C\uDFFC", + [":point_down::skin-tone-3:"] = "\uD83D\uDC47\uD83C\uDFFD", + [":point_down::skin-tone-4:"] = "\uD83D\uDC47\uD83C\uDFFE", + [":point_down::skin-tone-5:"] = "\uD83D\uDC47\uD83C\uDFFF", + [":point_down_tone1:"] = "\uD83D\uDC47\uD83C\uDFFB", + [":point_down_tone2:"] = "\uD83D\uDC47\uD83C\uDFFC", + [":point_down_tone3:"] = "\uD83D\uDC47\uD83C\uDFFD", + [":point_down_tone4:"] = "\uD83D\uDC47\uD83C\uDFFE", + [":point_down_tone5:"] = "\uD83D\uDC47\uD83C\uDFFF", + [":point_left:"] = "\uD83D\uDC48", + [":point_left::skin-tone-1:"] = "\uD83D\uDC48\uD83C\uDFFB", + [":point_left::skin-tone-2:"] = "\uD83D\uDC48\uD83C\uDFFC", + [":point_left::skin-tone-3:"] = "\uD83D\uDC48\uD83C\uDFFD", + [":point_left::skin-tone-4:"] = "\uD83D\uDC48\uD83C\uDFFE", + [":point_left::skin-tone-5:"] = "\uD83D\uDC48\uD83C\uDFFF", + [":point_left_tone1:"] = "\uD83D\uDC48\uD83C\uDFFB", + [":point_left_tone2:"] = "\uD83D\uDC48\uD83C\uDFFC", + [":point_left_tone3:"] = "\uD83D\uDC48\uD83C\uDFFD", + [":point_left_tone4:"] = "\uD83D\uDC48\uD83C\uDFFE", + [":point_left_tone5:"] = "\uD83D\uDC48\uD83C\uDFFF", + [":point_right:"] = "\uD83D\uDC49", + [":point_right::skin-tone-1:"] = "\uD83D\uDC49\uD83C\uDFFB", + [":point_right::skin-tone-2:"] = "\uD83D\uDC49\uD83C\uDFFC", + [":point_right::skin-tone-3:"] = "\uD83D\uDC49\uD83C\uDFFD", + [":point_right::skin-tone-4:"] = "\uD83D\uDC49\uD83C\uDFFE", + [":point_right::skin-tone-5:"] = "\uD83D\uDC49\uD83C\uDFFF", + [":point_right_tone1:"] = "\uD83D\uDC49\uD83C\uDFFB", + [":point_right_tone2:"] = "\uD83D\uDC49\uD83C\uDFFC", + [":point_right_tone3:"] = "\uD83D\uDC49\uD83C\uDFFD", + [":point_right_tone4:"] = "\uD83D\uDC49\uD83C\uDFFE", + [":point_right_tone5:"] = "\uD83D\uDC49\uD83C\uDFFF", + [":point_up:"] = "☝️", + [":point_up::skin-tone-1:"] = "☝\uD83C\uDFFB", + [":point_up::skin-tone-2:"] = "☝\uD83C\uDFFC", + [":point_up::skin-tone-3:"] = "☝\uD83C\uDFFD", + [":point_up::skin-tone-4:"] = "☝\uD83C\uDFFE", + [":point_up::skin-tone-5:"] = "☝\uD83C\uDFFF", + [":point_up_2:"] = "\uD83D\uDC46", + [":point_up_2::skin-tone-1:"] = "\uD83D\uDC46\uD83C\uDFFB", + [":point_up_2::skin-tone-2:"] = "\uD83D\uDC46\uD83C\uDFFC", + [":point_up_2::skin-tone-3:"] = "\uD83D\uDC46\uD83C\uDFFD", + [":point_up_2::skin-tone-4:"] = "\uD83D\uDC46\uD83C\uDFFE", + [":point_up_2::skin-tone-5:"] = "\uD83D\uDC46\uD83C\uDFFF", + [":point_up_2_tone1:"] = "\uD83D\uDC46\uD83C\uDFFB", + [":point_up_2_tone2:"] = "\uD83D\uDC46\uD83C\uDFFC", + [":point_up_2_tone3:"] = "\uD83D\uDC46\uD83C\uDFFD", + [":point_up_2_tone4:"] = "\uD83D\uDC46\uD83C\uDFFE", + [":point_up_2_tone5:"] = "\uD83D\uDC46\uD83C\uDFFF", + [":point_up_tone1:"] = "☝\uD83C\uDFFB", + [":point_up_tone2:"] = "☝\uD83C\uDFFC", + [":point_up_tone3:"] = "☝\uD83C\uDFFD", + [":point_up_tone4:"] = "☝\uD83C\uDFFE", + [":point_up_tone5:"] = "☝\uD83C\uDFFF", + [":polar_bear:"] = "\uD83D\uDC3B\u200D\u2744\uFE0F", + [":police_car:"] = "\uD83D\uDE93", + [":police_officer:"] = "\uD83D\uDC6E", + [":police_officer::skin-tone-1:"] = "\uD83D\uDC6E\uD83C\uDFFB", + [":police_officer::skin-tone-2:"] = "\uD83D\uDC6E\uD83C\uDFFC", + [":police_officer::skin-tone-3:"] = "\uD83D\uDC6E\uD83C\uDFFD", + [":police_officer::skin-tone-4:"] = "\uD83D\uDC6E\uD83C\uDFFE", + [":police_officer::skin-tone-5:"] = "\uD83D\uDC6E\uD83C\uDFFF", + [":police_officer_tone1:"] = "\uD83D\uDC6E\uD83C\uDFFB", + [":police_officer_tone2:"] = "\uD83D\uDC6E\uD83C\uDFFC", + [":police_officer_tone3:"] = "\uD83D\uDC6E\uD83C\uDFFD", + [":police_officer_tone4:"] = "\uD83D\uDC6E\uD83C\uDFFE", + [":police_officer_tone5:"] = "\uD83D\uDC6E\uD83C\uDFFF", + [":poo:"] = "\uD83D\uDCA9", + [":poodle:"] = "\uD83D\uDC29", + [":poop:"] = "\uD83D\uDCA9", + [":popcorn:"] = "\uD83C\uDF7F", + [":post_office:"] = "\uD83C\uDFE3", + [":postal_horn:"] = "\uD83D\uDCEF", + [":postbox:"] = "\uD83D\uDCEE", + [":potable_water:"] = "\uD83D\uDEB0", + [":potato:"] = "\uD83E\uDD54", + [":potted_plant:"] = "\uD83E\uDEB4", + [":pouch:"] = "\uD83D\uDC5D", + [":poultry_leg:"] = "\uD83C\uDF57", + [":pound:"] = "\uD83D\uDCB7", + [":pouring_liquid:"] = "\uD83E\uDED7", + [":pouting_cat:"] = "\uD83D\uDE3E", + [":pray:"] = "\uD83D\uDE4F", + [":pray::skin-tone-1:"] = "\uD83D\uDE4F\uD83C\uDFFB", + [":pray::skin-tone-2:"] = "\uD83D\uDE4F\uD83C\uDFFC", + [":pray::skin-tone-3:"] = "\uD83D\uDE4F\uD83C\uDFFD", + [":pray::skin-tone-4:"] = "\uD83D\uDE4F\uD83C\uDFFE", + [":pray::skin-tone-5:"] = "\uD83D\uDE4F\uD83C\uDFFF", + [":pray_tone1:"] = "\uD83D\uDE4F\uD83C\uDFFB", + [":pray_tone2:"] = "\uD83D\uDE4F\uD83C\uDFFC", + [":pray_tone3:"] = "\uD83D\uDE4F\uD83C\uDFFD", + [":pray_tone4:"] = "\uD83D\uDE4F\uD83C\uDFFE", + [":pray_tone5:"] = "\uD83D\uDE4F\uD83C\uDFFF", + [":prayer_beads:"] = "\uD83D\uDCFF", + [":pregnant_man:"] = "\uD83E\uDEC3", + [":pregnant_man::skin-tone-1:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":pregnant_man::skin-tone-2:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":pregnant_man::skin-tone-3:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man::skin-tone-4:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man::skin-tone-5:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man_dark_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man_light_skin_tone:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":pregnant_man_medium_dark_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man_medium_light_skin_tone:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":pregnant_man_medium_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man_tone1:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":pregnant_man_tone2:"] = "\uD83E\uDEC5\uD83C\uDFFF", + [":pregnant_man_tone3:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man_tone4:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_man_tone5:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person:"] = "\uD83E\uDEC4", + [":pregnant_person::skin-tone-1:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person::skin-tone-2:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person::skin-tone-3:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person::skin-tone-4:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person::skin-tone-5:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_dark_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_light_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_medium_dark_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_medium_light_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_medium_skin_tone:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_tone1:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_tone2:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_tone3:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_tone4:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_person_tone5:"] = "\uD83E\uDEC3\uD83C\uDFFD", + [":pregnant_woman:"] = "\uD83E\uDD30", + [":pregnant_woman::skin-tone-1:"] = "\uD83E\uDD30\uD83C\uDFFB", + [":pregnant_woman::skin-tone-2:"] = "\uD83E\uDD30\uD83C\uDFFC", + [":pregnant_woman::skin-tone-3:"] = "\uD83E\uDD30\uD83C\uDFFD", + [":pregnant_woman::skin-tone-4:"] = "\uD83E\uDD30\uD83C\uDFFE", + [":pregnant_woman::skin-tone-5:"] = "\uD83E\uDD30\uD83C\uDFFF", + [":pregnant_woman_tone1:"] = "\uD83E\uDD30\uD83C\uDFFB", + [":pregnant_woman_tone2:"] = "\uD83E\uDD30\uD83C\uDFFC", + [":pregnant_woman_tone3:"] = "\uD83E\uDD30\uD83C\uDFFD", + [":pregnant_woman_tone4:"] = "\uD83E\uDD30\uD83C\uDFFE", + [":pregnant_woman_tone5:"] = "\uD83E\uDD30\uD83C\uDFFF", + [":pretzel:"] = "\uD83E\uDD68", + [":previous_track:"] = "⏮️", + [":prince:"] = "\uD83E\uDD34", + [":prince::skin-tone-1:"] = "\uD83E\uDD34\uD83C\uDFFB", + [":prince::skin-tone-2:"] = "\uD83E\uDD34\uD83C\uDFFC", + [":prince::skin-tone-3:"] = "\uD83E\uDD34\uD83C\uDFFD", + [":prince::skin-tone-4:"] = "\uD83E\uDD34\uD83C\uDFFE", + [":prince::skin-tone-5:"] = "\uD83E\uDD34\uD83C\uDFFF", + [":prince_tone1:"] = "\uD83E\uDD34\uD83C\uDFFB", + [":prince_tone2:"] = "\uD83E\uDD34\uD83C\uDFFC", + [":prince_tone3:"] = "\uD83E\uDD34\uD83C\uDFFD", + [":prince_tone4:"] = "\uD83E\uDD34\uD83C\uDFFE", + [":prince_tone5:"] = "\uD83E\uDD34\uD83C\uDFFF", + [":princess:"] = "\uD83D\uDC78", + [":princess::skin-tone-1:"] = "\uD83D\uDC78\uD83C\uDFFB", + [":princess::skin-tone-2:"] = "\uD83D\uDC78\uD83C\uDFFC", + [":princess::skin-tone-3:"] = "\uD83D\uDC78\uD83C\uDFFD", + [":princess::skin-tone-4:"] = "\uD83D\uDC78\uD83C\uDFFE", + [":princess::skin-tone-5:"] = "\uD83D\uDC78\uD83C\uDFFF", + [":princess_tone1:"] = "\uD83D\uDC78\uD83C\uDFFB", + [":princess_tone2:"] = "\uD83D\uDC78\uD83C\uDFFC", + [":princess_tone3:"] = "\uD83D\uDC78\uD83C\uDFFD", + [":princess_tone4:"] = "\uD83D\uDC78\uD83C\uDFFE", + [":princess_tone5:"] = "\uD83D\uDC78\uD83C\uDFFF", + [":printer:"] = "\uD83D\uDDA8️", + [":probing_cane:"] = "\uD83E\uDDAF", + [":projector:"] = "\uD83D\uDCFD️", + [":pudding:"] = "\uD83C\uDF6E", + [":punch:"] = "\uD83D\uDC4A", + [":punch::skin-tone-1:"] = "\uD83D\uDC4A\uD83C\uDFFB", + [":punch::skin-tone-2:"] = "\uD83D\uDC4A\uD83C\uDFFC", + [":punch::skin-tone-3:"] = "\uD83D\uDC4A\uD83C\uDFFD", + [":punch::skin-tone-4:"] = "\uD83D\uDC4A\uD83C\uDFFE", + [":punch::skin-tone-5:"] = "\uD83D\uDC4A\uD83C\uDFFF", + [":punch_tone1:"] = "\uD83D\uDC4A\uD83C\uDFFB", + [":punch_tone2:"] = "\uD83D\uDC4A\uD83C\uDFFC", + [":punch_tone3:"] = "\uD83D\uDC4A\uD83C\uDFFD", + [":punch_tone4:"] = "\uD83D\uDC4A\uD83C\uDFFE", + [":punch_tone5:"] = "\uD83D\uDC4A\uD83C\uDFFF", + [":purple_circle:"] = "\uD83D\uDFE3", + [":purple_heart:"] = "\uD83D\uDC9C", + [":purple_square:"] = "\uD83D\uDFEA", + [":purse:"] = "\uD83D\uDC5B", + [":pushpin:"] = "\uD83D\uDCCC", + [":put_litter_in_its_place:"] = "\uD83D\uDEAE", + [":question:"] = "❓", + [":rabbit2:"] = "\uD83D\uDC07", + [":rabbit:"] = "\uD83D\uDC30", + [":raccoon:"] = "\uD83E\uDD9D", + [":race_car:"] = "\uD83C\uDFCE️", + [":racehorse:"] = "\uD83D\uDC0E", + [":racing_car:"] = "\uD83C\uDFCE️", + [":racing_motorcycle:"] = "\uD83C\uDFCD️", + [":radio:"] = "\uD83D\uDCFB", + [":radio_button:"] = "\uD83D\uDD18", + [":radioactive:"] = "☢️", + [":radioactive_sign:"] = "☢️", + [":rage:"] = "\uD83D\uDE21", + [":railroad_track:"] = "\uD83D\uDEE4️", + [":railway_car:"] = "\uD83D\uDE83", + [":railway_track:"] = "\uD83D\uDEE4️", + [":rainbow:"] = "\uD83C\uDF08", + [":rainbow_flag:"] = "\uD83C\uDFF3️\u200D\uD83C\uDF08", + [":raised_back_of_hand:"] = "\uD83E\uDD1A", + [":raised_back_of_hand::skin-tone-1:"] = "\uD83E\uDD1A\uD83C\uDFFB", + [":raised_back_of_hand::skin-tone-2:"] = "\uD83E\uDD1A\uD83C\uDFFC", + [":raised_back_of_hand::skin-tone-3:"] = "\uD83E\uDD1A\uD83C\uDFFD", + [":raised_back_of_hand::skin-tone-4:"] = "\uD83E\uDD1A\uD83C\uDFFE", + [":raised_back_of_hand::skin-tone-5:"] = "\uD83E\uDD1A\uD83C\uDFFF", + [":raised_back_of_hand_tone1:"] = "\uD83E\uDD1A\uD83C\uDFFB", + [":raised_back_of_hand_tone2:"] = "\uD83E\uDD1A\uD83C\uDFFC", + [":raised_back_of_hand_tone3:"] = "\uD83E\uDD1A\uD83C\uDFFD", + [":raised_back_of_hand_tone4:"] = "\uD83E\uDD1A\uD83C\uDFFE", + [":raised_back_of_hand_tone5:"] = "\uD83E\uDD1A\uD83C\uDFFF", + [":raised_hand:"] = "✋", + [":raised_hand::skin-tone-1:"] = "✋\uD83C\uDFFB", + [":raised_hand::skin-tone-2:"] = "✋\uD83C\uDFFC", + [":raised_hand::skin-tone-3:"] = "✋\uD83C\uDFFD", + [":raised_hand::skin-tone-4:"] = "✋\uD83C\uDFFE", + [":raised_hand::skin-tone-5:"] = "✋\uD83C\uDFFF", + [":raised_hand_tone1:"] = "✋\uD83C\uDFFB", + [":raised_hand_tone2:"] = "✋\uD83C\uDFFC", + [":raised_hand_tone3:"] = "✋\uD83C\uDFFD", + [":raised_hand_tone4:"] = "✋\uD83C\uDFFE", + [":raised_hand_tone5:"] = "✋\uD83C\uDFFF", + [":raised_hand_with_fingers_splayed:"] = "\uD83D\uDD90️", + [":raised_hand_with_fingers_splayed::skin-tone-1:"] = "\uD83D\uDD90\uD83C\uDFFB", + [":raised_hand_with_fingers_splayed::skin-tone-2:"] = "\uD83D\uDD90\uD83C\uDFFC", + [":raised_hand_with_fingers_splayed::skin-tone-3:"] = "\uD83D\uDD90\uD83C\uDFFD", + [":raised_hand_with_fingers_splayed::skin-tone-4:"] = "\uD83D\uDD90\uD83C\uDFFE", + [":raised_hand_with_fingers_splayed::skin-tone-5:"] = "\uD83D\uDD90\uD83C\uDFFF", + [":raised_hand_with_fingers_splayed_tone1:"] = "\uD83D\uDD90\uD83C\uDFFB", + [":raised_hand_with_fingers_splayed_tone2:"] = "\uD83D\uDD90\uD83C\uDFFC", + [":raised_hand_with_fingers_splayed_tone3:"] = "\uD83D\uDD90\uD83C\uDFFD", + [":raised_hand_with_fingers_splayed_tone4:"] = "\uD83D\uDD90\uD83C\uDFFE", + [":raised_hand_with_fingers_splayed_tone5:"] = "\uD83D\uDD90\uD83C\uDFFF", + [":raised_hand_with_part_between_middle_and_ring_fingers:"] = "\uD83D\uDD96", + [":raised_hand_with_part_between_middle_and_ring_fingers::skin-tone-1:"] = "\uD83D\uDD96\uD83C\uDFFB", + [":raised_hand_with_part_between_middle_and_ring_fingers::skin-tone-2:"] = "\uD83D\uDD96\uD83C\uDFFC", + [":raised_hand_with_part_between_middle_and_ring_fingers::skin-tone-3:"] = "\uD83D\uDD96\uD83C\uDFFD", + [":raised_hand_with_part_between_middle_and_ring_fingers::skin-tone-4:"] = "\uD83D\uDD96\uD83C\uDFFE", + [":raised_hand_with_part_between_middle_and_ring_fingers::skin-tone-5:"] = "\uD83D\uDD96\uD83C\uDFFF", + [":raised_hand_with_part_between_middle_and_ring_fingers_tone1:"] = "\uD83D\uDD96\uD83C\uDFFB", + [":raised_hand_with_part_between_middle_and_ring_fingers_tone2:"] = "\uD83D\uDD96\uD83C\uDFFC", + [":raised_hand_with_part_between_middle_and_ring_fingers_tone3:"] = "\uD83D\uDD96\uD83C\uDFFD", + [":raised_hand_with_part_between_middle_and_ring_fingers_tone4:"] = "\uD83D\uDD96\uD83C\uDFFE", + [":raised_hand_with_part_between_middle_and_ring_fingers_tone5:"] = "\uD83D\uDD96\uD83C\uDFFF", + [":raised_hands:"] = "\uD83D\uDE4C", + [":raised_hands::skin-tone-1:"] = "\uD83D\uDE4C\uD83C\uDFFB", + [":raised_hands::skin-tone-2:"] = "\uD83D\uDE4C\uD83C\uDFFC", + [":raised_hands::skin-tone-3:"] = "\uD83D\uDE4C\uD83C\uDFFD", + [":raised_hands::skin-tone-4:"] = "\uD83D\uDE4C\uD83C\uDFFE", + [":raised_hands::skin-tone-5:"] = "\uD83D\uDE4C\uD83C\uDFFF", + [":raised_hands_tone1:"] = "\uD83D\uDE4C\uD83C\uDFFB", + [":raised_hands_tone2:"] = "\uD83D\uDE4C\uD83C\uDFFC", + [":raised_hands_tone3:"] = "\uD83D\uDE4C\uD83C\uDFFD", + [":raised_hands_tone4:"] = "\uD83D\uDE4C\uD83C\uDFFE", + [":raised_hands_tone5:"] = "\uD83D\uDE4C\uD83C\uDFFF", + [":raising_hand:"] = "\uD83D\uDE4B", + [":raising_hand::skin-tone-1:"] = "\uD83D\uDE4B\uD83C\uDFFB", + [":raising_hand::skin-tone-2:"] = "\uD83D\uDE4B\uD83C\uDFFC", + [":raising_hand::skin-tone-3:"] = "\uD83D\uDE4B\uD83C\uDFFD", + [":raising_hand::skin-tone-4:"] = "\uD83D\uDE4B\uD83C\uDFFE", + [":raising_hand::skin-tone-5:"] = "\uD83D\uDE4B\uD83C\uDFFF", + [":raising_hand_tone1:"] = "\uD83D\uDE4B\uD83C\uDFFB", + [":raising_hand_tone2:"] = "\uD83D\uDE4B\uD83C\uDFFC", + [":raising_hand_tone3:"] = "\uD83D\uDE4B\uD83C\uDFFD", + [":raising_hand_tone4:"] = "\uD83D\uDE4B\uD83C\uDFFE", + [":raising_hand_tone5:"] = "\uD83D\uDE4B\uD83C\uDFFF", + [":ram:"] = "\uD83D\uDC0F", + [":ramen:"] = "\uD83C\uDF5C", + [":rat:"] = "\uD83D\uDC00", + [":razor:"] = "\uD83E\uDE92", + [":receipt:"] = "\uD83E\uDDFE", + [":record_button:"] = "⏺️", + [":recycle:"] = "♻️", + [":red_car:"] = "\uD83D\uDE97", + [":red_circle:"] = "\uD83D\uDD34", + [":red_envelope:"] = "\uD83E\uDDE7", + [":red_square:"] = "\uD83D\uDFE5", + [":regional_indicator_a:"] = "\uD83C\uDDE6", + [":regional_indicator_b:"] = "\uD83C\uDDE7", + [":regional_indicator_c:"] = "\uD83C\uDDE8", + [":regional_indicator_d:"] = "\uD83C\uDDE9", + [":regional_indicator_e:"] = "\uD83C\uDDEA", + [":regional_indicator_f:"] = "\uD83C\uDDEB", + [":regional_indicator_g:"] = "\uD83C\uDDEC", + [":regional_indicator_h:"] = "\uD83C\uDDED", + [":regional_indicator_i:"] = "\uD83C\uDDEE", + [":regional_indicator_j:"] = "\uD83C\uDDEF", + [":regional_indicator_k:"] = "\uD83C\uDDF0", + [":regional_indicator_l:"] = "\uD83C\uDDF1", + [":regional_indicator_m:"] = "\uD83C\uDDF2", + [":regional_indicator_n:"] = "\uD83C\uDDF3", + [":regional_indicator_o:"] = "\uD83C\uDDF4", + [":regional_indicator_p:"] = "\uD83C\uDDF5", + [":regional_indicator_q:"] = "\uD83C\uDDF6", + [":regional_indicator_r:"] = "\uD83C\uDDF7", + [":regional_indicator_s:"] = "\uD83C\uDDF8", + [":regional_indicator_t:"] = "\uD83C\uDDF9", + [":regional_indicator_u:"] = "\uD83C\uDDFA", + [":regional_indicator_v:"] = "\uD83C\uDDFB", + [":regional_indicator_w:"] = "\uD83C\uDDFC", + [":regional_indicator_x:"] = "\uD83C\uDDFD", + [":regional_indicator_y:"] = "\uD83C\uDDFE", + [":regional_indicator_z:"] = "\uD83C\uDDFF", + [":registered:"] = "®️", + [":relaxed:"] = "☺️", + [":relieved:"] = "\uD83D\uDE0C", + [":reminder_ribbon:"] = "\uD83C\uDF97️", + [":repeat:"] = "\uD83D\uDD01", + [":repeat_one:"] = "\uD83D\uDD02", + [":restroom:"] = "\uD83D\uDEBB", + [":reversed_hand_with_middle_finger_extended:"] = "\uD83D\uDD95", + [":reversed_hand_with_middle_finger_extended::skin-tone-1:"] = "\uD83D\uDD95\uD83C\uDFFB", + [":reversed_hand_with_middle_finger_extended::skin-tone-2:"] = "\uD83D\uDD95\uD83C\uDFFC", + [":reversed_hand_with_middle_finger_extended::skin-tone-3:"] = "\uD83D\uDD95\uD83C\uDFFD", + [":reversed_hand_with_middle_finger_extended::skin-tone-4:"] = "\uD83D\uDD95\uD83C\uDFFE", + [":reversed_hand_with_middle_finger_extended::skin-tone-5:"] = "\uD83D\uDD95\uD83C\uDFFF", + [":reversed_hand_with_middle_finger_extended_tone1:"] = "\uD83D\uDD95\uD83C\uDFFB", + [":reversed_hand_with_middle_finger_extended_tone2:"] = "\uD83D\uDD95\uD83C\uDFFC", + [":reversed_hand_with_middle_finger_extended_tone3:"] = "\uD83D\uDD95\uD83C\uDFFD", + [":reversed_hand_with_middle_finger_extended_tone4:"] = "\uD83D\uDD95\uD83C\uDFFE", + [":reversed_hand_with_middle_finger_extended_tone5:"] = "\uD83D\uDD95\uD83C\uDFFF", + [":revolving_hearts:"] = "\uD83D\uDC9E", + [":rewind:"] = "⏪", + [":rhino:"] = "\uD83E\uDD8F", + [":rhinoceros:"] = "\uD83E\uDD8F", + [":ribbon:"] = "\uD83C\uDF80", + [":rice:"] = "\uD83C\uDF5A", + [":rice_ball:"] = "\uD83C\uDF59", + [":rice_cracker:"] = "\uD83C\uDF58", + [":rice_scene:"] = "\uD83C\uDF91", + [":right_anger_bubble:"] = "\uD83D\uDDEF️", + [":right_facing_fist:"] = "\uD83E\uDD1C", + [":right_facing_fist::skin-tone-1:"] = "\uD83E\uDD1C\uD83C\uDFFB", + [":right_facing_fist::skin-tone-2:"] = "\uD83E\uDD1C\uD83C\uDFFC", + [":right_facing_fist::skin-tone-3:"] = "\uD83E\uDD1C\uD83C\uDFFD", + [":right_facing_fist::skin-tone-4:"] = "\uD83E\uDD1C\uD83C\uDFFE", + [":right_facing_fist::skin-tone-5:"] = "\uD83E\uDD1C\uD83C\uDFFF", + [":right_facing_fist_tone1:"] = "\uD83E\uDD1C\uD83C\uDFFB", + [":right_facing_fist_tone2:"] = "\uD83E\uDD1C\uD83C\uDFFC", + [":right_facing_fist_tone3:"] = "\uD83E\uDD1C\uD83C\uDFFD", + [":right_facing_fist_tone4:"] = "\uD83E\uDD1C\uD83C\uDFFE", + [":right_facing_fist_tone5:"] = "\uD83E\uDD1C\uD83C\uDFFF", + [":right_fist:"] = "\uD83E\uDD1C", + [":right_fist::skin-tone-1:"] = "\uD83E\uDD1C\uD83C\uDFFB", + [":right_fist::skin-tone-2:"] = "\uD83E\uDD1C\uD83C\uDFFC", + [":right_fist::skin-tone-3:"] = "\uD83E\uDD1C\uD83C\uDFFD", + [":right_fist::skin-tone-4:"] = "\uD83E\uDD1C\uD83C\uDFFE", + [":right_fist::skin-tone-5:"] = "\uD83E\uDD1C\uD83C\uDFFF", + [":right_fist_tone1:"] = "\uD83E\uDD1C\uD83C\uDFFB", + [":right_fist_tone2:"] = "\uD83E\uDD1C\uD83C\uDFFC", + [":right_fist_tone3:"] = "\uD83E\uDD1C\uD83C\uDFFD", + [":right_fist_tone4:"] = "\uD83E\uDD1C\uD83C\uDFFE", + [":right_fist_tone5:"] = "\uD83E\uDD1C\uD83C\uDFFF", + [":rightwards_hand:"] = "\uD83E\uDEF1", + [":rightwards_hand::skin-tone-1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand::skin-tone-2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand::skin-tone-3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand::skin-tone-4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand::skin-tone-5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_medium_dark_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_medium_light_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_medium_skin_tone:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_tone1:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_tone2:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_tone3:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_tone4:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":rightwards_hand_tone5:"] = "\uD83E\uDEF1\uD83C\uDFFB", + [":ring:"] = "\uD83D\uDC8D", + [":ring_buoy:"] = "\uD83D\uDEDF", + [":ringed_planet:"] = "\uD83E\uDE90", + [":robot:"] = "\uD83E\uDD16", + [":robot_face:"] = "\uD83E\uDD16", + [":rock:"] = "\uD83E\uDEA8", + [":rocket:"] = "\uD83D\uDE80", + [":rofl:"] = "\uD83E\uDD23", + [":roll_of_paper:"] = "\uD83E\uDDFB", + [":rolled_up_newspaper:"] = "\uD83D\uDDDE️", + [":roller_coaster:"] = "\uD83C\uDFA2", + [":roller_skate:"] = "\uD83D\uDEFC", + [":rolling_eyes:"] = "\uD83D\uDE44", + [":rolling_on_the_floor_laughing:"] = "\uD83E\uDD23", + [":rooster:"] = "\uD83D\uDC13", + [":rose:"] = "\uD83C\uDF39", + [":rosette:"] = "\uD83C\uDFF5️", + [":rotating_light:"] = "\uD83D\uDEA8", + [":round_pushpin:"] = "\uD83D\uDCCD", + [":rowboat:"] = "\uD83D\uDEA3", + [":rowboat::skin-tone-1:"] = "\uD83D\uDEA3\uD83C\uDFFB", + [":rowboat::skin-tone-2:"] = "\uD83D\uDEA3\uD83C\uDFFC", + [":rowboat::skin-tone-3:"] = "\uD83D\uDEA3\uD83C\uDFFD", + [":rowboat::skin-tone-4:"] = "\uD83D\uDEA3\uD83C\uDFFE", + [":rowboat::skin-tone-5:"] = "\uD83D\uDEA3\uD83C\uDFFF", + [":rowboat_tone1:"] = "\uD83D\uDEA3\uD83C\uDFFB", + [":rowboat_tone2:"] = "\uD83D\uDEA3\uD83C\uDFFC", + [":rowboat_tone3:"] = "\uD83D\uDEA3\uD83C\uDFFD", + [":rowboat_tone4:"] = "\uD83D\uDEA3\uD83C\uDFFE", + [":rowboat_tone5:"] = "\uD83D\uDEA3\uD83C\uDFFF", + [":rugby_football:"] = "\uD83C\uDFC9", + [":runner:"] = "\uD83C\uDFC3", + [":runner::skin-tone-1:"] = "\uD83C\uDFC3\uD83C\uDFFB", + [":runner::skin-tone-2:"] = "\uD83C\uDFC3\uD83C\uDFFC", + [":runner::skin-tone-3:"] = "\uD83C\uDFC3\uD83C\uDFFD", + [":runner::skin-tone-4:"] = "\uD83C\uDFC3\uD83C\uDFFE", + [":runner::skin-tone-5:"] = "\uD83C\uDFC3\uD83C\uDFFF", + [":runner_tone1:"] = "\uD83C\uDFC3\uD83C\uDFFB", + [":runner_tone2:"] = "\uD83C\uDFC3\uD83C\uDFFC", + [":runner_tone3:"] = "\uD83C\uDFC3\uD83C\uDFFD", + [":runner_tone4:"] = "\uD83C\uDFC3\uD83C\uDFFE", + [":runner_tone5:"] = "\uD83C\uDFC3\uD83C\uDFFF", + [":running_shirt_with_sash:"] = "\uD83C\uDFBD", + [":s"] = "\uD83D\uDE12", + [":sa:"] = "\uD83C\uDE02️", + [":safety_pin:"] = "\uD83E\uDDF7", + [":safety_vest:"] = "\uD83E\uDDBA", + [":sagittarius:"] = "♐", + [":sailboat:"] = "⛵", + [":sake:"] = "\uD83C\uDF76", + [":salad:"] = "\uD83E\uDD57", + [":salt:"] = "\uD83E\uDDC2", + [":saluting_face:"] = "\uD83E\uDEE1", + [":sandal:"] = "\uD83D\uDC61", + [":sandwich:"] = "\uD83E\uDD6A", + [":santa:"] = "\uD83C\uDF85", + [":santa::skin-tone-1:"] = "\uD83C\uDF85\uD83C\uDFFB", + [":santa::skin-tone-2:"] = "\uD83C\uDF85\uD83C\uDFFC", + [":santa::skin-tone-3:"] = "\uD83C\uDF85\uD83C\uDFFD", + [":santa::skin-tone-4:"] = "\uD83C\uDF85\uD83C\uDFFE", + [":santa::skin-tone-5:"] = "\uD83C\uDF85\uD83C\uDFFF", + [":santa_tone1:"] = "\uD83C\uDF85\uD83C\uDFFB", + [":santa_tone2:"] = "\uD83C\uDF85\uD83C\uDFFC", + [":santa_tone3:"] = "\uD83C\uDF85\uD83C\uDFFD", + [":santa_tone4:"] = "\uD83C\uDF85\uD83C\uDFFE", + [":santa_tone5:"] = "\uD83C\uDF85\uD83C\uDFFF", + [":sari:"] = "\uD83E\uDD7B", + [":satellite:"] = "\uD83D\uDCE1", + [":satellite_orbital:"] = "\uD83D\uDEF0️", + [":satisfied:"] = "\uD83D\uDE06", + [":sauropod:"] = "\uD83E\uDD95", + [":saxophone:"] = "\uD83C\uDFB7", + [":scales:"] = "⚖️", + [":scarf:"] = "\uD83E\uDDE3", + [":school:"] = "\uD83C\uDFEB", + [":school_satchel:"] = "\uD83C\uDF92", + [":scissors:"] = "✂️", + [":scooter:"] = "\uD83D\uDEF4", + [":scorpion:"] = "\uD83E\uDD82", + [":scorpius:"] = "♏", + [":scotland:"] = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74\uDB40\uDC7F", + [":scream:"] = "\uD83D\uDE31", + [":scream_cat:"] = "\uD83D\uDE40", + [":screwdriver:"] = "\uD83E\uDE9B", + [":scroll:"] = "\uD83D\uDCDC", + [":seal:"] = "\uD83E\uDDAD", + [":seat:"] = "\uD83D\uDCBA", + [":second_place:"] = "\uD83E\uDD48", + [":second_place_medal:"] = "\uD83E\uDD48", + [":secret:"] = "㊙️", + [":see_no_evil:"] = "\uD83D\uDE48", + [":seedling:"] = "\uD83C\uDF31", + [":selfie:"] = "\uD83E\uDD33", + [":selfie::skin-tone-1:"] = "\uD83E\uDD33\uD83C\uDFFB", + [":selfie::skin-tone-2:"] = "\uD83E\uDD33\uD83C\uDFFC", + [":selfie::skin-tone-3:"] = "\uD83E\uDD33\uD83C\uDFFD", + [":selfie::skin-tone-4:"] = "\uD83E\uDD33\uD83C\uDFFE", + [":selfie::skin-tone-5:"] = "\uD83E\uDD33\uD83C\uDFFF", + [":selfie_tone1:"] = "\uD83E\uDD33\uD83C\uDFFB", + [":selfie_tone2:"] = "\uD83E\uDD33\uD83C\uDFFC", + [":selfie_tone3:"] = "\uD83E\uDD33\uD83C\uDFFD", + [":selfie_tone4:"] = "\uD83E\uDD33\uD83C\uDFFE", + [":selfie_tone5:"] = "\uD83E\uDD33\uD83C\uDFFF", + [":service_dog:"] = "\uD83D\uDC15\u200D\uD83E\uDDBA", + [":seven:"] = "7️⃣", + [":sewing_needle:"] = "\uD83E\uDEA1", + [":shaking_hands:"] = "\uD83E\uDD1D", + [":shallow_pan_of_food:"] = "\uD83E\uDD58", + [":shamrock:"] = "☘️", + [":shark:"] = "\uD83E\uDD88", + [":shaved_ice:"] = "\uD83C\uDF67", + [":sheep:"] = "\uD83D\uDC11", + [":shell:"] = "\uD83D\uDC1A", + [":shelled_peanut:"] = "\uD83E\uDD5C", + [":shield:"] = "\uD83D\uDEE1️", + [":shinto_shrine:"] = "⛩️", + [":ship:"] = "\uD83D\uDEA2", + [":shirt:"] = "\uD83D\uDC55", + [":shit:"] = "\uD83D\uDCA9", + [":shopping_bags:"] = "\uD83D\uDECD️", + [":shopping_cart:"] = "\uD83D\uDED2", + [":shopping_trolley:"] = "\uD83D\uDED2", + [":shorts:"] = "\uD83E\uDE73", + [":shower:"] = "\uD83D\uDEBF", + [":shrimp:"] = "\uD83E\uDD90", + [":shrug:"] = "\uD83E\uDD37", + [":shrug::skin-tone-1:"] = "\uD83E\uDD37\uD83C\uDFFB", + [":shrug::skin-tone-2:"] = "\uD83E\uDD37\uD83C\uDFFC", + [":shrug::skin-tone-3:"] = "\uD83E\uDD37\uD83C\uDFFD", + [":shrug::skin-tone-4:"] = "\uD83E\uDD37\uD83C\uDFFE", + [":shrug::skin-tone-5:"] = "\uD83E\uDD37\uD83C\uDFFF", + [":shrug_tone1:"] = "\uD83E\uDD37\uD83C\uDFFB", + [":shrug_tone2:"] = "\uD83E\uDD37\uD83C\uDFFC", + [":shrug_tone3:"] = "\uD83E\uDD37\uD83C\uDFFD", + [":shrug_tone4:"] = "\uD83E\uDD37\uD83C\uDFFE", + [":shrug_tone5:"] = "\uD83E\uDD37\uD83C\uDFFF", + [":shushing_face:"] = "\uD83E\uDD2B", + [":sick:"] = "\uD83E\uDD22", + [":sign_of_the_horns:"] = "\uD83E\uDD18", + [":sign_of_the_horns::skin-tone-1:"] = "\uD83E\uDD18\uD83C\uDFFB", + [":sign_of_the_horns::skin-tone-2:"] = "\uD83E\uDD18\uD83C\uDFFC", + [":sign_of_the_horns::skin-tone-3:"] = "\uD83E\uDD18\uD83C\uDFFD", + [":sign_of_the_horns::skin-tone-4:"] = "\uD83E\uDD18\uD83C\uDFFE", + [":sign_of_the_horns::skin-tone-5:"] = "\uD83E\uDD18\uD83C\uDFFF", + [":sign_of_the_horns_tone1:"] = "\uD83E\uDD18\uD83C\uDFFB", + [":sign_of_the_horns_tone2:"] = "\uD83E\uDD18\uD83C\uDFFC", + [":sign_of_the_horns_tone3:"] = "\uD83E\uDD18\uD83C\uDFFD", + [":sign_of_the_horns_tone4:"] = "\uD83E\uDD18\uD83C\uDFFE", + [":sign_of_the_horns_tone5:"] = "\uD83E\uDD18\uD83C\uDFFF", + [":signal_strength:"] = "\uD83D\uDCF6", + [":six:"] = "6️⃣", + [":six_pointed_star:"] = "\uD83D\uDD2F", + [":skateboard:"] = "\uD83D\uDEF9", + [":skeleton:"] = "\uD83D\uDC80", + [":ski:"] = "\uD83C\uDFBF", + [":skier:"] = "⛷️", + [":skull:"] = "\uD83D\uDC80", + [":skull_and_crossbones:"] = "☠️", + [":skull_crossbones:"] = "☠️", + [":skunk:"] = "\uD83E\uDDA8", + [":sled:"] = "\uD83D\uDEF7", + [":sleeping:"] = "\uD83D\uDE34", + [":sleeping_accommodation:"] = "\uD83D\uDECC", + [":sleeping_accommodation::skin-tone-1:"] = "\uD83D\uDECC\uD83C\uDFFB", + [":sleeping_accommodation::skin-tone-2:"] = "\uD83D\uDECC\uD83C\uDFFC", + [":sleeping_accommodation::skin-tone-3:"] = "\uD83D\uDECC\uD83C\uDFFD", + [":sleeping_accommodation::skin-tone-4:"] = "\uD83D\uDECC\uD83C\uDFFE", + [":sleeping_accommodation::skin-tone-5:"] = "\uD83D\uDECC\uD83C\uDFFF", + [":sleepy:"] = "\uD83D\uDE2A", + [":sleuth_or_spy:"] = "\uD83D\uDD75️", + [":sleuth_or_spy::skin-tone-1:"] = "\uD83D\uDD75\uD83C\uDFFB", + [":sleuth_or_spy::skin-tone-2:"] = "\uD83D\uDD75\uD83C\uDFFC", + [":sleuth_or_spy::skin-tone-3:"] = "\uD83D\uDD75\uD83C\uDFFD", + [":sleuth_or_spy::skin-tone-4:"] = "\uD83D\uDD75\uD83C\uDFFE", + [":sleuth_or_spy::skin-tone-5:"] = "\uD83D\uDD75\uD83C\uDFFF", + [":sleuth_or_spy_tone1:"] = "\uD83D\uDD75\uD83C\uDFFB", + [":sleuth_or_spy_tone2:"] = "\uD83D\uDD75\uD83C\uDFFC", + [":sleuth_or_spy_tone3:"] = "\uD83D\uDD75\uD83C\uDFFD", + [":sleuth_or_spy_tone4:"] = "\uD83D\uDD75\uD83C\uDFFE", + [":sleuth_or_spy_tone5:"] = "\uD83D\uDD75\uD83C\uDFFF", + [":slight_frown:"] = "\uD83D\uDE41", + [":slight_smile:"] = "\uD83D\uDE42", + [":slightly_frowning_face:"] = "\uD83D\uDE41", + [":slightly_smiling_face:"] = "\uD83D\uDE42", + [":slot_machine:"] = "\uD83C\uDFB0", + [":sloth:"] = "\uD83E\uDDA5", + [":small_airplane:"] = "\uD83D\uDEE9️", + [":small_blue_diamond:"] = "\uD83D\uDD39", + [":small_orange_diamond:"] = "\uD83D\uDD38", + [":small_red_triangle:"] = "\uD83D\uDD3A", + [":small_red_triangle_down:"] = "\uD83D\uDD3B", + [":smile:"] = "\uD83D\uDE04", + [":smile_cat:"] = "\uD83D\uDE38", + [":smiley:"] = "\uD83D\uDE03", + [":smiley_cat:"] = "\uD83D\uDE3A", + [":smiling_face_with_3_hearts:"] = "\uD83E\uDD70", + [":smiling_face_with_tear:"] = "\uD83E\uDD72", + [":smiling_face_with_tear:"] = "\uD83E\uDD72", + [":smiling_imp:"] = "\uD83D\uDE08", + [":smirk:"] = "\uD83D\uDE0F", + [":smirk_cat:"] = "\uD83D\uDE3C", + [":smoking:"] = "\uD83D\uDEAC", + [":snail:"] = "\uD83D\uDC0C", + [":snake:"] = "\uD83D\uDC0D", + [":sneeze:"] = "\uD83E\uDD27", + [":sneezing_face:"] = "\uD83E\uDD27", + [":snow_capped_mountain:"] = "\uD83C\uDFD4️", + [":snowboarder:"] = "\uD83C\uDFC2", + [":snowboarder::skin-tone-1:"] = "\uD83C\uDFC2\uD83C\uDFFB", + [":snowboarder::skin-tone-2:"] = "\uD83C\uDFC2\uD83C\uDFFC", + [":snowboarder::skin-tone-3:"] = "\uD83C\uDFC2\uD83C\uDFFD", + [":snowboarder::skin-tone-4:"] = "\uD83C\uDFC2\uD83C\uDFFE", + [":snowboarder::skin-tone-5:"] = "\uD83C\uDFC2\uD83C\uDFFF", + [":snowboarder_dark_skin_tone:"] = "\uD83C\uDFC2\uD83C\uDFFF", + [":snowboarder_light_skin_tone:"] = "\uD83C\uDFC2\uD83C\uDFFB", + [":snowboarder_medium_dark_skin_tone:"] = "\uD83C\uDFC2\uD83C\uDFFE", + [":snowboarder_medium_light_skin_tone:"] = "\uD83C\uDFC2\uD83C\uDFFC", + [":snowboarder_medium_skin_tone:"] = "\uD83C\uDFC2\uD83C\uDFFD", + [":snowboarder_tone1:"] = "\uD83C\uDFC2\uD83C\uDFFB", + [":snowboarder_tone2:"] = "\uD83C\uDFC2\uD83C\uDFFC", + [":snowboarder_tone3:"] = "\uD83C\uDFC2\uD83C\uDFFD", + [":snowboarder_tone4:"] = "\uD83C\uDFC2\uD83C\uDFFE", + [":snowboarder_tone5:"] = "\uD83C\uDFC2\uD83C\uDFFF", + [":snowflake:"] = "❄️", + [":snowman2:"] = "☃️", + [":snowman:"] = "⛄", + [":soap:"] = "\uD83E\uDDFC", + [":sob:"] = "\uD83D\uDE2D", + [":soccer:"] = "⚽", + [":socks:"] = "\uD83E\uDDE6", + [":softball:"] = "\uD83E\uDD4E", + [":soon:"] = "\uD83D\uDD1C", + [":sos:"] = "\uD83C\uDD98", + [":sound:"] = "\uD83D\uDD09", + [":space_invader:"] = "\uD83D\uDC7E", + [":spades:"] = "♠️", + [":spaghetti:"] = "\uD83C\uDF5D", + [":sparkle:"] = "❇️", + [":sparkler:"] = "\uD83C\uDF87", + [":sparkles:"] = "✨", + [":sparkling_heart:"] = "\uD83D\uDC96", + [":speak_no_evil:"] = "\uD83D\uDE4A", + [":speaker:"] = "\uD83D\uDD08", + [":speaking_head:"] = "\uD83D\uDDE3️", + [":speaking_head_in_silhouette:"] = "\uD83D\uDDE3️", + [":speech_balloon:"] = "\uD83D\uDCAC", + [":speech_left:"] = "\uD83D\uDDE8️", + [":speedboat:"] = "\uD83D\uDEA4", + [":spider:"] = "\uD83D\uDD77️", + [":spider_web:"] = "\uD83D\uDD78️", + [":spiral_calendar_pad:"] = "\uD83D\uDDD3️", + [":spiral_note_pad:"] = "\uD83D\uDDD2️", + [":sponge:"] = "\uD83E\uDDFD", + [":spoon:"] = "\uD83E\uDD44", + [":sports_medal:"] = "\uD83C\uDFC5", + [":spy:"] = "\uD83D\uDD75️", + [":spy::skin-tone-1:"] = "\uD83D\uDD75\uD83C\uDFFB", + [":spy::skin-tone-2:"] = "\uD83D\uDD75\uD83C\uDFFC", + [":spy::skin-tone-3:"] = "\uD83D\uDD75\uD83C\uDFFD", + [":spy::skin-tone-4:"] = "\uD83D\uDD75\uD83C\uDFFE", + [":spy::skin-tone-5:"] = "\uD83D\uDD75\uD83C\uDFFF", + [":spy_tone1:"] = "\uD83D\uDD75\uD83C\uDFFB", + [":spy_tone2:"] = "\uD83D\uDD75\uD83C\uDFFC", + [":spy_tone3:"] = "\uD83D\uDD75\uD83C\uDFFD", + [":spy_tone4:"] = "\uD83D\uDD75\uD83C\uDFFE", + [":spy_tone5:"] = "\uD83D\uDD75\uD83C\uDFFF", + [":squeeze_bottle:"] = "\uD83E\uDDF4", + [":squid:"] = "\uD83E\uDD91", + [":stadium:"] = "\uD83C\uDFDF️", + [":star2:"] = "\uD83C\uDF1F", + [":star:"] = "⭐", + [":star_and_crescent:"] = "☪️", + [":star_of_david:"] = "✡️", + [":star_struck:"] = "\uD83E\uDD29", + [":stars:"] = "\uD83C\uDF20", + [":station:"] = "\uD83D\uDE89", + [":statue_of_liberty:"] = "\uD83D\uDDFD", + [":steam_locomotive:"] = "\uD83D\uDE82", + [":stethoscope:"] = "\uD83E\uDE7A", + [":stew:"] = "\uD83C\uDF72", + [":stop_button:"] = "⏹️", + [":stop_sign:"] = "\uD83D\uDED1", + [":stopwatch:"] = "⏱️", + [":straight_ruler:"] = "\uD83D\uDCCF", + [":strawberry:"] = "\uD83C\uDF53", + [":stuck_out_tongue:"] = "\uD83D\uDE1B", + [":stuck_out_tongue_closed_eyes:"] = "\uD83D\uDE1D", + [":stuck_out_tongue_winking_eye:"] = "\uD83D\uDE1C", + [":studio_microphone:"] = "\uD83C\uDF99️", + [":stuffed_flatbread:"] = "\uD83E\uDD59", + [":stuffed_pita:"] = "\uD83E\uDD59", + [":sun_with_face:"] = "\uD83C\uDF1E", + [":sunflower:"] = "\uD83C\uDF3B", + [":sunglasses:"] = "\uD83D\uDE0E", + [":sunny:"] = "☀️", + [":sunrise:"] = "\uD83C\uDF05", + [":sunrise_over_mountains:"] = "\uD83C\uDF04", + [":superhero:"] = "\uD83E\uDDB8", + [":superhero::skin-tone-1:"] = "\uD83E\uDDB8\uD83C\uDFFB", + [":superhero::skin-tone-2:"] = "\uD83E\uDDB8\uD83C\uDFFC", + [":superhero::skin-tone-3:"] = "\uD83E\uDDB8\uD83C\uDFFD", + [":superhero::skin-tone-4:"] = "\uD83E\uDDB8\uD83C\uDFFE", + [":superhero::skin-tone-5:"] = "\uD83E\uDDB8\uD83C\uDFFF", + [":superhero_dark_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFF", + [":superhero_light_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFB", + [":superhero_medium_dark_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFE", + [":superhero_medium_light_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFC", + [":superhero_medium_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFD", + [":superhero_tone1:"] = "\uD83E\uDDB8\uD83C\uDFFB", + [":superhero_tone2:"] = "\uD83E\uDDB8\uD83C\uDFFC", + [":superhero_tone3:"] = "\uD83E\uDDB8\uD83C\uDFFD", + [":superhero_tone4:"] = "\uD83E\uDDB8\uD83C\uDFFE", + [":superhero_tone5:"] = "\uD83E\uDDB8\uD83C\uDFFF", + [":supervillain:"] = "\uD83E\uDDB9", + [":supervillain::skin-tone-1:"] = "\uD83E\uDDB9\uD83C\uDFFB", + [":supervillain::skin-tone-2:"] = "\uD83E\uDDB9\uD83C\uDFFC", + [":supervillain::skin-tone-3:"] = "\uD83E\uDDB9\uD83C\uDFFD", + [":supervillain::skin-tone-4:"] = "\uD83E\uDDB9\uD83C\uDFFE", + [":supervillain::skin-tone-5:"] = "\uD83E\uDDB9\uD83C\uDFFF", + [":supervillain_dark_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFF", + [":supervillain_light_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFB", + [":supervillain_medium_dark_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFE", + [":supervillain_medium_light_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFC", + [":supervillain_medium_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFD", + [":supervillain_tone1:"] = "\uD83E\uDDB9\uD83C\uDFFB", + [":supervillain_tone2:"] = "\uD83E\uDDB9\uD83C\uDFFC", + [":supervillain_tone3:"] = "\uD83E\uDDB9\uD83C\uDFFD", + [":supervillain_tone4:"] = "\uD83E\uDDB9\uD83C\uDFFE", + [":supervillain_tone5:"] = "\uD83E\uDDB9\uD83C\uDFFF", + [":surfer:"] = "\uD83C\uDFC4", + [":surfer::skin-tone-1:"] = "\uD83C\uDFC4\uD83C\uDFFB", + [":surfer::skin-tone-2:"] = "\uD83C\uDFC4\uD83C\uDFFC", + [":surfer::skin-tone-3:"] = "\uD83C\uDFC4\uD83C\uDFFD", + [":surfer::skin-tone-4:"] = "\uD83C\uDFC4\uD83C\uDFFE", + [":surfer::skin-tone-5:"] = "\uD83C\uDFC4\uD83C\uDFFF", + [":surfer_tone1:"] = "\uD83C\uDFC4\uD83C\uDFFB", + [":surfer_tone2:"] = "\uD83C\uDFC4\uD83C\uDFFC", + [":surfer_tone3:"] = "\uD83C\uDFC4\uD83C\uDFFD", + [":surfer_tone4:"] = "\uD83C\uDFC4\uD83C\uDFFE", + [":surfer_tone5:"] = "\uD83C\uDFC4\uD83C\uDFFF", + [":sushi:"] = "\uD83C\uDF63", + [":suspension_railway:"] = "\uD83D\uDE9F", + [":swan:"] = "\uD83E\uDDA2", + [":sweat:"] = "\uD83D\uDE13", + [":sweat_drops:"] = "\uD83D\uDCA6", + [":sweat_smile:"] = "\uD83D\uDE05", + [":sweet_potato:"] = "\uD83C\uDF60", + [":swimmer:"] = "\uD83C\uDFCA", + [":swimmer::skin-tone-1:"] = "\uD83C\uDFCA\uD83C\uDFFB", + [":swimmer::skin-tone-2:"] = "\uD83C\uDFCA\uD83C\uDFFC", + [":swimmer::skin-tone-3:"] = "\uD83C\uDFCA\uD83C\uDFFD", + [":swimmer::skin-tone-4:"] = "\uD83C\uDFCA\uD83C\uDFFE", + [":swimmer::skin-tone-5:"] = "\uD83C\uDFCA\uD83C\uDFFF", + [":swimmer_tone1:"] = "\uD83C\uDFCA\uD83C\uDFFB", + [":swimmer_tone2:"] = "\uD83C\uDFCA\uD83C\uDFFC", + [":swimmer_tone3:"] = "\uD83C\uDFCA\uD83C\uDFFD", + [":swimmer_tone4:"] = "\uD83C\uDFCA\uD83C\uDFFE", + [":swimmer_tone5:"] = "\uD83C\uDFCA\uD83C\uDFFF", + [":symbols:"] = "\uD83D\uDD23", + [":synagogue:"] = "\uD83D\uDD4D", + [":syringe:"] = "\uD83D\uDC89", + [":t_rex:"] = "\uD83E\uDD96", + [":table_tennis:"] = "\uD83C\uDFD3", + [":taco:"] = "\uD83C\uDF2E", + [":tada:"] = "\uD83C\uDF89", + [":takeout_box:"] = "\uD83E\uDD61", + [":tamale:"] = "\uD83E\uDED4", + [":tanabata_tree:"] = "\uD83C\uDF8B", + [":tangerine:"] = "\uD83C\uDF4A", + [":taurus:"] = "♉", + [":taxi:"] = "\uD83D\uDE95", + [":tea:"] = "\uD83C\uDF75", + [":teapot:"] = "\uD83E\uDED6", + [":teddy_bear:"] = "\uD83E\uDDF8", + [":telephone:"] = "☎️", + [":telephone_receiver:"] = "\uD83D\uDCDE", + [":telescope:"] = "\uD83D\uDD2D", + [":tennis:"] = "\uD83C\uDFBE", + [":tent:"] = "⛺", + [":test_tube:"] = "\uD83E\uDDEA", + [":thermometer:"] = "\uD83C\uDF21️", + [":thermometer_face:"] = "\uD83E\uDD12", + [":thinking:"] = "\uD83E\uDD14", + [":thinking_face:"] = "\uD83E\uDD14", + [":third_place:"] = "\uD83E\uDD49", + [":third_place_medal:"] = "\uD83E\uDD49", + [":thong_sandal:"] = "\uD83E\uDE74", + [":thought_balloon:"] = "\uD83D\uDCAD", + [":thread:"] = "\uD83E\uDDF5", + [":three:"] = "3️⃣", + [":three_button_mouse:"] = "\uD83D\uDDB1️", + [":thumbdown:"] = "\uD83D\uDC4E", + [":thumbdown::skin-tone-1:"] = "\uD83D\uDC4E\uD83C\uDFFB", + [":thumbdown::skin-tone-2:"] = "\uD83D\uDC4E\uD83C\uDFFC", + [":thumbdown::skin-tone-3:"] = "\uD83D\uDC4E\uD83C\uDFFD", + [":thumbdown::skin-tone-4:"] = "\uD83D\uDC4E\uD83C\uDFFE", + [":thumbdown::skin-tone-5:"] = "\uD83D\uDC4E\uD83C\uDFFF", + [":thumbdown_tone1:"] = "\uD83D\uDC4E\uD83C\uDFFB", + [":thumbdown_tone2:"] = "\uD83D\uDC4E\uD83C\uDFFC", + [":thumbdown_tone3:"] = "\uD83D\uDC4E\uD83C\uDFFD", + [":thumbdown_tone4:"] = "\uD83D\uDC4E\uD83C\uDFFE", + [":thumbdown_tone5:"] = "\uD83D\uDC4E\uD83C\uDFFF", + [":thumbsdown:"] = "\uD83D\uDC4E", + [":thumbsdown::skin-tone-1:"] = "\uD83D\uDC4E\uD83C\uDFFB", + [":thumbsdown::skin-tone-2:"] = "\uD83D\uDC4E\uD83C\uDFFC", + [":thumbsdown::skin-tone-3:"] = "\uD83D\uDC4E\uD83C\uDFFD", + [":thumbsdown::skin-tone-4:"] = "\uD83D\uDC4E\uD83C\uDFFE", + [":thumbsdown::skin-tone-5:"] = "\uD83D\uDC4E\uD83C\uDFFF", + [":thumbsdown_tone1:"] = "\uD83D\uDC4E\uD83C\uDFFB", + [":thumbsdown_tone2:"] = "\uD83D\uDC4E\uD83C\uDFFC", + [":thumbsdown_tone3:"] = "\uD83D\uDC4E\uD83C\uDFFD", + [":thumbsdown_tone4:"] = "\uD83D\uDC4E\uD83C\uDFFE", + [":thumbsdown_tone5:"] = "\uD83D\uDC4E\uD83C\uDFFF", + [":thumbsup:"] = "\uD83D\uDC4D", + [":thumbsup::skin-tone-1:"] = "\uD83D\uDC4D\uD83C\uDFFB", + [":thumbsup::skin-tone-2:"] = "\uD83D\uDC4D\uD83C\uDFFC", + [":thumbsup::skin-tone-3:"] = "\uD83D\uDC4D\uD83C\uDFFD", + [":thumbsup::skin-tone-4:"] = "\uD83D\uDC4D\uD83C\uDFFE", + [":thumbsup::skin-tone-5:"] = "\uD83D\uDC4D\uD83C\uDFFF", + [":thumbsup_tone1:"] = "\uD83D\uDC4D\uD83C\uDFFB", + [":thumbsup_tone2:"] = "\uD83D\uDC4D\uD83C\uDFFC", + [":thumbsup_tone3:"] = "\uD83D\uDC4D\uD83C\uDFFD", + [":thumbsup_tone4:"] = "\uD83D\uDC4D\uD83C\uDFFE", + [":thumbsup_tone5:"] = "\uD83D\uDC4D\uD83C\uDFFF", + [":thumbup:"] = "\uD83D\uDC4D", + [":thumbup::skin-tone-1:"] = "\uD83D\uDC4D\uD83C\uDFFB", + [":thumbup::skin-tone-2:"] = "\uD83D\uDC4D\uD83C\uDFFC", + [":thumbup::skin-tone-3:"] = "\uD83D\uDC4D\uD83C\uDFFD", + [":thumbup::skin-tone-4:"] = "\uD83D\uDC4D\uD83C\uDFFE", + [":thumbup::skin-tone-5:"] = "\uD83D\uDC4D\uD83C\uDFFF", + [":thumbup_tone1:"] = "\uD83D\uDC4D\uD83C\uDFFB", + [":thumbup_tone2:"] = "\uD83D\uDC4D\uD83C\uDFFC", + [":thumbup_tone3:"] = "\uD83D\uDC4D\uD83C\uDFFD", + [":thumbup_tone4:"] = "\uD83D\uDC4D\uD83C\uDFFE", + [":thumbup_tone5:"] = "\uD83D\uDC4D\uD83C\uDFFF", + [":thunder_cloud_and_rain:"] = "⛈️", + [":thunder_cloud_rain:"] = "⛈️", + [":ticket:"] = "\uD83C\uDFAB", + [":tickets:"] = "\uD83C\uDF9F️", + [":tiger2:"] = "\uD83D\uDC05", + [":tiger:"] = "\uD83D\uDC2F", + [":timer:"] = "⏲️", + [":timer_clock:"] = "⏲️", + [":tired_face:"] = "\uD83D\uDE2B", + [":tm:"] = "™️", + [":toilet:"] = "\uD83D\uDEBD", + [":tokyo_tower:"] = "\uD83D\uDDFC", + [":tomato:"] = "\uD83C\uDF45", + [":tongue:"] = "\uD83D\uDC45", + [":toolbox:"] = "\uD83E\uDDF0", + [":tools:"] = "\uD83D\uDEE0️", + [":tooth:"] = "\uD83E\uDDB7", + [":toothbrush:"] = "\uD83E\uDEA5", + [":top:"] = "\uD83D\uDD1D", + [":tophat:"] = "\uD83C\uDFA9", + [":track_next:"] = "⏭️", + [":track_previous:"] = "⏮️", + [":trackball:"] = "\uD83D\uDDB2️", + [":tractor:"] = "\uD83D\uDE9C", + [":traffic_light:"] = "\uD83D\uDEA5", + [":train2:"] = "\uD83D\uDE86", + [":train:"] = "\uD83D\uDE8B", + [":tram:"] = "\uD83D\uDE8A", + [":transgender_flag:"] = "\uD83C\uDFF3\uFE0F\u200D\u26A7\uFE0F", + [":transgender_symbol:"] = "\u26A7\uFE0F", + [":triangular_flag_on_post:"] = "\uD83D\uDEA9", + [":triangular_ruler:"] = "\uD83D\uDCD0", + [":trident:"] = "\uD83D\uDD31", + [":triumph:"] = "\uD83D\uDE24", + [":troll:"] = "\uD83E\uDDCC", + [":trolleybus:"] = "\uD83D\uDE8E", + [":trophy:"] = "\uD83C\uDFC6", + [":tropical_drink:"] = "\uD83C\uDF79", + [":tropical_fish:"] = "\uD83D\uDC20", + [":truck:"] = "\uD83D\uDE9A", + [":trumpet:"] = "\uD83C\uDFBA", + [":tulip:"] = "\uD83C\uDF37", + [":tumbler_glass:"] = "\uD83E\uDD43", + [":turkey:"] = "\uD83E\uDD83", + [":turtle:"] = "\uD83D\uDC22", + [":tuxedo_tone1:"] = "\uD83E\uDD35\uD83C\uDFFB", + [":tuxedo_tone2:"] = "\uD83E\uDD35\uD83C\uDFFC", + [":tuxedo_tone3:"] = "\uD83E\uDD35\uD83C\uDFFD", + [":tuxedo_tone4:"] = "\uD83E\uDD35\uD83C\uDFFE", + [":tuxedo_tone5:"] = "\uD83E\uDD35\uD83C\uDFFF", + [":tv:"] = "\uD83D\uDCFA", + [":twisted_rightwards_arrows:"] = "\uD83D\uDD00", + [":two:"] = "2️⃣", + [":two_hearts:"] = "\uD83D\uDC95", + [":two_men_holding_hands:"] = "\uD83D\uDC6C", + [":two_women_holding_hands:"] = "\uD83D\uDC6D", + [":u5272:"] = "\uD83C\uDE39", + [":u5408:"] = "\uD83C\uDE34", + [":u55b6:"] = "\uD83C\uDE3A", + [":u6307:"] = "\uD83C\uDE2F", + [":u6708:"] = "\uD83C\uDE37️", + [":u6709:"] = "\uD83C\uDE36", + [":u6e80:"] = "\uD83C\uDE35", + [":u7121:"] = "\uD83C\uDE1A", + [":u7533:"] = "\uD83C\uDE38", + [":u7981:"] = "\uD83C\uDE32", + [":u7a7a:"] = "\uD83C\uDE33", + [":umbrella2:"] = "☂️", + [":umbrella:"] = "☔", + [":umbrella_on_ground:"] = "⛱️", + [":unamused:"] = "\uD83D\uDE12", + [":underage:"] = "\uD83D\uDD1E", + [":unicorn:"] = "\uD83E\uDD84", + [":unicorn_face:"] = "\uD83E\uDD84", + [":united_nations:"] = "\uD83C\uDDFA\uD83C\uDDF3", + [":unlock:"] = "\uD83D\uDD13", + [":up:"] = "\uD83C\uDD99", + [":upside_down:"] = "\uD83D\uDE43", + [":upside_down_face:"] = "\uD83D\uDE43", + [":urn:"] = "⚱️", + [":v:"] = "✌️", + [":v::skin-tone-1:"] = "✌\uD83C\uDFFB", + [":v::skin-tone-2:"] = "✌\uD83C\uDFFC", + [":v::skin-tone-3:"] = "✌\uD83C\uDFFD", + [":v::skin-tone-4:"] = "✌\uD83C\uDFFE", + [":v::skin-tone-5:"] = "✌\uD83C\uDFFF", + [":v_tone1:"] = "✌\uD83C\uDFFB", + [":v_tone2:"] = "✌\uD83C\uDFFC", + [":v_tone3:"] = "✌\uD83C\uDFFD", + [":v_tone4:"] = "✌\uD83C\uDFFE", + [":v_tone5:"] = "✌\uD83C\uDFFF", + [":vampire:"] = "\uD83E\uDDDB", + [":vampire::skin-tone-1:"] = "\uD83E\uDDDB\uD83C\uDFFB", + [":vampire::skin-tone-2:"] = "\uD83E\uDDDB\uD83C\uDFFC", + [":vampire::skin-tone-3:"] = "\uD83E\uDDDB\uD83C\uDFFD", + [":vampire::skin-tone-4:"] = "\uD83E\uDDDB\uD83C\uDFFE", + [":vampire::skin-tone-5:"] = "\uD83E\uDDDB\uD83C\uDFFF", + [":vampire_dark_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFF", + [":vampire_light_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFB", + [":vampire_medium_dark_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFE", + [":vampire_medium_light_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFC", + [":vampire_medium_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFD", + [":vampire_tone1:"] = "\uD83E\uDDDB\uD83C\uDFFB", + [":vampire_tone2:"] = "\uD83E\uDDDB\uD83C\uDFFC", + [":vampire_tone3:"] = "\uD83E\uDDDB\uD83C\uDFFD", + [":vampire_tone4:"] = "\uD83E\uDDDB\uD83C\uDFFE", + [":vampire_tone5:"] = "\uD83E\uDDDB\uD83C\uDFFF", + [":vertical_traffic_light:"] = "\uD83D\uDEA6", + [":vhs:"] = "\uD83D\uDCFC", + [":vibration_mode:"] = "\uD83D\uDCF3", + [":video_camera:"] = "\uD83D\uDCF9", + [":video_game:"] = "\uD83C\uDFAE", + [":violin:"] = "\uD83C\uDFBB", + [":virgo:"] = "♍", + [":volcano:"] = "\uD83C\uDF0B", + [":volleyball:"] = "\uD83C\uDFD0", + [":vs:"] = "\uD83C\uDD9A", + [":vulcan:"] = "\uD83D\uDD96", + [":vulcan::skin-tone-1:"] = "\uD83D\uDD96\uD83C\uDFFB", + [":vulcan::skin-tone-2:"] = "\uD83D\uDD96\uD83C\uDFFC", + [":vulcan::skin-tone-3:"] = "\uD83D\uDD96\uD83C\uDFFD", + [":vulcan::skin-tone-4:"] = "\uD83D\uDD96\uD83C\uDFFE", + [":vulcan::skin-tone-5:"] = "\uD83D\uDD96\uD83C\uDFFF", + [":vulcan_tone1:"] = "\uD83D\uDD96\uD83C\uDFFB", + [":vulcan_tone2:"] = "\uD83D\uDD96\uD83C\uDFFC", + [":vulcan_tone3:"] = "\uD83D\uDD96\uD83C\uDFFD", + [":vulcan_tone4:"] = "\uD83D\uDD96\uD83C\uDFFE", + [":vulcan_tone5:"] = "\uD83D\uDD96\uD83C\uDFFF", + [":waffle:"] = "\uD83E\uDDC7", + [":wales:"] = "\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73\uDB40\uDC7F", + [":walking:"] = "\uD83D\uDEB6", + [":walking::skin-tone-1:"] = "\uD83D\uDEB6\uD83C\uDFFB", + [":walking::skin-tone-2:"] = "\uD83D\uDEB6\uD83C\uDFFC", + [":walking::skin-tone-3:"] = "\uD83D\uDEB6\uD83C\uDFFD", + [":walking::skin-tone-4:"] = "\uD83D\uDEB6\uD83C\uDFFE", + [":walking::skin-tone-5:"] = "\uD83D\uDEB6\uD83C\uDFFF", + [":walking_tone1:"] = "\uD83D\uDEB6\uD83C\uDFFB", + [":walking_tone2:"] = "\uD83D\uDEB6\uD83C\uDFFC", + [":walking_tone3:"] = "\uD83D\uDEB6\uD83C\uDFFD", + [":walking_tone4:"] = "\uD83D\uDEB6\uD83C\uDFFE", + [":walking_tone5:"] = "\uD83D\uDEB6\uD83C\uDFFF", + [":waning_crescent_moon:"] = "\uD83C\uDF18", + [":waning_gibbous_moon:"] = "\uD83C\uDF16", + [":warning:"] = "⚠️", + [":wastebasket:"] = "\uD83D\uDDD1️", + [":watch:"] = "⌚", + [":water_buffalo:"] = "\uD83D\uDC03", + [":water_polo:"] = "\uD83E\uDD3D", + [":water_polo::skin-tone-1:"] = "\uD83E\uDD3D\uD83C\uDFFB", + [":water_polo::skin-tone-2:"] = "\uD83E\uDD3D\uD83C\uDFFC", + [":water_polo::skin-tone-3:"] = "\uD83E\uDD3D\uD83C\uDFFD", + [":water_polo::skin-tone-4:"] = "\uD83E\uDD3D\uD83C\uDFFE", + [":water_polo::skin-tone-5:"] = "\uD83E\uDD3D\uD83C\uDFFF", + [":water_polo_tone1:"] = "\uD83E\uDD3D\uD83C\uDFFB", + [":water_polo_tone2:"] = "\uD83E\uDD3D\uD83C\uDFFC", + [":water_polo_tone3:"] = "\uD83E\uDD3D\uD83C\uDFFD", + [":water_polo_tone4:"] = "\uD83E\uDD3D\uD83C\uDFFE", + [":water_polo_tone5:"] = "\uD83E\uDD3D\uD83C\uDFFF", + [":watermelon:"] = "\uD83C\uDF49", + [":wave:"] = "\uD83D\uDC4B", + [":wave::skin-tone-1:"] = "\uD83D\uDC4B\uD83C\uDFFB", + [":wave::skin-tone-2:"] = "\uD83D\uDC4B\uD83C\uDFFC", + [":wave::skin-tone-3:"] = "\uD83D\uDC4B\uD83C\uDFFD", + [":wave::skin-tone-4:"] = "\uD83D\uDC4B\uD83C\uDFFE", + [":wave::skin-tone-5:"] = "\uD83D\uDC4B\uD83C\uDFFF", + [":wave_tone1:"] = "\uD83D\uDC4B\uD83C\uDFFB", + [":wave_tone2:"] = "\uD83D\uDC4B\uD83C\uDFFC", + [":wave_tone3:"] = "\uD83D\uDC4B\uD83C\uDFFD", + [":wave_tone4:"] = "\uD83D\uDC4B\uD83C\uDFFE", + [":wave_tone5:"] = "\uD83D\uDC4B\uD83C\uDFFF", + [":wavy_dash:"] = "〰️", + [":waxing_crescent_moon:"] = "\uD83C\uDF12", + [":waxing_gibbous_moon:"] = "\uD83C\uDF14", + [":wc:"] = "\uD83D\uDEBE", + [":weary:"] = "\uD83D\uDE29", + [":wedding:"] = "\uD83D\uDC92", + [":weight_lifter:"] = "\uD83C\uDFCB️", + [":weight_lifter::skin-tone-1:"] = "\uD83C\uDFCB\uD83C\uDFFB", + [":weight_lifter::skin-tone-2:"] = "\uD83C\uDFCB\uD83C\uDFFC", + [":weight_lifter::skin-tone-3:"] = "\uD83C\uDFCB\uD83C\uDFFD", + [":weight_lifter::skin-tone-4:"] = "\uD83C\uDFCB\uD83C\uDFFE", + [":weight_lifter::skin-tone-5:"] = "\uD83C\uDFCB\uD83C\uDFFF", + [":weight_lifter_tone1:"] = "\uD83C\uDFCB\uD83C\uDFFB", + [":weight_lifter_tone2:"] = "\uD83C\uDFCB\uD83C\uDFFC", + [":weight_lifter_tone3:"] = "\uD83C\uDFCB\uD83C\uDFFD", + [":weight_lifter_tone4:"] = "\uD83C\uDFCB\uD83C\uDFFE", + [":weight_lifter_tone5:"] = "\uD83C\uDFCB\uD83C\uDFFF", + [":whale2:"] = "\uD83D\uDC0B", + [":whale:"] = "\uD83D\uDC33", + [":wheel:"] = "\uD83D\uDEDE", + [":wheel_of_dharma:"] = "☸️", + [":wheelchair:"] = "♿", + [":whisky:"] = "\uD83E\uDD43", + [":white_check_mark:"] = "✅", + [":white_circle:"] = "⚪", + [":white_flower:"] = "\uD83D\uDCAE", + [":white_frowning_face:"] = "☹️", + [":white_heart:"] = "\uD83E\uDD0D", + [":white_large_square:"] = "⬜", + [":white_medium_small_square:"] = "◽", + [":white_medium_square:"] = "◻️", + [":white_small_square:"] = "▫️", + [":white_square_button:"] = "\uD83D\uDD33", + [":white_sun_behind_cloud:"] = "\uD83C\uDF25️", + [":white_sun_behind_cloud_with_rain:"] = "\uD83C\uDF26️", + [":white_sun_cloud:"] = "\uD83C\uDF25️", + [":white_sun_rain_cloud:"] = "\uD83C\uDF26️", + [":white_sun_small_cloud:"] = "\uD83C\uDF24️", + [":white_sun_with_small_cloud:"] = "\uD83C\uDF24️", + [":wilted_flower:"] = "\uD83E\uDD40", + [":wilted_rose:"] = "\uD83E\uDD40", + [":wind_blowing_face:"] = "\uD83C\uDF2C️", + [":wind_chime:"] = "\uD83C\uDF90", + [":window:"] = "\uD83E\uDE9F", + [":wine_glass:"] = "\uD83C\uDF77", + [":wink:"] = "\uD83D\uDE09", + [":wolf:"] = "\uD83D\uDC3A", + [":woman:"] = "\uD83D\uDC69", + [":woman::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB", + [":woman::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC", + [":woman::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD", + [":woman::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE", + [":woman::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF", + [":woman_artist:"] = "\uD83D\uDC69\u200D\uD83C\uDFA8", + [":woman_artist::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFA8", + [":woman_artist::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFA8", + [":woman_artist::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFA8", + [":woman_artist::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFA8", + [":woman_artist::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFA8", + [":woman_artist_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFA8", + [":woman_artist_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFA8", + [":woman_artist_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFA8", + [":woman_artist_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFA8", + [":woman_artist_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFA8", + [":woman_artist_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFA8", + [":woman_artist_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFA8", + [":woman_artist_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFA8", + [":woman_artist_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFA8", + [":woman_artist_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFA8", + [":woman_astronaut:"] = "\uD83D\uDC69\u200D\uD83D\uDE80", + [":woman_astronaut::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDE80", + [":woman_astronaut::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDE80", + [":woman_astronaut::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDE80", + [":woman_astronaut::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDE80", + [":woman_astronaut::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDE80", + [":woman_astronaut_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDE80", + [":woman_astronaut_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDE80", + [":woman_astronaut_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDE80", + [":woman_astronaut_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDE80", + [":woman_astronaut_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDE80", + [":woman_astronaut_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDE80", + [":woman_astronaut_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDE80", + [":woman_astronaut_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDE80", + [":woman_astronaut_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDE80", + [":woman_astronaut_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDE80", + [":woman_bald:"] = "\uD83D\uDC69\u200D\uD83E\uDDB2", + [":woman_bald::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB2", + [":woman_bald::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB2", + [":woman_bald::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB2", + [":woman_bald::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB2", + [":woman_bald::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB2", + [":woman_bald_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB2", + [":woman_bald_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB2", + [":woman_bald_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB2", + [":woman_bald_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB2", + [":woman_bald_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB2", + [":woman_bald_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB2", + [":woman_bald_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB2", + [":woman_bald_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB2", + [":woman_bald_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB2", + [":woman_bald_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB2", + [":woman_beard:"] = "\uD83E\uDDD4\u200D\u2640\uFE0F", + [":woman_beard::skin-tone-1:"] = "\uD83E\uDDD4\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_beard::skin-tone-2:"] = "\uD83E\uDDD4\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_beard::skin-tone-3:"] = "\uD83E\uDDD4\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_beard::skin-tone-4:"] = "\uD83E\uDDD4\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_beard::skin-tone-5:"] = "\uD83E\uDDD4\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_beard_tone1:"] = "\uD83E\uDDD4\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_beard_tone2:"] = "\uD83E\uDDD4\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_beard_tone3:"] = "\uD83E\uDDD4\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_beard_tone4:"] = "\uD83E\uDDD4\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_beard_tone5:"] = "\uD83E\uDDD4\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_biking:"] = "\uD83D\uDEB4\u200D♀️", + [":woman_biking::skin-tone-1:"] = "\uD83D\uDEB4\uD83C\uDFFB\u200D♀️", + [":woman_biking::skin-tone-2:"] = "\uD83D\uDEB4\uD83C\uDFFC\u200D♀️", + [":woman_biking::skin-tone-3:"] = "\uD83D\uDEB4\uD83C\uDFFD\u200D♀️", + [":woman_biking::skin-tone-4:"] = "\uD83D\uDEB4\uD83C\uDFFE\u200D♀️", + [":woman_biking::skin-tone-5:"] = "\uD83D\uDEB4\uD83C\uDFFF\u200D♀️", + [":woman_biking_dark_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFF\u200D♀️", + [":woman_biking_light_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFB\u200D♀️", + [":woman_biking_medium_dark_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFE\u200D♀️", + [":woman_biking_medium_light_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFC\u200D♀️", + [":woman_biking_medium_skin_tone:"] = "\uD83D\uDEB4\uD83C\uDFFD\u200D♀️", + [":woman_biking_tone1:"] = "\uD83D\uDEB4\uD83C\uDFFB\u200D♀️", + [":woman_biking_tone2:"] = "\uD83D\uDEB4\uD83C\uDFFC\u200D♀️", + [":woman_biking_tone3:"] = "\uD83D\uDEB4\uD83C\uDFFD\u200D♀️", + [":woman_biking_tone4:"] = "\uD83D\uDEB4\uD83C\uDFFE\u200D♀️", + [":woman_biking_tone5:"] = "\uD83D\uDEB4\uD83C\uDFFF\u200D♀️", + [":woman_bouncing_ball:"] = "⛹️\u200D♀️", + [":woman_bouncing_ball::skin-tone-1:"] = "⛹\uD83C\uDFFB\u200D♀️", + [":woman_bouncing_ball::skin-tone-2:"] = "⛹\uD83C\uDFFC\u200D♀️", + [":woman_bouncing_ball::skin-tone-3:"] = "⛹\uD83C\uDFFD\u200D♀️", + [":woman_bouncing_ball::skin-tone-4:"] = "⛹\uD83C\uDFFE\u200D♀️", + [":woman_bouncing_ball::skin-tone-5:"] = "⛹\uD83C\uDFFF\u200D♀️", + [":woman_bouncing_ball_dark_skin_tone:"] = "⛹\uD83C\uDFFF\u200D♀️", + [":woman_bouncing_ball_light_skin_tone:"] = "⛹\uD83C\uDFFB\u200D♀️", + [":woman_bouncing_ball_medium_dark_skin_tone:"] = "⛹\uD83C\uDFFE\u200D♀️", + [":woman_bouncing_ball_medium_light_skin_tone:"] = "⛹\uD83C\uDFFC\u200D♀️", + [":woman_bouncing_ball_medium_skin_tone:"] = "⛹\uD83C\uDFFD\u200D♀️", + [":woman_bouncing_ball_tone1:"] = "⛹\uD83C\uDFFB\u200D♀️", + [":woman_bouncing_ball_tone2:"] = "⛹\uD83C\uDFFC\u200D♀️", + [":woman_bouncing_ball_tone3:"] = "⛹\uD83C\uDFFD\u200D♀️", + [":woman_bouncing_ball_tone4:"] = "⛹\uD83C\uDFFE\u200D♀️", + [":woman_bouncing_ball_tone5:"] = "⛹\uD83C\uDFFF\u200D♀️", + [":woman_bowing:"] = "\uD83D\uDE47\u200D♀️", + [":woman_bowing::skin-tone-1:"] = "\uD83D\uDE47\uD83C\uDFFB\u200D♀️", + [":woman_bowing::skin-tone-2:"] = "\uD83D\uDE47\uD83C\uDFFC\u200D♀️", + [":woman_bowing::skin-tone-3:"] = "\uD83D\uDE47\uD83C\uDFFD\u200D♀️", + [":woman_bowing::skin-tone-4:"] = "\uD83D\uDE47\uD83C\uDFFE\u200D♀️", + [":woman_bowing::skin-tone-5:"] = "\uD83D\uDE47\uD83C\uDFFF\u200D♀️", + [":woman_bowing_dark_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFF\u200D♀️", + [":woman_bowing_light_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFB\u200D♀️", + [":woman_bowing_medium_dark_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFE\u200D♀️", + [":woman_bowing_medium_light_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFC\u200D♀️", + [":woman_bowing_medium_skin_tone:"] = "\uD83D\uDE47\uD83C\uDFFD\u200D♀️", + [":woman_bowing_tone1:"] = "\uD83D\uDE47\uD83C\uDFFB\u200D♀️", + [":woman_bowing_tone2:"] = "\uD83D\uDE47\uD83C\uDFFC\u200D♀️", + [":woman_bowing_tone3:"] = "\uD83D\uDE47\uD83C\uDFFD\u200D♀️", + [":woman_bowing_tone4:"] = "\uD83D\uDE47\uD83C\uDFFE\u200D♀️", + [":woman_bowing_tone5:"] = "\uD83D\uDE47\uD83C\uDFFF\u200D♀️", + [":woman_cartwheeling:"] = "\uD83E\uDD38\u200D♀️", + [":woman_cartwheeling::skin-tone-1:"] = "\uD83E\uDD38\uD83C\uDFFB\u200D♀️", + [":woman_cartwheeling::skin-tone-2:"] = "\uD83E\uDD38\uD83C\uDFFC\u200D♀️", + [":woman_cartwheeling::skin-tone-3:"] = "\uD83E\uDD38\uD83C\uDFFD\u200D♀️", + [":woman_cartwheeling::skin-tone-4:"] = "\uD83E\uDD38\uD83C\uDFFE\u200D♀️", + [":woman_cartwheeling::skin-tone-5:"] = "\uD83E\uDD38\uD83C\uDFFF\u200D♀️", + [":woman_cartwheeling_dark_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFF\u200D♀️", + [":woman_cartwheeling_light_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFB\u200D♀️", + [":woman_cartwheeling_medium_dark_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFE\u200D♀️", + [":woman_cartwheeling_medium_light_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFC\u200D♀️", + [":woman_cartwheeling_medium_skin_tone:"] = "\uD83E\uDD38\uD83C\uDFFD\u200D♀️", + [":woman_cartwheeling_tone1:"] = "\uD83E\uDD38\uD83C\uDFFB\u200D♀️", + [":woman_cartwheeling_tone2:"] = "\uD83E\uDD38\uD83C\uDFFC\u200D♀️", + [":woman_cartwheeling_tone3:"] = "\uD83E\uDD38\uD83C\uDFFD\u200D♀️", + [":woman_cartwheeling_tone4:"] = "\uD83E\uDD38\uD83C\uDFFE\u200D♀️", + [":woman_cartwheeling_tone5:"] = "\uD83E\uDD38\uD83C\uDFFF\u200D♀️", + [":woman_climbing:"] = "\uD83E\uDDD7\u200D♀️", + [":woman_climbing::skin-tone-1:"] = "\uD83E\uDDD7\uD83C\uDFFB\u200D♀️", + [":woman_climbing::skin-tone-2:"] = "\uD83E\uDDD7\uD83C\uDFFC\u200D♀️", + [":woman_climbing::skin-tone-3:"] = "\uD83E\uDDD7\uD83C\uDFFD\u200D♀️", + [":woman_climbing::skin-tone-4:"] = "\uD83E\uDDD7\uD83C\uDFFE\u200D♀️", + [":woman_climbing::skin-tone-5:"] = "\uD83E\uDDD7\uD83C\uDFFF\u200D♀️", + [":woman_climbing_dark_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFF\u200D♀️", + [":woman_climbing_light_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFB\u200D♀️", + [":woman_climbing_medium_dark_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFE\u200D♀️", + [":woman_climbing_medium_light_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFC\u200D♀️", + [":woman_climbing_medium_skin_tone:"] = "\uD83E\uDDD7\uD83C\uDFFD\u200D♀️", + [":woman_climbing_tone1:"] = "\uD83E\uDDD7\uD83C\uDFFB\u200D♀️", + [":woman_climbing_tone2:"] = "\uD83E\uDDD7\uD83C\uDFFC\u200D♀️", + [":woman_climbing_tone3:"] = "\uD83E\uDDD7\uD83C\uDFFD\u200D♀️", + [":woman_climbing_tone4:"] = "\uD83E\uDDD7\uD83C\uDFFE\u200D♀️", + [":woman_climbing_tone5:"] = "\uD83E\uDDD7\uD83C\uDFFF\u200D♀️", + [":woman_construction_worker:"] = "\uD83D\uDC77\u200D♀️", + [":woman_construction_worker::skin-tone-1:"] = "\uD83D\uDC77\uD83C\uDFFB\u200D♀️", + [":woman_construction_worker::skin-tone-2:"] = "\uD83D\uDC77\uD83C\uDFFC\u200D♀️", + [":woman_construction_worker::skin-tone-3:"] = "\uD83D\uDC77\uD83C\uDFFD\u200D♀️", + [":woman_construction_worker::skin-tone-4:"] = "\uD83D\uDC77\uD83C\uDFFE\u200D♀️", + [":woman_construction_worker::skin-tone-5:"] = "\uD83D\uDC77\uD83C\uDFFF\u200D♀️", + [":woman_construction_worker_dark_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFF\u200D♀️", + [":woman_construction_worker_light_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFB\u200D♀️", + [":woman_construction_worker_medium_dark_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFE\u200D♀️", + [":woman_construction_worker_medium_light_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFC\u200D♀️", + [":woman_construction_worker_medium_skin_tone:"] = "\uD83D\uDC77\uD83C\uDFFD\u200D♀️", + [":woman_construction_worker_tone1:"] = "\uD83D\uDC77\uD83C\uDFFB\u200D♀️", + [":woman_construction_worker_tone2:"] = "\uD83D\uDC77\uD83C\uDFFC\u200D♀️", + [":woman_construction_worker_tone3:"] = "\uD83D\uDC77\uD83C\uDFFD\u200D♀️", + [":woman_construction_worker_tone4:"] = "\uD83D\uDC77\uD83C\uDFFE\u200D♀️", + [":woman_construction_worker_tone5:"] = "\uD83D\uDC77\uD83C\uDFFF\u200D♀️", + [":woman_cook:"] = "\uD83D\uDC69\u200D\uD83C\uDF73", + [":woman_cook::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF73", + [":woman_cook::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF73", + [":woman_cook::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF73", + [":woman_cook::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF73", + [":woman_cook::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF73", + [":woman_cook_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF73", + [":woman_cook_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF73", + [":woman_cook_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF73", + [":woman_cook_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF73", + [":woman_cook_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF73", + [":woman_cook_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF73", + [":woman_cook_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF73", + [":woman_cook_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF73", + [":woman_cook_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF73", + [":woman_cook_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF73", + [":woman_curly_haired:"] = "\uD83D\uDC69\u200D\uD83E\uDDB1", + [":woman_curly_haired::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB1", + [":woman_curly_haired::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB1", + [":woman_curly_haired::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB1", + [":woman_curly_haired::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB1", + [":woman_curly_haired::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB1", + [":woman_curly_haired_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB1", + [":woman_curly_haired_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB1", + [":woman_curly_haired_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB1", + [":woman_curly_haired_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB1", + [":woman_curly_haired_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB1", + [":woman_curly_haired_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB1", + [":woman_curly_haired_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB1", + [":woman_curly_haired_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB1", + [":woman_curly_haired_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB1", + [":woman_curly_haired_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB1", + [":woman_dark_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_detective:"] = "\uD83D\uDD75️\u200D♀️", + [":woman_detective::skin-tone-1:"] = "\uD83D\uDD75\uD83C\uDFFB\u200D♀️", + [":woman_detective::skin-tone-2:"] = "\uD83D\uDD75\uD83C\uDFFC\u200D♀️", + [":woman_detective::skin-tone-3:"] = "\uD83D\uDD75\uD83C\uDFFD\u200D♀️", + [":woman_detective::skin-tone-4:"] = "\uD83D\uDD75\uD83C\uDFFE\u200D♀️", + [":woman_detective::skin-tone-5:"] = "\uD83D\uDD75\uD83C\uDFFF\u200D♀️", + [":woman_detective_dark_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFF\u200D♀️", + [":woman_detective_light_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFB\u200D♀️", + [":woman_detective_medium_dark_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFE\u200D♀️", + [":woman_detective_medium_light_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFC\u200D♀️", + [":woman_detective_medium_skin_tone:"] = "\uD83D\uDD75\uD83C\uDFFD\u200D♀️", + [":woman_detective_tone1:"] = "\uD83D\uDD75\uD83C\uDFFB\u200D♀️", + [":woman_detective_tone2:"] = "\uD83D\uDD75\uD83C\uDFFC\u200D♀️", + [":woman_detective_tone3:"] = "\uD83D\uDD75\uD83C\uDFFD\u200D♀️", + [":woman_detective_tone4:"] = "\uD83D\uDD75\uD83C\uDFFE\u200D♀️", + [":woman_detective_tone5:"] = "\uD83D\uDD75\uD83C\uDFFF\u200D♀️", + [":woman_elf:"] = "\uD83E\uDDDD\u200D♀️", + [":woman_elf::skin-tone-1:"] = "\uD83E\uDDDD\uD83C\uDFFB\u200D♀️", + [":woman_elf::skin-tone-2:"] = "\uD83E\uDDDD\uD83C\uDFFC\u200D♀️", + [":woman_elf::skin-tone-3:"] = "\uD83E\uDDDD\uD83C\uDFFD\u200D♀️", + [":woman_elf::skin-tone-4:"] = "\uD83E\uDDDD\uD83C\uDFFE\u200D♀️", + [":woman_elf::skin-tone-5:"] = "\uD83E\uDDDD\uD83C\uDFFF\u200D♀️", + [":woman_elf_dark_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFF\u200D♀️", + [":woman_elf_light_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFB\u200D♀️", + [":woman_elf_medium_dark_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFE\u200D♀️", + [":woman_elf_medium_light_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFC\u200D♀️", + [":woman_elf_medium_skin_tone:"] = "\uD83E\uDDDD\uD83C\uDFFD\u200D♀️", + [":woman_elf_tone1:"] = "\uD83E\uDDDD\uD83C\uDFFB\u200D♀️", + [":woman_elf_tone2:"] = "\uD83E\uDDDD\uD83C\uDFFC\u200D♀️", + [":woman_elf_tone3:"] = "\uD83E\uDDDD\uD83C\uDFFD\u200D♀️", + [":woman_elf_tone4:"] = "\uD83E\uDDDD\uD83C\uDFFE\u200D♀️", + [":woman_elf_tone5:"] = "\uD83E\uDDDD\uD83C\uDFFF\u200D♀️", + [":woman_facepalming:"] = "\uD83E\uDD26\u200D♀️", + [":woman_facepalming::skin-tone-1:"] = "\uD83E\uDD26\uD83C\uDFFB\u200D♀️", + [":woman_facepalming::skin-tone-2:"] = "\uD83E\uDD26\uD83C\uDFFC\u200D♀️", + [":woman_facepalming::skin-tone-3:"] = "\uD83E\uDD26\uD83C\uDFFD\u200D♀️", + [":woman_facepalming::skin-tone-4:"] = "\uD83E\uDD26\uD83C\uDFFE\u200D♀️", + [":woman_facepalming::skin-tone-5:"] = "\uD83E\uDD26\uD83C\uDFFF\u200D♀️", + [":woman_facepalming_dark_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFF\u200D♀️", + [":woman_facepalming_light_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFB\u200D♀️", + [":woman_facepalming_medium_dark_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFE\u200D♀️", + [":woman_facepalming_medium_light_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFC\u200D♀️", + [":woman_facepalming_medium_skin_tone:"] = "\uD83E\uDD26\uD83C\uDFFD\u200D♀️", + [":woman_facepalming_tone1:"] = "\uD83E\uDD26\uD83C\uDFFB\u200D♀️", + [":woman_facepalming_tone2:"] = "\uD83E\uDD26\uD83C\uDFFC\u200D♀️", + [":woman_facepalming_tone3:"] = "\uD83E\uDD26\uD83C\uDFFD\u200D♀️", + [":woman_facepalming_tone4:"] = "\uD83E\uDD26\uD83C\uDFFE\u200D♀️", + [":woman_facepalming_tone5:"] = "\uD83E\uDD26\uD83C\uDFFF\u200D♀️", + [":woman_factory_worker:"] = "\uD83D\uDC69\u200D\uD83C\uDFED", + [":woman_factory_worker::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFED", + [":woman_factory_worker::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFED", + [":woman_factory_worker::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFED", + [":woman_factory_worker::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFED", + [":woman_factory_worker::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFED", + [":woman_factory_worker_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFED", + [":woman_factory_worker_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFED", + [":woman_factory_worker_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFED", + [":woman_factory_worker_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFED", + [":woman_factory_worker_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFED", + [":woman_factory_worker_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFED", + [":woman_factory_worker_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFED", + [":woman_factory_worker_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFED", + [":woman_factory_worker_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFED", + [":woman_factory_worker_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFED", + [":woman_fairy:"] = "\uD83E\uDDDA\u200D♀️", + [":woman_fairy::skin-tone-1:"] = "\uD83E\uDDDA\uD83C\uDFFB\u200D♀️", + [":woman_fairy::skin-tone-2:"] = "\uD83E\uDDDA\uD83C\uDFFC\u200D♀️", + [":woman_fairy::skin-tone-3:"] = "\uD83E\uDDDA\uD83C\uDFFD\u200D♀️", + [":woman_fairy::skin-tone-4:"] = "\uD83E\uDDDA\uD83C\uDFFE\u200D♀️", + [":woman_fairy::skin-tone-5:"] = "\uD83E\uDDDA\uD83C\uDFFF\u200D♀️", + [":woman_fairy_dark_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFF\u200D♀️", + [":woman_fairy_light_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFB\u200D♀️", + [":woman_fairy_medium_dark_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFE\u200D♀️", + [":woman_fairy_medium_light_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFC\u200D♀️", + [":woman_fairy_medium_skin_tone:"] = "\uD83E\uDDDA\uD83C\uDFFD\u200D♀️", + [":woman_fairy_tone1:"] = "\uD83E\uDDDA\uD83C\uDFFB\u200D♀️", + [":woman_fairy_tone2:"] = "\uD83E\uDDDA\uD83C\uDFFC\u200D♀️", + [":woman_fairy_tone3:"] = "\uD83E\uDDDA\uD83C\uDFFD\u200D♀️", + [":woman_fairy_tone4:"] = "\uD83E\uDDDA\uD83C\uDFFE\u200D♀️", + [":woman_fairy_tone5:"] = "\uD83E\uDDDA\uD83C\uDFFF\u200D♀️", + [":woman_farmer:"] = "\uD83D\uDC69\u200D\uD83C\uDF3E", + [":woman_farmer::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF3E", + [":woman_farmer::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF3E", + [":woman_farmer::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF3E", + [":woman_farmer::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF3E", + [":woman_farmer::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF3E", + [":woman_farmer_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF3E", + [":woman_farmer_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF3E", + [":woman_farmer_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF3E", + [":woman_farmer_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF3E", + [":woman_farmer_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF3E", + [":woman_farmer_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF3E", + [":woman_farmer_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF3E", + [":woman_farmer_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF3E", + [":woman_farmer_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF3E", + [":woman_farmer_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF3E", + [":woman_feeding_baby:"] = "\uD83D\uDC69\u200D\uD83C\uDF7C", + [":woman_feeding_baby::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":woman_feeding_baby::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":woman_feeding_baby::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":woman_feeding_baby::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":woman_feeding_baby::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":woman_feeding_baby_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":woman_feeding_baby_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":woman_feeding_baby_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":woman_feeding_baby_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":woman_feeding_baby_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":woman_feeding_baby_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF7C", + [":woman_feeding_baby_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF7C", + [":woman_feeding_baby_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF7C", + [":woman_feeding_baby_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF7C", + [":woman_feeding_baby_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF7C", + [":woman_firefighter:"] = "\uD83D\uDC69\u200D\uD83D\uDE92", + [":woman_firefighter::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDE92", + [":woman_firefighter::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDE92", + [":woman_firefighter::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDE92", + [":woman_firefighter::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDE92", + [":woman_firefighter::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDE92", + [":woman_firefighter_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDE92", + [":woman_firefighter_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDE92", + [":woman_firefighter_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDE92", + [":woman_firefighter_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDE92", + [":woman_firefighter_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDE92", + [":woman_firefighter_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDE92", + [":woman_firefighter_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDE92", + [":woman_firefighter_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDE92", + [":woman_firefighter_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDE92", + [":woman_firefighter_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDE92", + [":woman_frowning:"] = "\uD83D\uDE4D\u200D♀️", + [":woman_frowning::skin-tone-1:"] = "\uD83D\uDE4D\uD83C\uDFFB\u200D♀️", + [":woman_frowning::skin-tone-2:"] = "\uD83D\uDE4D\uD83C\uDFFC\u200D♀️", + [":woman_frowning::skin-tone-3:"] = "\uD83D\uDE4D\uD83C\uDFFD\u200D♀️", + [":woman_frowning::skin-tone-4:"] = "\uD83D\uDE4D\uD83C\uDFFE\u200D♀️", + [":woman_frowning::skin-tone-5:"] = "\uD83D\uDE4D\uD83C\uDFFF\u200D♀️", + [":woman_frowning_dark_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFF\u200D♀️", + [":woman_frowning_light_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFB\u200D♀️", + [":woman_frowning_medium_dark_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFE\u200D♀️", + [":woman_frowning_medium_light_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFC\u200D♀️", + [":woman_frowning_medium_skin_tone:"] = "\uD83D\uDE4D\uD83C\uDFFD\u200D♀️", + [":woman_frowning_tone1:"] = "\uD83D\uDE4D\uD83C\uDFFB\u200D♀️", + [":woman_frowning_tone2:"] = "\uD83D\uDE4D\uD83C\uDFFC\u200D♀️", + [":woman_frowning_tone3:"] = "\uD83D\uDE4D\uD83C\uDFFD\u200D♀️", + [":woman_frowning_tone4:"] = "\uD83D\uDE4D\uD83C\uDFFE\u200D♀️", + [":woman_frowning_tone5:"] = "\uD83D\uDE4D\uD83C\uDFFF\u200D♀️", + [":woman_genie:"] = "\uD83E\uDDDE\u200D♀️", + [":woman_gesturing_no:"] = "\uD83D\uDE45\u200D♀️", + [":woman_gesturing_no::skin-tone-1:"] = "\uD83D\uDE45\uD83C\uDFFB\u200D♀️", + [":woman_gesturing_no::skin-tone-2:"] = "\uD83D\uDE45\uD83C\uDFFC\u200D♀️", + [":woman_gesturing_no::skin-tone-3:"] = "\uD83D\uDE45\uD83C\uDFFD\u200D♀️", + [":woman_gesturing_no::skin-tone-4:"] = "\uD83D\uDE45\uD83C\uDFFE\u200D♀️", + [":woman_gesturing_no::skin-tone-5:"] = "\uD83D\uDE45\uD83C\uDFFF\u200D♀️", + [":woman_gesturing_no_dark_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFF\u200D♀️", + [":woman_gesturing_no_light_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFB\u200D♀️", + [":woman_gesturing_no_medium_dark_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFE\u200D♀️", + [":woman_gesturing_no_medium_light_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFC\u200D♀️", + [":woman_gesturing_no_medium_skin_tone:"] = "\uD83D\uDE45\uD83C\uDFFD\u200D♀️", + [":woman_gesturing_no_tone1:"] = "\uD83D\uDE45\uD83C\uDFFB\u200D♀️", + [":woman_gesturing_no_tone2:"] = "\uD83D\uDE45\uD83C\uDFFC\u200D♀️", + [":woman_gesturing_no_tone3:"] = "\uD83D\uDE45\uD83C\uDFFD\u200D♀️", + [":woman_gesturing_no_tone4:"] = "\uD83D\uDE45\uD83C\uDFFE\u200D♀️", + [":woman_gesturing_no_tone5:"] = "\uD83D\uDE45\uD83C\uDFFF\u200D♀️", + [":woman_gesturing_ok:"] = "\uD83D\uDE46\u200D♀️", + [":woman_gesturing_ok::skin-tone-1:"] = "\uD83D\uDE46\uD83C\uDFFB\u200D♀️", + [":woman_gesturing_ok::skin-tone-2:"] = "\uD83D\uDE46\uD83C\uDFFC\u200D♀️", + [":woman_gesturing_ok::skin-tone-3:"] = "\uD83D\uDE46\uD83C\uDFFD\u200D♀️", + [":woman_gesturing_ok::skin-tone-4:"] = "\uD83D\uDE46\uD83C\uDFFE\u200D♀️", + [":woman_gesturing_ok::skin-tone-5:"] = "\uD83D\uDE46\uD83C\uDFFF\u200D♀️", + [":woman_gesturing_ok_dark_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFF\u200D♀️", + [":woman_gesturing_ok_light_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFB\u200D♀️", + [":woman_gesturing_ok_medium_dark_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFE\u200D♀️", + [":woman_gesturing_ok_medium_light_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFC\u200D♀️", + [":woman_gesturing_ok_medium_skin_tone:"] = "\uD83D\uDE46\uD83C\uDFFD\u200D♀️", + [":woman_gesturing_ok_tone1:"] = "\uD83D\uDE46\uD83C\uDFFB\u200D♀️", + [":woman_gesturing_ok_tone2:"] = "\uD83D\uDE46\uD83C\uDFFC\u200D♀️", + [":woman_gesturing_ok_tone3:"] = "\uD83D\uDE46\uD83C\uDFFD\u200D♀️", + [":woman_gesturing_ok_tone4:"] = "\uD83D\uDE46\uD83C\uDFFE\u200D♀️", + [":woman_gesturing_ok_tone5:"] = "\uD83D\uDE46\uD83C\uDFFF\u200D♀️", + [":woman_getting_face_massage:"] = "\uD83D\uDC86\u200D♀️", + [":woman_getting_face_massage::skin-tone-1:"] = "\uD83D\uDC86\uD83C\uDFFB\u200D♀️", + [":woman_getting_face_massage::skin-tone-2:"] = "\uD83D\uDC86\uD83C\uDFFC\u200D♀️", + [":woman_getting_face_massage::skin-tone-3:"] = "\uD83D\uDC86\uD83C\uDFFD\u200D♀️", + [":woman_getting_face_massage::skin-tone-4:"] = "\uD83D\uDC86\uD83C\uDFFE\u200D♀️", + [":woman_getting_face_massage::skin-tone-5:"] = "\uD83D\uDC86\uD83C\uDFFF\u200D♀️", + [":woman_getting_face_massage_dark_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFF\u200D♀️", + [":woman_getting_face_massage_light_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFB\u200D♀️", + [":woman_getting_face_massage_medium_dark_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFE\u200D♀️", + [":woman_getting_face_massage_medium_light_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFC\u200D♀️", + [":woman_getting_face_massage_medium_skin_tone:"] = "\uD83D\uDC86\uD83C\uDFFD\u200D♀️", + [":woman_getting_face_massage_tone1:"] = "\uD83D\uDC86\uD83C\uDFFB\u200D♀️", + [":woman_getting_face_massage_tone2:"] = "\uD83D\uDC86\uD83C\uDFFC\u200D♀️", + [":woman_getting_face_massage_tone3:"] = "\uD83D\uDC86\uD83C\uDFFD\u200D♀️", + [":woman_getting_face_massage_tone4:"] = "\uD83D\uDC86\uD83C\uDFFE\u200D♀️", + [":woman_getting_face_massage_tone5:"] = "\uD83D\uDC86\uD83C\uDFFF\u200D♀️", + [":woman_getting_haircut:"] = "\uD83D\uDC87\u200D♀️", + [":woman_getting_haircut::skin-tone-1:"] = "\uD83D\uDC87\uD83C\uDFFB\u200D♀️", + [":woman_getting_haircut::skin-tone-2:"] = "\uD83D\uDC87\uD83C\uDFFC\u200D♀️", + [":woman_getting_haircut::skin-tone-3:"] = "\uD83D\uDC87\uD83C\uDFFD\u200D♀️", + [":woman_getting_haircut::skin-tone-4:"] = "\uD83D\uDC87\uD83C\uDFFE\u200D♀️", + [":woman_getting_haircut::skin-tone-5:"] = "\uD83D\uDC87\uD83C\uDFFF\u200D♀️", + [":woman_getting_haircut_dark_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFF\u200D♀️", + [":woman_getting_haircut_light_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFB\u200D♀️", + [":woman_getting_haircut_medium_dark_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFE\u200D♀️", + [":woman_getting_haircut_medium_light_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFC\u200D♀️", + [":woman_getting_haircut_medium_skin_tone:"] = "\uD83D\uDC87\uD83C\uDFFD\u200D♀️", + [":woman_getting_haircut_tone1:"] = "\uD83D\uDC87\uD83C\uDFFB\u200D♀️", + [":woman_getting_haircut_tone2:"] = "\uD83D\uDC87\uD83C\uDFFC\u200D♀️", + [":woman_getting_haircut_tone3:"] = "\uD83D\uDC87\uD83C\uDFFD\u200D♀️", + [":woman_getting_haircut_tone4:"] = "\uD83D\uDC87\uD83C\uDFFE\u200D♀️", + [":woman_getting_haircut_tone5:"] = "\uD83D\uDC87\uD83C\uDFFF\u200D♀️", + [":woman_golfing:"] = "\uD83C\uDFCC️\u200D♀️", + [":woman_golfing::skin-tone-1:"] = "\uD83C\uDFCC\uD83C\uDFFB\u200D♀️", + [":woman_golfing::skin-tone-2:"] = "\uD83C\uDFCC\uD83C\uDFFC\u200D♀️", + [":woman_golfing::skin-tone-3:"] = "\uD83C\uDFCC\uD83C\uDFFD\u200D♀️", + [":woman_golfing::skin-tone-4:"] = "\uD83C\uDFCC\uD83C\uDFFE\u200D♀️", + [":woman_golfing::skin-tone-5:"] = "\uD83C\uDFCC\uD83C\uDFFF\u200D♀️", + [":woman_golfing_dark_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFF\u200D♀️", + [":woman_golfing_light_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFB\u200D♀️", + [":woman_golfing_medium_dark_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFE\u200D♀️", + [":woman_golfing_medium_light_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFC\u200D♀️", + [":woman_golfing_medium_skin_tone:"] = "\uD83C\uDFCC\uD83C\uDFFD\u200D♀️", + [":woman_golfing_tone1:"] = "\uD83C\uDFCC\uD83C\uDFFB\u200D♀️", + [":woman_golfing_tone2:"] = "\uD83C\uDFCC\uD83C\uDFFC\u200D♀️", + [":woman_golfing_tone3:"] = "\uD83C\uDFCC\uD83C\uDFFD\u200D♀️", + [":woman_golfing_tone4:"] = "\uD83C\uDFCC\uD83C\uDFFE\u200D♀️", + [":woman_golfing_tone5:"] = "\uD83C\uDFCC\uD83C\uDFFF\u200D♀️", + [":woman_guard:"] = "\uD83D\uDC82\u200D♀️", + [":woman_guard::skin-tone-1:"] = "\uD83D\uDC82\uD83C\uDFFB\u200D♀️", + [":woman_guard::skin-tone-2:"] = "\uD83D\uDC82\uD83C\uDFFC\u200D♀️", + [":woman_guard::skin-tone-3:"] = "\uD83D\uDC82\uD83C\uDFFD\u200D♀️", + [":woman_guard::skin-tone-4:"] = "\uD83D\uDC82\uD83C\uDFFE\u200D♀️", + [":woman_guard::skin-tone-5:"] = "\uD83D\uDC82\uD83C\uDFFF\u200D♀️", + [":woman_guard_dark_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFF\u200D♀️", + [":woman_guard_light_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFB\u200D♀️", + [":woman_guard_medium_dark_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFE\u200D♀️", + [":woman_guard_medium_light_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFC\u200D♀️", + [":woman_guard_medium_skin_tone:"] = "\uD83D\uDC82\uD83C\uDFFD\u200D♀️", + [":woman_guard_tone1:"] = "\uD83D\uDC82\uD83C\uDFFB\u200D♀️", + [":woman_guard_tone2:"] = "\uD83D\uDC82\uD83C\uDFFC\u200D♀️", + [":woman_guard_tone3:"] = "\uD83D\uDC82\uD83C\uDFFD\u200D♀️", + [":woman_guard_tone4:"] = "\uD83D\uDC82\uD83C\uDFFE\u200D♀️", + [":woman_guard_tone5:"] = "\uD83D\uDC82\uD83C\uDFFF\u200D♀️", + [":woman_health_worker:"] = "\uD83D\uDC69\u200D⚕️", + [":woman_health_worker::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D⚕️", + [":woman_health_worker::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D⚕️", + [":woman_health_worker::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D⚕️", + [":woman_health_worker::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D⚕️", + [":woman_health_worker::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D⚕️", + [":woman_health_worker_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D⚕️", + [":woman_health_worker_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D⚕️", + [":woman_health_worker_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D⚕️", + [":woman_health_worker_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D⚕️", + [":woman_health_worker_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D⚕️", + [":woman_health_worker_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D⚕️", + [":woman_health_worker_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D⚕️", + [":woman_health_worker_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D⚕️", + [":woman_health_worker_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D⚕️", + [":woman_health_worker_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D⚕️", + [":woman_in_lotus_position:"] = "\uD83E\uDDD8\u200D♀️", + [":woman_in_lotus_position::skin-tone-1:"] = "\uD83E\uDDD8\uD83C\uDFFB\u200D♀️", + [":woman_in_lotus_position::skin-tone-2:"] = "\uD83E\uDDD8\uD83C\uDFFC\u200D♀️", + [":woman_in_lotus_position::skin-tone-3:"] = "\uD83E\uDDD8\uD83C\uDFFD\u200D♀️", + [":woman_in_lotus_position::skin-tone-4:"] = "\uD83E\uDDD8\uD83C\uDFFE\u200D♀️", + [":woman_in_lotus_position::skin-tone-5:"] = "\uD83E\uDDD8\uD83C\uDFFF\u200D♀️", + [":woman_in_lotus_position_dark_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFF\u200D♀️", + [":woman_in_lotus_position_light_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFB\u200D♀️", + [":woman_in_lotus_position_medium_dark_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFE\u200D♀️", + [":woman_in_lotus_position_medium_light_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFC\u200D♀️", + [":woman_in_lotus_position_medium_skin_tone:"] = "\uD83E\uDDD8\uD83C\uDFFD\u200D♀️", + [":woman_in_lotus_position_tone1:"] = "\uD83E\uDDD8\uD83C\uDFFB\u200D♀️", + [":woman_in_lotus_position_tone2:"] = "\uD83E\uDDD8\uD83C\uDFFC\u200D♀️", + [":woman_in_lotus_position_tone3:"] = "\uD83E\uDDD8\uD83C\uDFFD\u200D♀️", + [":woman_in_lotus_position_tone4:"] = "\uD83E\uDDD8\uD83C\uDFFE\u200D♀️", + [":woman_in_lotus_position_tone5:"] = "\uD83E\uDDD8\uD83C\uDFFF\u200D♀️", + [":woman_in_manual_wheelchair:"] = "\uD83D\uDC69\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDBD", + [":woman_in_manual_wheelchair_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDBD", + [":woman_in_motorized_wheelchair:"] = "\uD83D\uDC69\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDBC", + [":woman_in_motorized_wheelchair_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDBC", + [":woman_in_steamy_room:"] = "\uD83E\uDDD6\u200D♀️", + [":woman_in_steamy_room::skin-tone-1:"] = "\uD83E\uDDD6\uD83C\uDFFB\u200D♀️", + [":woman_in_steamy_room::skin-tone-2:"] = "\uD83E\uDDD6\uD83C\uDFFC\u200D♀️", + [":woman_in_steamy_room::skin-tone-3:"] = "\uD83E\uDDD6\uD83C\uDFFD\u200D♀️", + [":woman_in_steamy_room::skin-tone-4:"] = "\uD83E\uDDD6\uD83C\uDFFE\u200D♀️", + [":woman_in_steamy_room::skin-tone-5:"] = "\uD83E\uDDD6\uD83C\uDFFF\u200D♀️", + [":woman_in_steamy_room_dark_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFF\u200D♀️", + [":woman_in_steamy_room_light_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFB\u200D♀️", + [":woman_in_steamy_room_medium_dark_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFE\u200D♀️", + [":woman_in_steamy_room_medium_light_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFC\u200D♀️", + [":woman_in_steamy_room_medium_skin_tone:"] = "\uD83E\uDDD6\uD83C\uDFFD\u200D♀️", + [":woman_in_steamy_room_tone1:"] = "\uD83E\uDDD6\uD83C\uDFFB\u200D♀️", + [":woman_in_steamy_room_tone2:"] = "\uD83E\uDDD6\uD83C\uDFFC\u200D♀️", + [":woman_in_steamy_room_tone3:"] = "\uD83E\uDDD6\uD83C\uDFFD\u200D♀️", + [":woman_in_steamy_room_tone4:"] = "\uD83E\uDDD6\uD83C\uDFFE\u200D♀️", + [":woman_in_steamy_room_tone5:"] = "\uD83E\uDDD6\uD83C\uDFFF\u200D♀️", + [":woman_in_tuxedo:"] = "\uD83E\uDD35\u200D\u2640\uFE0F", + [":woman_in_tuxedo::skin-tone-1:"] = "\uD83E\uDD35\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_in_tuxedo::skin-tone-2:"] = "\uD83E\uDD35\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_in_tuxedo::skin-tone-3:"] = "\uD83E\uDD35\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_in_tuxedo::skin-tone-4:"] = "\uD83E\uDD35\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_in_tuxedo::skin-tone-5:"] = "\uD83E\uDD35\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_in_tuxedo_dark_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_in_tuxedo_light_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_in_tuxedo_medium_dark_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_in_tuxedo_medium_light_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_in_tuxedo_medium_skin_tone:"] = "\uD83E\uDD35\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_in_tuxedo_tone1:"] = "\uD83E\uDD35\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_in_tuxedo_tone2:"] = "\uD83E\uDD35\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_in_tuxedo_tone3:"] = "\uD83E\uDD35\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_in_tuxedo_tone4:"] = "\uD83E\uDD35\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_in_tuxedo_tone5:"] = "\uD83E\uDD35\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_judge:"] = "\uD83D\uDC69\u200D⚖️", + [":woman_judge::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D⚖️", + [":woman_judge::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D⚖️", + [":woman_judge::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D⚖️", + [":woman_judge::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D⚖️", + [":woman_judge::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D⚖️", + [":woman_judge_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D⚖️", + [":woman_judge_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D⚖️", + [":woman_judge_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D⚖️", + [":woman_judge_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D⚖️", + [":woman_judge_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D⚖️", + [":woman_judge_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D⚖️", + [":woman_judge_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D⚖️", + [":woman_judge_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D⚖️", + [":woman_judge_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D⚖️", + [":woman_judge_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D⚖️", + [":woman_juggling:"] = "\uD83E\uDD39\u200D♀️", + [":woman_juggling::skin-tone-1:"] = "\uD83E\uDD39\uD83C\uDFFB\u200D♀️", + [":woman_juggling::skin-tone-2:"] = "\uD83E\uDD39\uD83C\uDFFC\u200D♀️", + [":woman_juggling::skin-tone-3:"] = "\uD83E\uDD39\uD83C\uDFFD\u200D♀️", + [":woman_juggling::skin-tone-4:"] = "\uD83E\uDD39\uD83C\uDFFE\u200D♀️", + [":woman_juggling::skin-tone-5:"] = "\uD83E\uDD39\uD83C\uDFFF\u200D♀️", + [":woman_juggling_dark_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFF\u200D♀️", + [":woman_juggling_light_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFB\u200D♀️", + [":woman_juggling_medium_dark_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFE\u200D♀️", + [":woman_juggling_medium_light_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFC\u200D♀️", + [":woman_juggling_medium_skin_tone:"] = "\uD83E\uDD39\uD83C\uDFFD\u200D♀️", + [":woman_juggling_tone1:"] = "\uD83E\uDD39\uD83C\uDFFB\u200D♀️", + [":woman_juggling_tone2:"] = "\uD83E\uDD39\uD83C\uDFFC\u200D♀️", + [":woman_juggling_tone3:"] = "\uD83E\uDD39\uD83C\uDFFD\u200D♀️", + [":woman_juggling_tone4:"] = "\uD83E\uDD39\uD83C\uDFFE\u200D♀️", + [":woman_juggling_tone5:"] = "\uD83E\uDD39\uD83C\uDFFF\u200D♀️", + [":woman_kneeling:"] = "\uD83E\uDDCE\u200D♀️", + [":woman_kneeling::skin-tone-1:"] = "\uD83E\uDDCE\uD83C\uDFFB\u200D♀️", + [":woman_kneeling::skin-tone-2:"] = "\uD83E\uDDCE\uD83C\uDFFC\u200D♀️", + [":woman_kneeling::skin-tone-3:"] = "\uD83E\uDDCE\uD83C\uDFFD\u200D♀️", + [":woman_kneeling::skin-tone-4:"] = "\uD83E\uDDCE\uD83C\uDFFE\u200D♀️", + [":woman_kneeling::skin-tone-5:"] = "\uD83E\uDDCE\uD83C\uDFFF\u200D♀️", + [":woman_kneeling_dark_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFF\u200D♀️", + [":woman_kneeling_light_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFB\u200D♀️", + [":woman_kneeling_medium_dark_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFE\u200D♀️", + [":woman_kneeling_medium_light_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFC\u200D♀️", + [":woman_kneeling_medium_skin_tone:"] = "\uD83E\uDDCE\uD83C\uDFFD\u200D♀️", + [":woman_kneeling_tone1:"] = "\uD83E\uDDCE\uD83C\uDFFB\u200D♀️", + [":woman_kneeling_tone2:"] = "\uD83E\uDDCE\uD83C\uDFFC\u200D♀️", + [":woman_kneeling_tone3:"] = "\uD83E\uDDCE\uD83C\uDFFD\u200D♀️", + [":woman_kneeling_tone4:"] = "\uD83E\uDDCE\uD83C\uDFFE\u200D♀️", + [":woman_kneeling_tone5:"] = "\uD83E\uDDCE\uD83C\uDFFF\u200D♀️", + [":woman_lifting_weights:"] = "\uD83C\uDFCB️\u200D♀️", + [":woman_lifting_weights::skin-tone-1:"] = "\uD83C\uDFCB\uD83C\uDFFB\u200D♀️", + [":woman_lifting_weights::skin-tone-2:"] = "\uD83C\uDFCB\uD83C\uDFFC\u200D♀️", + [":woman_lifting_weights::skin-tone-3:"] = "\uD83C\uDFCB\uD83C\uDFFD\u200D♀️", + [":woman_lifting_weights::skin-tone-4:"] = "\uD83C\uDFCB\uD83C\uDFFE\u200D♀️", + [":woman_lifting_weights::skin-tone-5:"] = "\uD83C\uDFCB\uD83C\uDFFF\u200D♀️", + [":woman_lifting_weights_dark_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFF\u200D♀️", + [":woman_lifting_weights_light_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFB\u200D♀️", + [":woman_lifting_weights_medium_dark_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFE\u200D♀️", + [":woman_lifting_weights_medium_light_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFC\u200D♀️", + [":woman_lifting_weights_medium_skin_tone:"] = "\uD83C\uDFCB\uD83C\uDFFD\u200D♀️", + [":woman_lifting_weights_tone1:"] = "\uD83C\uDFCB\uD83C\uDFFB\u200D♀️", + [":woman_lifting_weights_tone2:"] = "\uD83C\uDFCB\uD83C\uDFFC\u200D♀️", + [":woman_lifting_weights_tone3:"] = "\uD83C\uDFCB\uD83C\uDFFD\u200D♀️", + [":woman_lifting_weights_tone4:"] = "\uD83C\uDFCB\uD83C\uDFFE\u200D♀️", + [":woman_lifting_weights_tone5:"] = "\uD83C\uDFCB\uD83C\uDFFF\u200D♀️", + [":woman_light_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_mage:"] = "\uD83E\uDDD9\u200D♀️", + [":woman_mage::skin-tone-1:"] = "\uD83E\uDDD9\uD83C\uDFFB\u200D♀️", + [":woman_mage::skin-tone-2:"] = "\uD83E\uDDD9\uD83C\uDFFC\u200D♀️", + [":woman_mage::skin-tone-3:"] = "\uD83E\uDDD9\uD83C\uDFFD\u200D♀️", + [":woman_mage::skin-tone-4:"] = "\uD83E\uDDD9\uD83C\uDFFE\u200D♀️", + [":woman_mage::skin-tone-5:"] = "\uD83E\uDDD9\uD83C\uDFFF\u200D♀️", + [":woman_mage_dark_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFF\u200D♀️", + [":woman_mage_light_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFB\u200D♀️", + [":woman_mage_medium_dark_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFE\u200D♀️", + [":woman_mage_medium_light_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFC\u200D♀️", + [":woman_mage_medium_skin_tone:"] = "\uD83E\uDDD9\uD83C\uDFFD\u200D♀️", + [":woman_mage_tone1:"] = "\uD83E\uDDD9\uD83C\uDFFB\u200D♀️", + [":woman_mage_tone2:"] = "\uD83E\uDDD9\uD83C\uDFFC\u200D♀️", + [":woman_mage_tone3:"] = "\uD83E\uDDD9\uD83C\uDFFD\u200D♀️", + [":woman_mage_tone4:"] = "\uD83E\uDDD9\uD83C\uDFFE\u200D♀️", + [":woman_mage_tone5:"] = "\uD83E\uDDD9\uD83C\uDFFF\u200D♀️", + [":woman_mechanic:"] = "\uD83D\uDC69\u200D\uD83D\uDD27", + [":woman_mechanic::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDD27", + [":woman_mechanic::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDD27", + [":woman_mechanic::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDD27", + [":woman_mechanic::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDD27", + [":woman_mechanic::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDD27", + [":woman_mechanic_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDD27", + [":woman_mechanic_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDD27", + [":woman_mechanic_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDD27", + [":woman_mechanic_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDD27", + [":woman_mechanic_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDD27", + [":woman_mechanic_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDD27", + [":woman_mechanic_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDD27", + [":woman_mechanic_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDD27", + [":woman_mechanic_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDD27", + [":woman_mechanic_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDD27", + [":woman_medium_dark_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_medium_light_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_medium_skin_tone_beard:"] = "\uD83E\uDDD4\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_mountain_biking:"] = "\uD83D\uDEB5\u200D♀️", + [":woman_mountain_biking::skin-tone-1:"] = "\uD83D\uDEB5\uD83C\uDFFB\u200D♀️", + [":woman_mountain_biking::skin-tone-2:"] = "\uD83D\uDEB5\uD83C\uDFFC\u200D♀️", + [":woman_mountain_biking::skin-tone-3:"] = "\uD83D\uDEB5\uD83C\uDFFD\u200D♀️", + [":woman_mountain_biking::skin-tone-4:"] = "\uD83D\uDEB5\uD83C\uDFFE\u200D♀️", + [":woman_mountain_biking::skin-tone-5:"] = "\uD83D\uDEB5\uD83C\uDFFF\u200D♀️", + [":woman_mountain_biking_dark_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFF\u200D♀️", + [":woman_mountain_biking_light_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFB\u200D♀️", + [":woman_mountain_biking_medium_dark_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFE\u200D♀️", + [":woman_mountain_biking_medium_light_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFC\u200D♀️", + [":woman_mountain_biking_medium_skin_tone:"] = "\uD83D\uDEB5\uD83C\uDFFD\u200D♀️", + [":woman_mountain_biking_tone1:"] = "\uD83D\uDEB5\uD83C\uDFFB\u200D♀️", + [":woman_mountain_biking_tone2:"] = "\uD83D\uDEB5\uD83C\uDFFC\u200D♀️", + [":woman_mountain_biking_tone3:"] = "\uD83D\uDEB5\uD83C\uDFFD\u200D♀️", + [":woman_mountain_biking_tone4:"] = "\uD83D\uDEB5\uD83C\uDFFE\u200D♀️", + [":woman_mountain_biking_tone5:"] = "\uD83D\uDEB5\uD83C\uDFFF\u200D♀️", + [":woman_office_worker:"] = "\uD83D\uDC69\u200D\uD83D\uDCBC", + [":woman_office_worker::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDCBC", + [":woman_office_worker::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDCBC", + [":woman_office_worker::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDCBC", + [":woman_office_worker::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDCBC", + [":woman_office_worker::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDCBC", + [":woman_office_worker_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDCBC", + [":woman_office_worker_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDCBC", + [":woman_office_worker_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDCBC", + [":woman_office_worker_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDCBC", + [":woman_office_worker_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDCBC", + [":woman_office_worker_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDCBC", + [":woman_office_worker_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDCBC", + [":woman_office_worker_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDCBC", + [":woman_office_worker_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDCBC", + [":woman_office_worker_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDCBC", + [":woman_pilot:"] = "\uD83D\uDC69\u200D✈️", + [":woman_pilot::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D✈️", + [":woman_pilot::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D✈️", + [":woman_pilot::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D✈️", + [":woman_pilot::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D✈️", + [":woman_pilot::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D✈️", + [":woman_pilot_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D✈️", + [":woman_pilot_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D✈️", + [":woman_pilot_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D✈️", + [":woman_pilot_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D✈️", + [":woman_pilot_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D✈️", + [":woman_pilot_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D✈️", + [":woman_pilot_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D✈️", + [":woman_pilot_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D✈️", + [":woman_pilot_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D✈️", + [":woman_pilot_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D✈️", + [":woman_playing_handball:"] = "\uD83E\uDD3E\u200D♀️", + [":woman_playing_handball::skin-tone-1:"] = "\uD83E\uDD3E\uD83C\uDFFB\u200D♀️", + [":woman_playing_handball::skin-tone-2:"] = "\uD83E\uDD3E\uD83C\uDFFC\u200D♀️", + [":woman_playing_handball::skin-tone-3:"] = "\uD83E\uDD3E\uD83C\uDFFD\u200D♀️", + [":woman_playing_handball::skin-tone-4:"] = "\uD83E\uDD3E\uD83C\uDFFE\u200D♀️", + [":woman_playing_handball::skin-tone-5:"] = "\uD83E\uDD3E\uD83C\uDFFF\u200D♀️", + [":woman_playing_handball_dark_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFF\u200D♀️", + [":woman_playing_handball_light_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFB\u200D♀️", + [":woman_playing_handball_medium_dark_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFE\u200D♀️", + [":woman_playing_handball_medium_light_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFC\u200D♀️", + [":woman_playing_handball_medium_skin_tone:"] = "\uD83E\uDD3E\uD83C\uDFFD\u200D♀️", + [":woman_playing_handball_tone1:"] = "\uD83E\uDD3E\uD83C\uDFFB\u200D♀️", + [":woman_playing_handball_tone2:"] = "\uD83E\uDD3E\uD83C\uDFFC\u200D♀️", + [":woman_playing_handball_tone3:"] = "\uD83E\uDD3E\uD83C\uDFFD\u200D♀️", + [":woman_playing_handball_tone4:"] = "\uD83E\uDD3E\uD83C\uDFFE\u200D♀️", + [":woman_playing_handball_tone5:"] = "\uD83E\uDD3E\uD83C\uDFFF\u200D♀️", + [":woman_playing_water_polo:"] = "\uD83E\uDD3D\u200D♀️", + [":woman_playing_water_polo::skin-tone-1:"] = "\uD83E\uDD3D\uD83C\uDFFB\u200D♀️", + [":woman_playing_water_polo::skin-tone-2:"] = "\uD83E\uDD3D\uD83C\uDFFC\u200D♀️", + [":woman_playing_water_polo::skin-tone-3:"] = "\uD83E\uDD3D\uD83C\uDFFD\u200D♀️", + [":woman_playing_water_polo::skin-tone-4:"] = "\uD83E\uDD3D\uD83C\uDFFE\u200D♀️", + [":woman_playing_water_polo::skin-tone-5:"] = "\uD83E\uDD3D\uD83C\uDFFF\u200D♀️", + [":woman_playing_water_polo_dark_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFF\u200D♀️", + [":woman_playing_water_polo_light_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFB\u200D♀️", + [":woman_playing_water_polo_medium_dark_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFE\u200D♀️", + [":woman_playing_water_polo_medium_light_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFC\u200D♀️", + [":woman_playing_water_polo_medium_skin_tone:"] = "\uD83E\uDD3D\uD83C\uDFFD\u200D♀️", + [":woman_playing_water_polo_tone1:"] = "\uD83E\uDD3D\uD83C\uDFFB\u200D♀️", + [":woman_playing_water_polo_tone2:"] = "\uD83E\uDD3D\uD83C\uDFFC\u200D♀️", + [":woman_playing_water_polo_tone3:"] = "\uD83E\uDD3D\uD83C\uDFFD\u200D♀️", + [":woman_playing_water_polo_tone4:"] = "\uD83E\uDD3D\uD83C\uDFFE\u200D♀️", + [":woman_playing_water_polo_tone5:"] = "\uD83E\uDD3D\uD83C\uDFFF\u200D♀️", + [":woman_police_officer:"] = "\uD83D\uDC6E\u200D♀️", + [":woman_police_officer::skin-tone-1:"] = "\uD83D\uDC6E\uD83C\uDFFB\u200D♀️", + [":woman_police_officer::skin-tone-2:"] = "\uD83D\uDC6E\uD83C\uDFFC\u200D♀️", + [":woman_police_officer::skin-tone-3:"] = "\uD83D\uDC6E\uD83C\uDFFD\u200D♀️", + [":woman_police_officer::skin-tone-4:"] = "\uD83D\uDC6E\uD83C\uDFFE\u200D♀️", + [":woman_police_officer::skin-tone-5:"] = "\uD83D\uDC6E\uD83C\uDFFF\u200D♀️", + [":woman_police_officer_dark_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFF\u200D♀️", + [":woman_police_officer_light_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFB\u200D♀️", + [":woman_police_officer_medium_dark_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFE\u200D♀️", + [":woman_police_officer_medium_light_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFC\u200D♀️", + [":woman_police_officer_medium_skin_tone:"] = "\uD83D\uDC6E\uD83C\uDFFD\u200D♀️", + [":woman_police_officer_tone1:"] = "\uD83D\uDC6E\uD83C\uDFFB\u200D♀️", + [":woman_police_officer_tone2:"] = "\uD83D\uDC6E\uD83C\uDFFC\u200D♀️", + [":woman_police_officer_tone3:"] = "\uD83D\uDC6E\uD83C\uDFFD\u200D♀️", + [":woman_police_officer_tone4:"] = "\uD83D\uDC6E\uD83C\uDFFE\u200D♀️", + [":woman_police_officer_tone5:"] = "\uD83D\uDC6E\uD83C\uDFFF\u200D♀️", + [":woman_pouting:"] = "\uD83D\uDE4E\u200D♀️", + [":woman_pouting::skin-tone-1:"] = "\uD83D\uDE4E\uD83C\uDFFB\u200D♀️", + [":woman_pouting::skin-tone-2:"] = "\uD83D\uDE4E\uD83C\uDFFC\u200D♀️", + [":woman_pouting::skin-tone-3:"] = "\uD83D\uDE4E\uD83C\uDFFD\u200D♀️", + [":woman_pouting::skin-tone-4:"] = "\uD83D\uDE4E\uD83C\uDFFE\u200D♀️", + [":woman_pouting::skin-tone-5:"] = "\uD83D\uDE4E\uD83C\uDFFF\u200D♀️", + [":woman_pouting_dark_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFF\u200D♀️", + [":woman_pouting_light_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFB\u200D♀️", + [":woman_pouting_medium_dark_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFE\u200D♀️", + [":woman_pouting_medium_light_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFC\u200D♀️", + [":woman_pouting_medium_skin_tone:"] = "\uD83D\uDE4E\uD83C\uDFFD\u200D♀️", + [":woman_pouting_tone1:"] = "\uD83D\uDE4E\uD83C\uDFFB\u200D♀️", + [":woman_pouting_tone2:"] = "\uD83D\uDE4E\uD83C\uDFFC\u200D♀️", + [":woman_pouting_tone3:"] = "\uD83D\uDE4E\uD83C\uDFFD\u200D♀️", + [":woman_pouting_tone4:"] = "\uD83D\uDE4E\uD83C\uDFFE\u200D♀️", + [":woman_pouting_tone5:"] = "\uD83D\uDE4E\uD83C\uDFFF\u200D♀️", + [":woman_raising_hand:"] = "\uD83D\uDE4B\u200D♀️", + [":woman_raising_hand::skin-tone-1:"] = "\uD83D\uDE4B\uD83C\uDFFB\u200D♀️", + [":woman_raising_hand::skin-tone-2:"] = "\uD83D\uDE4B\uD83C\uDFFC\u200D♀️", + [":woman_raising_hand::skin-tone-3:"] = "\uD83D\uDE4B\uD83C\uDFFD\u200D♀️", + [":woman_raising_hand::skin-tone-4:"] = "\uD83D\uDE4B\uD83C\uDFFE\u200D♀️", + [":woman_raising_hand::skin-tone-5:"] = "\uD83D\uDE4B\uD83C\uDFFF\u200D♀️", + [":woman_raising_hand_dark_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFF\u200D♀️", + [":woman_raising_hand_light_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFB\u200D♀️", + [":woman_raising_hand_medium_dark_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFE\u200D♀️", + [":woman_raising_hand_medium_light_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFC\u200D♀️", + [":woman_raising_hand_medium_skin_tone:"] = "\uD83D\uDE4B\uD83C\uDFFD\u200D♀️", + [":woman_raising_hand_tone1:"] = "\uD83D\uDE4B\uD83C\uDFFB\u200D♀️", + [":woman_raising_hand_tone2:"] = "\uD83D\uDE4B\uD83C\uDFFC\u200D♀️", + [":woman_raising_hand_tone3:"] = "\uD83D\uDE4B\uD83C\uDFFD\u200D♀️", + [":woman_raising_hand_tone4:"] = "\uD83D\uDE4B\uD83C\uDFFE\u200D♀️", + [":woman_raising_hand_tone5:"] = "\uD83D\uDE4B\uD83C\uDFFF\u200D♀️", + [":woman_red_haired:"] = "\uD83D\uDC69\u200D\uD83E\uDDB0", + [":woman_red_haired::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB0", + [":woman_red_haired::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB0", + [":woman_red_haired::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB0", + [":woman_red_haired::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB0", + [":woman_red_haired::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB0", + [":woman_red_haired_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB0", + [":woman_red_haired_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB0", + [":woman_red_haired_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB0", + [":woman_red_haired_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB0", + [":woman_red_haired_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB0", + [":woman_red_haired_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB0", + [":woman_red_haired_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB0", + [":woman_red_haired_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB0", + [":woman_red_haired_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB0", + [":woman_red_haired_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB0", + [":woman_rowing_boat:"] = "\uD83D\uDEA3\u200D♀️", + [":woman_rowing_boat::skin-tone-1:"] = "\uD83D\uDEA3\uD83C\uDFFB\u200D♀️", + [":woman_rowing_boat::skin-tone-2:"] = "\uD83D\uDEA3\uD83C\uDFFC\u200D♀️", + [":woman_rowing_boat::skin-tone-3:"] = "\uD83D\uDEA3\uD83C\uDFFD\u200D♀️", + [":woman_rowing_boat::skin-tone-4:"] = "\uD83D\uDEA3\uD83C\uDFFE\u200D♀️", + [":woman_rowing_boat::skin-tone-5:"] = "\uD83D\uDEA3\uD83C\uDFFF\u200D♀️", + [":woman_rowing_boat_dark_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFF\u200D♀️", + [":woman_rowing_boat_light_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFB\u200D♀️", + [":woman_rowing_boat_medium_dark_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFE\u200D♀️", + [":woman_rowing_boat_medium_light_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFC\u200D♀️", + [":woman_rowing_boat_medium_skin_tone:"] = "\uD83D\uDEA3\uD83C\uDFFD\u200D♀️", + [":woman_rowing_boat_tone1:"] = "\uD83D\uDEA3\uD83C\uDFFB\u200D♀️", + [":woman_rowing_boat_tone2:"] = "\uD83D\uDEA3\uD83C\uDFFC\u200D♀️", + [":woman_rowing_boat_tone3:"] = "\uD83D\uDEA3\uD83C\uDFFD\u200D♀️", + [":woman_rowing_boat_tone4:"] = "\uD83D\uDEA3\uD83C\uDFFE\u200D♀️", + [":woman_rowing_boat_tone5:"] = "\uD83D\uDEA3\uD83C\uDFFF\u200D♀️", + [":woman_running:"] = "\uD83C\uDFC3\u200D♀️", + [":woman_running::skin-tone-1:"] = "\uD83C\uDFC3\uD83C\uDFFB\u200D♀️", + [":woman_running::skin-tone-2:"] = "\uD83C\uDFC3\uD83C\uDFFC\u200D♀️", + [":woman_running::skin-tone-3:"] = "\uD83C\uDFC3\uD83C\uDFFD\u200D♀️", + [":woman_running::skin-tone-4:"] = "\uD83C\uDFC3\uD83C\uDFFE\u200D♀️", + [":woman_running::skin-tone-5:"] = "\uD83C\uDFC3\uD83C\uDFFF\u200D♀️", + [":woman_running_dark_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFF\u200D♀️", + [":woman_running_light_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFB\u200D♀️", + [":woman_running_medium_dark_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFE\u200D♀️", + [":woman_running_medium_light_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFC\u200D♀️", + [":woman_running_medium_skin_tone:"] = "\uD83C\uDFC3\uD83C\uDFFD\u200D♀️", + [":woman_running_tone1:"] = "\uD83C\uDFC3\uD83C\uDFFB\u200D♀️", + [":woman_running_tone2:"] = "\uD83C\uDFC3\uD83C\uDFFC\u200D♀️", + [":woman_running_tone3:"] = "\uD83C\uDFC3\uD83C\uDFFD\u200D♀️", + [":woman_running_tone4:"] = "\uD83C\uDFC3\uD83C\uDFFE\u200D♀️", + [":woman_running_tone5:"] = "\uD83C\uDFC3\uD83C\uDFFF\u200D♀️", + [":woman_scientist:"] = "\uD83D\uDC69\u200D\uD83D\uDD2C", + [":woman_scientist::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDD2C", + [":woman_scientist::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDD2C", + [":woman_scientist::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDD2C", + [":woman_scientist::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDD2C", + [":woman_scientist::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDD2C", + [":woman_scientist_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDD2C", + [":woman_scientist_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDD2C", + [":woman_scientist_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDD2C", + [":woman_scientist_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDD2C", + [":woman_scientist_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDD2C", + [":woman_scientist_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDD2C", + [":woman_scientist_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDD2C", + [":woman_scientist_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDD2C", + [":woman_scientist_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDD2C", + [":woman_scientist_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDD2C", + [":woman_shrugging:"] = "\uD83E\uDD37\u200D♀️", + [":woman_shrugging::skin-tone-1:"] = "\uD83E\uDD37\uD83C\uDFFB\u200D♀️", + [":woman_shrugging::skin-tone-2:"] = "\uD83E\uDD37\uD83C\uDFFC\u200D♀️", + [":woman_shrugging::skin-tone-3:"] = "\uD83E\uDD37\uD83C\uDFFD\u200D♀️", + [":woman_shrugging::skin-tone-4:"] = "\uD83E\uDD37\uD83C\uDFFE\u200D♀️", + [":woman_shrugging::skin-tone-5:"] = "\uD83E\uDD37\uD83C\uDFFF\u200D♀️", + [":woman_shrugging_dark_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFF\u200D♀️", + [":woman_shrugging_light_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFB\u200D♀️", + [":woman_shrugging_medium_dark_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFE\u200D♀️", + [":woman_shrugging_medium_light_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFC\u200D♀️", + [":woman_shrugging_medium_skin_tone:"] = "\uD83E\uDD37\uD83C\uDFFD\u200D♀️", + [":woman_shrugging_tone1:"] = "\uD83E\uDD37\uD83C\uDFFB\u200D♀️", + [":woman_shrugging_tone2:"] = "\uD83E\uDD37\uD83C\uDFFC\u200D♀️", + [":woman_shrugging_tone3:"] = "\uD83E\uDD37\uD83C\uDFFD\u200D♀️", + [":woman_shrugging_tone4:"] = "\uD83E\uDD37\uD83C\uDFFE\u200D♀️", + [":woman_shrugging_tone5:"] = "\uD83E\uDD37\uD83C\uDFFF\u200D♀️", + [":woman_singer:"] = "\uD83D\uDC69\u200D\uD83C\uDFA4", + [":woman_singer::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFA4", + [":woman_singer::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFA4", + [":woman_singer::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFA4", + [":woman_singer::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFA4", + [":woman_singer::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFA4", + [":woman_singer_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFA4", + [":woman_singer_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFA4", + [":woman_singer_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFA4", + [":woman_singer_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFA4", + [":woman_singer_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFA4", + [":woman_singer_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFA4", + [":woman_singer_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFA4", + [":woman_singer_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFA4", + [":woman_singer_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFA4", + [":woman_singer_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFA4", + [":woman_standing:"] = "\uD83E\uDDCD\u200D♀️", + [":woman_standing::skin-tone-1:"] = "\uD83E\uDDCD\uD83C\uDFFB\u200D♀️", + [":woman_standing::skin-tone-2:"] = "\uD83E\uDDCD\uD83C\uDFFC\u200D♀️", + [":woman_standing::skin-tone-3:"] = "\uD83E\uDDCD\uD83C\uDFFD\u200D♀️", + [":woman_standing::skin-tone-4:"] = "\uD83E\uDDCD\uD83C\uDFFE\u200D♀️", + [":woman_standing::skin-tone-5:"] = "\uD83E\uDDCD\uD83C\uDFFF\u200D♀️", + [":woman_standing_dark_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFF\u200D♀️", + [":woman_standing_light_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFB\u200D♀️", + [":woman_standing_medium_dark_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFE\u200D♀️", + [":woman_standing_medium_light_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFC\u200D♀️", + [":woman_standing_medium_skin_tone:"] = "\uD83E\uDDCD\uD83C\uDFFD\u200D♀️", + [":woman_standing_tone1:"] = "\uD83E\uDDCD\uD83C\uDFFB\u200D♀️", + [":woman_standing_tone2:"] = "\uD83E\uDDCD\uD83C\uDFFC\u200D♀️", + [":woman_standing_tone3:"] = "\uD83E\uDDCD\uD83C\uDFFD\u200D♀️", + [":woman_standing_tone4:"] = "\uD83E\uDDCD\uD83C\uDFFE\u200D♀️", + [":woman_standing_tone5:"] = "\uD83E\uDDCD\uD83C\uDFFF\u200D♀️", + [":woman_student:"] = "\uD83D\uDC69\u200D\uD83C\uDF93", + [":woman_student::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF93", + [":woman_student::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF93", + [":woman_student::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF93", + [":woman_student::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF93", + [":woman_student::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF93", + [":woman_student_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF93", + [":woman_student_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF93", + [":woman_student_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF93", + [":woman_student_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF93", + [":woman_student_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF93", + [":woman_student_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDF93", + [":woman_student_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDF93", + [":woman_student_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDF93", + [":woman_student_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDF93", + [":woman_student_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDF93", + [":woman_superhero:"] = "\uD83E\uDDB8\u200D♀️", + [":woman_superhero::skin-tone-1:"] = "\uD83E\uDDB8\uD83C\uDFFB\u200D♀️", + [":woman_superhero::skin-tone-2:"] = "\uD83E\uDDB8\uD83C\uDFFC\u200D♀️", + [":woman_superhero::skin-tone-3:"] = "\uD83E\uDDB8\uD83C\uDFFD\u200D♀️", + [":woman_superhero::skin-tone-4:"] = "\uD83E\uDDB8\uD83C\uDFFE\u200D♀️", + [":woman_superhero::skin-tone-5:"] = "\uD83E\uDDB8\uD83C\uDFFF\u200D♀️", + [":woman_superhero_dark_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFF\u200D♀️", + [":woman_superhero_light_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFB\u200D♀️", + [":woman_superhero_medium_dark_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFE\u200D♀️", + [":woman_superhero_medium_light_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFC\u200D♀️", + [":woman_superhero_medium_skin_tone:"] = "\uD83E\uDDB8\uD83C\uDFFD\u200D♀️", + [":woman_superhero_tone1:"] = "\uD83E\uDDB8\uD83C\uDFFB\u200D♀️", + [":woman_superhero_tone2:"] = "\uD83E\uDDB8\uD83C\uDFFC\u200D♀️", + [":woman_superhero_tone3:"] = "\uD83E\uDDB8\uD83C\uDFFD\u200D♀️", + [":woman_superhero_tone4:"] = "\uD83E\uDDB8\uD83C\uDFFE\u200D♀️", + [":woman_superhero_tone5:"] = "\uD83E\uDDB8\uD83C\uDFFF\u200D♀️", + [":woman_supervillain:"] = "\uD83E\uDDB9\u200D♀️", + [":woman_supervillain::skin-tone-1:"] = "\uD83E\uDDB9\uD83C\uDFFB\u200D♀️", + [":woman_supervillain::skin-tone-2:"] = "\uD83E\uDDB9\uD83C\uDFFC\u200D♀️", + [":woman_supervillain::skin-tone-3:"] = "\uD83E\uDDB9\uD83C\uDFFD\u200D♀️", + [":woman_supervillain::skin-tone-4:"] = "\uD83E\uDDB9\uD83C\uDFFE\u200D♀️", + [":woman_supervillain::skin-tone-5:"] = "\uD83E\uDDB9\uD83C\uDFFF\u200D♀️", + [":woman_supervillain_dark_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFF\u200D♀️", + [":woman_supervillain_light_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFB\u200D♀️", + [":woman_supervillain_medium_dark_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFE\u200D♀️", + [":woman_supervillain_medium_light_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFC\u200D♀️", + [":woman_supervillain_medium_skin_tone:"] = "\uD83E\uDDB9\uD83C\uDFFD\u200D♀️", + [":woman_supervillain_tone1:"] = "\uD83E\uDDB9\uD83C\uDFFB\u200D♀️", + [":woman_supervillain_tone2:"] = "\uD83E\uDDB9\uD83C\uDFFC\u200D♀️", + [":woman_supervillain_tone3:"] = "\uD83E\uDDB9\uD83C\uDFFD\u200D♀️", + [":woman_supervillain_tone4:"] = "\uD83E\uDDB9\uD83C\uDFFE\u200D♀️", + [":woman_supervillain_tone5:"] = "\uD83E\uDDB9\uD83C\uDFFF\u200D♀️", + [":woman_surfing:"] = "\uD83C\uDFC4\u200D♀️", + [":woman_surfing::skin-tone-1:"] = "\uD83C\uDFC4\uD83C\uDFFB\u200D♀️", + [":woman_surfing::skin-tone-2:"] = "\uD83C\uDFC4\uD83C\uDFFC\u200D♀️", + [":woman_surfing::skin-tone-3:"] = "\uD83C\uDFC4\uD83C\uDFFD\u200D♀️", + [":woman_surfing::skin-tone-4:"] = "\uD83C\uDFC4\uD83C\uDFFE\u200D♀️", + [":woman_surfing::skin-tone-5:"] = "\uD83C\uDFC4\uD83C\uDFFF\u200D♀️", + [":woman_surfing_dark_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFF\u200D♀️", + [":woman_surfing_light_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFB\u200D♀️", + [":woman_surfing_medium_dark_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFE\u200D♀️", + [":woman_surfing_medium_light_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFC\u200D♀️", + [":woman_surfing_medium_skin_tone:"] = "\uD83C\uDFC4\uD83C\uDFFD\u200D♀️", + [":woman_surfing_tone1:"] = "\uD83C\uDFC4\uD83C\uDFFB\u200D♀️", + [":woman_surfing_tone2:"] = "\uD83C\uDFC4\uD83C\uDFFC\u200D♀️", + [":woman_surfing_tone3:"] = "\uD83C\uDFC4\uD83C\uDFFD\u200D♀️", + [":woman_surfing_tone4:"] = "\uD83C\uDFC4\uD83C\uDFFE\u200D♀️", + [":woman_surfing_tone5:"] = "\uD83C\uDFC4\uD83C\uDFFF\u200D♀️", + [":woman_swimming:"] = "\uD83C\uDFCA\u200D♀️", + [":woman_swimming::skin-tone-1:"] = "\uD83C\uDFCA\uD83C\uDFFB\u200D♀️", + [":woman_swimming::skin-tone-2:"] = "\uD83C\uDFCA\uD83C\uDFFC\u200D♀️", + [":woman_swimming::skin-tone-3:"] = "\uD83C\uDFCA\uD83C\uDFFD\u200D♀️", + [":woman_swimming::skin-tone-4:"] = "\uD83C\uDFCA\uD83C\uDFFE\u200D♀️", + [":woman_swimming::skin-tone-5:"] = "\uD83C\uDFCA\uD83C\uDFFF\u200D♀️", + [":woman_swimming_dark_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFF\u200D♀️", + [":woman_swimming_light_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFB\u200D♀️", + [":woman_swimming_medium_dark_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFE\u200D♀️", + [":woman_swimming_medium_light_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFC\u200D♀️", + [":woman_swimming_medium_skin_tone:"] = "\uD83C\uDFCA\uD83C\uDFFD\u200D♀️", + [":woman_swimming_tone1:"] = "\uD83C\uDFCA\uD83C\uDFFB\u200D♀️", + [":woman_swimming_tone2:"] = "\uD83C\uDFCA\uD83C\uDFFC\u200D♀️", + [":woman_swimming_tone3:"] = "\uD83C\uDFCA\uD83C\uDFFD\u200D♀️", + [":woman_swimming_tone4:"] = "\uD83C\uDFCA\uD83C\uDFFE\u200D♀️", + [":woman_swimming_tone5:"] = "\uD83C\uDFCA\uD83C\uDFFF\u200D♀️", + [":woman_teacher:"] = "\uD83D\uDC69\u200D\uD83C\uDFEB", + [":woman_teacher::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFEB", + [":woman_teacher::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFEB", + [":woman_teacher::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFEB", + [":woman_teacher::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFEB", + [":woman_teacher::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFEB", + [":woman_teacher_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFEB", + [":woman_teacher_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFEB", + [":woman_teacher_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFEB", + [":woman_teacher_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFEB", + [":woman_teacher_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFEB", + [":woman_teacher_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83C\uDFEB", + [":woman_teacher_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83C\uDFEB", + [":woman_teacher_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83C\uDFEB", + [":woman_teacher_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83C\uDFEB", + [":woman_teacher_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83C\uDFEB", + [":woman_technologist:"] = "\uD83D\uDC69\u200D\uD83D\uDCBB", + [":woman_technologist::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDCBB", + [":woman_technologist::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDCBB", + [":woman_technologist::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDCBB", + [":woman_technologist::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDCBB", + [":woman_technologist::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDCBB", + [":woman_technologist_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDCBB", + [":woman_technologist_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDCBB", + [":woman_technologist_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDCBB", + [":woman_technologist_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDCBB", + [":woman_technologist_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDCBB", + [":woman_technologist_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDCBB", + [":woman_technologist_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83D\uDCBB", + [":woman_technologist_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83D\uDCBB", + [":woman_technologist_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83D\uDCBB", + [":woman_technologist_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83D\uDCBB", + [":woman_tipping_hand:"] = "\uD83D\uDC81\u200D♀️", + [":woman_tipping_hand::skin-tone-1:"] = "\uD83D\uDC81\uD83C\uDFFB\u200D♀️", + [":woman_tipping_hand::skin-tone-2:"] = "\uD83D\uDC81\uD83C\uDFFC\u200D♀️", + [":woman_tipping_hand::skin-tone-3:"] = "\uD83D\uDC81\uD83C\uDFFD\u200D♀️", + [":woman_tipping_hand::skin-tone-4:"] = "\uD83D\uDC81\uD83C\uDFFE\u200D♀️", + [":woman_tipping_hand::skin-tone-5:"] = "\uD83D\uDC81\uD83C\uDFFF\u200D♀️", + [":woman_tipping_hand_dark_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFF\u200D♀️", + [":woman_tipping_hand_light_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFB\u200D♀️", + [":woman_tipping_hand_medium_dark_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFE\u200D♀️", + [":woman_tipping_hand_medium_light_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFC\u200D♀️", + [":woman_tipping_hand_medium_skin_tone:"] = "\uD83D\uDC81\uD83C\uDFFD\u200D♀️", + [":woman_tipping_hand_tone1:"] = "\uD83D\uDC81\uD83C\uDFFB\u200D♀️", + [":woman_tipping_hand_tone2:"] = "\uD83D\uDC81\uD83C\uDFFC\u200D♀️", + [":woman_tipping_hand_tone3:"] = "\uD83D\uDC81\uD83C\uDFFD\u200D♀️", + [":woman_tipping_hand_tone4:"] = "\uD83D\uDC81\uD83C\uDFFE\u200D♀️", + [":woman_tipping_hand_tone5:"] = "\uD83D\uDC81\uD83C\uDFFF\u200D♀️", + [":woman_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB", + [":woman_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC", + [":woman_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD", + [":woman_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE", + [":woman_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF", + [":woman_vampire:"] = "\uD83E\uDDDB\u200D♀️", + [":woman_vampire::skin-tone-1:"] = "\uD83E\uDDDB\uD83C\uDFFB\u200D♀️", + [":woman_vampire::skin-tone-2:"] = "\uD83E\uDDDB\uD83C\uDFFC\u200D♀️", + [":woman_vampire::skin-tone-3:"] = "\uD83E\uDDDB\uD83C\uDFFD\u200D♀️", + [":woman_vampire::skin-tone-4:"] = "\uD83E\uDDDB\uD83C\uDFFE\u200D♀️", + [":woman_vampire::skin-tone-5:"] = "\uD83E\uDDDB\uD83C\uDFFF\u200D♀️", + [":woman_vampire_dark_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFF\u200D♀️", + [":woman_vampire_light_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFB\u200D♀️", + [":woman_vampire_medium_dark_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFE\u200D♀️", + [":woman_vampire_medium_light_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFC\u200D♀️", + [":woman_vampire_medium_skin_tone:"] = "\uD83E\uDDDB\uD83C\uDFFD\u200D♀️", + [":woman_vampire_tone1:"] = "\uD83E\uDDDB\uD83C\uDFFB\u200D♀️", + [":woman_vampire_tone2:"] = "\uD83E\uDDDB\uD83C\uDFFC\u200D♀️", + [":woman_vampire_tone3:"] = "\uD83E\uDDDB\uD83C\uDFFD\u200D♀️", + [":woman_vampire_tone4:"] = "\uD83E\uDDDB\uD83C\uDFFE\u200D♀️", + [":woman_vampire_tone5:"] = "\uD83E\uDDDB\uD83C\uDFFF\u200D♀️", + [":woman_walking:"] = "\uD83D\uDEB6\u200D♀️", + [":woman_walking::skin-tone-1:"] = "\uD83D\uDEB6\uD83C\uDFFB\u200D♀️", + [":woman_walking::skin-tone-2:"] = "\uD83D\uDEB6\uD83C\uDFFC\u200D♀️", + [":woman_walking::skin-tone-3:"] = "\uD83D\uDEB6\uD83C\uDFFD\u200D♀️", + [":woman_walking::skin-tone-4:"] = "\uD83D\uDEB6\uD83C\uDFFE\u200D♀️", + [":woman_walking::skin-tone-5:"] = "\uD83D\uDEB6\uD83C\uDFFF\u200D♀️", + [":woman_walking_dark_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFF\u200D♀️", + [":woman_walking_light_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFB\u200D♀️", + [":woman_walking_medium_dark_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFE\u200D♀️", + [":woman_walking_medium_light_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFC\u200D♀️", + [":woman_walking_medium_skin_tone:"] = "\uD83D\uDEB6\uD83C\uDFFD\u200D♀️", + [":woman_walking_tone1:"] = "\uD83D\uDEB6\uD83C\uDFFB\u200D♀️", + [":woman_walking_tone2:"] = "\uD83D\uDEB6\uD83C\uDFFC\u200D♀️", + [":woman_walking_tone3:"] = "\uD83D\uDEB6\uD83C\uDFFD\u200D♀️", + [":woman_walking_tone4:"] = "\uD83D\uDEB6\uD83C\uDFFE\u200D♀️", + [":woman_walking_tone5:"] = "\uD83D\uDEB6\uD83C\uDFFF\u200D♀️", + [":woman_wearing_turban:"] = "\uD83D\uDC73\u200D♀️", + [":woman_wearing_turban::skin-tone-1:"] = "\uD83D\uDC73\uD83C\uDFFB\u200D♀️", + [":woman_wearing_turban::skin-tone-2:"] = "\uD83D\uDC73\uD83C\uDFFC\u200D♀️", + [":woman_wearing_turban::skin-tone-3:"] = "\uD83D\uDC73\uD83C\uDFFD\u200D♀️", + [":woman_wearing_turban::skin-tone-4:"] = "\uD83D\uDC73\uD83C\uDFFE\u200D♀️", + [":woman_wearing_turban::skin-tone-5:"] = "\uD83D\uDC73\uD83C\uDFFF\u200D♀️", + [":woman_wearing_turban_dark_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFF\u200D♀️", + [":woman_wearing_turban_light_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFB\u200D♀️", + [":woman_wearing_turban_medium_dark_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFE\u200D♀️", + [":woman_wearing_turban_medium_light_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFC\u200D♀️", + [":woman_wearing_turban_medium_skin_tone:"] = "\uD83D\uDC73\uD83C\uDFFD\u200D♀️", + [":woman_wearing_turban_tone1:"] = "\uD83D\uDC73\uD83C\uDFFB\u200D♀️", + [":woman_wearing_turban_tone2:"] = "\uD83D\uDC73\uD83C\uDFFC\u200D♀️", + [":woman_wearing_turban_tone3:"] = "\uD83D\uDC73\uD83C\uDFFD\u200D♀️", + [":woman_wearing_turban_tone4:"] = "\uD83D\uDC73\uD83C\uDFFE\u200D♀️", + [":woman_wearing_turban_tone5:"] = "\uD83D\uDC73\uD83C\uDFFF\u200D♀️", + [":woman_white_haired:"] = "\uD83D\uDC69\u200D\uD83E\uDDB3", + [":woman_white_haired::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB3", + [":woman_white_haired::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB3", + [":woman_white_haired::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB3", + [":woman_white_haired::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB3", + [":woman_white_haired::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB3", + [":woman_white_haired_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB3", + [":woman_white_haired_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB3", + [":woman_white_haired_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB3", + [":woman_white_haired_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB3", + [":woman_white_haired_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB3", + [":woman_white_haired_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB3", + [":woman_white_haired_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDB3", + [":woman_white_haired_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDB3", + [":woman_white_haired_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDB3", + [":woman_white_haired_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDB3", + [":woman_with_headscarf:"] = "\uD83E\uDDD5", + [":woman_with_headscarf::skin-tone-1:"] = "\uD83E\uDDD5\uD83C\uDFFB", + [":woman_with_headscarf::skin-tone-2:"] = "\uD83E\uDDD5\uD83C\uDFFC", + [":woman_with_headscarf::skin-tone-3:"] = "\uD83E\uDDD5\uD83C\uDFFD", + [":woman_with_headscarf::skin-tone-4:"] = "\uD83E\uDDD5\uD83C\uDFFE", + [":woman_with_headscarf::skin-tone-5:"] = "\uD83E\uDDD5\uD83C\uDFFF", + [":woman_with_headscarf_dark_skin_tone:"] = "\uD83E\uDDD5\uD83C\uDFFF", + [":woman_with_headscarf_light_skin_tone:"] = "\uD83E\uDDD5\uD83C\uDFFB", + [":woman_with_headscarf_medium_dark_skin_tone:"] = "\uD83E\uDDD5\uD83C\uDFFE", + [":woman_with_headscarf_medium_light_skin_tone:"] = "\uD83E\uDDD5\uD83C\uDFFC", + [":woman_with_headscarf_medium_skin_tone:"] = "\uD83E\uDDD5\uD83C\uDFFD", + [":woman_with_headscarf_tone1:"] = "\uD83E\uDDD5\uD83C\uDFFB", + [":woman_with_headscarf_tone2:"] = "\uD83E\uDDD5\uD83C\uDFFC", + [":woman_with_headscarf_tone3:"] = "\uD83E\uDDD5\uD83C\uDFFD", + [":woman_with_headscarf_tone4:"] = "\uD83E\uDDD5\uD83C\uDFFE", + [":woman_with_headscarf_tone5:"] = "\uD83E\uDDD5\uD83C\uDFFF", + [":woman_with_probing_cane:"] = "\uD83D\uDC69\u200D\uD83E\uDDAF", + [":woman_with_probing_cane::skin-tone-1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDAF", + [":woman_with_probing_cane::skin-tone-2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDAF", + [":woman_with_probing_cane::skin-tone-3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDAF", + [":woman_with_probing_cane::skin-tone-4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDAF", + [":woman_with_probing_cane::skin-tone-5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_medium_dark_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_medium_light_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_medium_skin_tone:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_tone1:"] = "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_tone2:"] = "\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_tone3:"] = "\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_tone4:"] = "\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDDAF", + [":woman_with_probing_cane_tone5:"] = "\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDDAF", + [":woman_with_veil:"] = "\uD83D\uDC70\u200D\u2640\uFE0F", + [":woman_with_veil::skin-tone-1:"] = "\uD83D\uDC70\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_with_veil::skin-tone-2:"] = "\uD83D\uDC70\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_with_veil::skin-tone-3:"] = "\uD83D\uDC70\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_with_veil::skin-tone-4:"] = "\uD83D\uDC70\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_with_veil::skin-tone-5:"] = "\uD83D\uDC70\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_with_veil_dark_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_with_veil_light_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_with_veil_medium_dark_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_with_veil_medium_light_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_with_veil_medium_skin_tone:"] = "\uD83D\uDC70\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_with_veil_tone1:"] = "\uD83D\uDC70\uD83C\uDFFB\u200D\u2640\uFE0F", + [":woman_with_veil_tone2:"] = "\uD83D\uDC70\uD83C\uDFFC\u200D\u2640\uFE0F", + [":woman_with_veil_tone3:"] = "\uD83D\uDC70\uD83C\uDFFD\u200D\u2640\uFE0F", + [":woman_with_veil_tone4:"] = "\uD83D\uDC70\uD83C\uDFFE\u200D\u2640\uFE0F", + [":woman_with_veil_tone5:"] = "\uD83D\uDC70\uD83C\uDFFF\u200D\u2640\uFE0F", + [":woman_zombie:"] = "\uD83E\uDDDF\u200D♀️", + [":womans_clothes:"] = "\uD83D\uDC5A", + [":womans_flat_shoe:"] = "\uD83E\uDD7F", + [":womans_hat:"] = "\uD83D\uDC52", + [":women_with_bunny_ears_partying:"] = "\uD83D\uDC6F\u200D♀️", + [":women_wrestling:"] = "\uD83E\uDD3C\u200D♀️", + [":womens:"] = "\uD83D\uDEBA", + [":wood:"] = "\uD83E\uDEB5", + [":woozy_face:"] = "\uD83E\uDD74", + [":world_map:"] = "\uD83D\uDDFA️", + [":worm:"] = "\uD83E\uDEB1", + [":worried:"] = "\uD83D\uDE1F", + [":worship_symbol:"] = "\uD83D\uDED0", + [":wrench:"] = "\uD83D\uDD27", + [":wrestlers:"] = "\uD83E\uDD3C", + [":wrestling:"] = "\uD83E\uDD3C", + [":writing_hand:"] = "✍️", + [":writing_hand::skin-tone-1:"] = "✍\uD83C\uDFFB", + [":writing_hand::skin-tone-2:"] = "✍\uD83C\uDFFC", + [":writing_hand::skin-tone-3:"] = "✍\uD83C\uDFFD", + [":writing_hand::skin-tone-4:"] = "✍\uD83C\uDFFE", + [":writing_hand::skin-tone-5:"] = "✍\uD83C\uDFFF", + [":writing_hand_tone1:"] = "✍\uD83C\uDFFB", + [":writing_hand_tone2:"] = "✍\uD83C\uDFFC", + [":writing_hand_tone3:"] = "✍\uD83C\uDFFD", + [":writing_hand_tone4:"] = "✍\uD83C\uDFFE", + [":writing_hand_tone5:"] = "✍\uD83C\uDFFF", + [":x:"] = "❌", + [":x_ray:"] = "\uD83E\uDE7B", + [":yarn:"] = "\uD83E\uDDF6", + [":yawning_face:"] = "\uD83E\uDD71", + [":yellow_circle:"] = "\uD83D\uDFE1", + [":yellow_heart:"] = "\uD83D\uDC9B", + [":yellow_square:"] = "\uD83D\uDFE8", + [":yen:"] = "\uD83D\uDCB4", + [":yin_yang:"] = "☯️", + [":yo_yo:"] = "\uD83E\uDE80", + [":yum:"] = "\uD83D\uDE0B", + [":z"] = "\uD83D\uDE12", + [":zany_face:"] = "\uD83E\uDD2A", + [":zap:"] = "⚡", + [":zebra:"] = "\uD83E\uDD93", + [":zero:"] = "0️⃣", + [":zipper_mouth:"] = "\uD83E\uDD10", + [":zipper_mouth_face:"] = "\uD83E\uDD10", + [":zombie:"] = "\uD83E\uDDDF", + [":zzz:"] = "\uD83D\uDCA4", + [":|"] = "\uD83D\uDE10", + [";("] = "\uD83D\uDE2D", + [";)"] = "\uD83D\uDE09", + [";-("] = "\uD83D\uDE2D", + [";-)"] = "\uD83D\uDE09", + [":("] = "\uD83D\uDE20", + [">:-("] = "\uD83D\uDE20", + [">=("] = "\uD83D\uDE20", + [">=-("] = "\uD83D\uDE20", + ["B-)"] = "\uD83D\uDE0E", + ["O:)"] = "\uD83D\uDE07", + ["O:-)"] = "\uD83D\uDE07", + ["O=)"] = "\uD83D\uDE07", + ["O=-)"] = "\uD83D\uDE07", + ["X-)"] = "\uD83D\uDE06", + ["]:("] = "\uD83D\uDC7F", + ["]:)"] = "\uD83D\uDE08", + ["]:-("] = "\uD83D\uDC7F", + ["]:-)"] = "\uD83D\uDE08", + ["]=("] = "\uD83D\uDC7F", + ["]=)"] = "\uD83D\uDE08", + ["]=-("] = "\uD83D\uDC7F", + ["]=-)"] = "\uD83D\uDE08", + ["o:)"] = "\uD83D\uDE07", + ["o:-)"] = "\uD83D\uDE07", + ["o=)"] = "\uD83D\uDE07", + ["o=-)"] = "\uD83D\uDE07", + ["x-)"] = "\uD83D\uDE06", + ["♡"] = "❤️" + }; + + private static IReadOnlyCollection _unicodes; + private static IReadOnlyCollection Unicodes + { + get + { + _unicodes ??= NamesAndUnicodes.Select(kvp => kvp.Value).ToImmutableHashSet(); + return _unicodes; + } + } + + private static IReadOnlyDictionary> _unicodesAndNames; + private static IReadOnlyDictionary> UnicodesAndNames + { + get + { + _unicodesAndNames ??= + NamesAndUnicodes + .GroupBy(kvp => kvp.Value) + .ToImmutableDictionary( + grouping => grouping.Key, + grouping => grouping.Select(kvp => kvp.Key) + .ToList() + .AsReadOnly() + ); + return _unicodesAndNames; + } + } + + public static implicit operator Emoji(string s) => Parse(s); + } +} diff --git a/src/Discord.Net.Core/Entities/Emotes/Emote.cs b/src/Discord.Net.Core/Entities/Emotes/Emote.cs new file mode 100644 index 0000000..7152973 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Emotes/Emote.cs @@ -0,0 +1,115 @@ +using System; +using System.Diagnostics; +using System.Globalization; + +namespace Discord +{ + /// + /// A custom image-based emote. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class Emote : IEmote, ISnowflakeEntity + { + /// + public string Name { get; } + /// + public ulong Id { get; } + /// + /// Gets whether this emote is animated. + /// + /// + /// A boolean that determines whether or not this emote is an animated one. + /// + public bool Animated { get; } + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + /// Gets the image URL of this emote. + /// + /// + /// A string that points to the URL of this emote. + /// + public string Url => CDN.GetEmojiUrl(Id, Animated); + + internal Emote(ulong id, string name, bool animated) + { + Id = id; + Name = name; + Animated = animated; + } + + /// + /// Determines whether the specified emote is equal to the current emote. + /// + /// The object to compare with the current object. + public override bool Equals(object other) + { + if (other == null) + return false; + if (other == this) + return true; + + var otherEmote = other as Emote; + if (otherEmote == null) + return false; + + return Id == otherEmote.Id; + } + + /// + public override int GetHashCode() + => Id.GetHashCode(); + + /// Parses an from its raw format. + /// The raw encoding of an emote (e.g. <:dab:277855270321782784>). + /// An emote. + /// Invalid emote format. + public static Emote Parse(string text) + { + if (TryParse(text, out Emote result)) + return result; + throw new ArgumentException(message: "Invalid emote format.", paramName: nameof(text)); + } + + /// Tries to parse an from its raw format. + /// The raw encoding of an emote; for example, <:dab:277855270321782784>. + /// An emote. + public static bool TryParse(string text, out Emote result) + { + result = null; + + if (text == null) + return false; + + if (text.Length >= 4 && text[0] == '<' && (text[1] == ':' || (text[1] == 'a' && text[2] == ':')) && text[text.Length - 1] == '>') + { + bool animated = text[1] == 'a'; + int startIndex = animated ? 3 : 2; + + int splitIndex = text.IndexOf(':', startIndex); + if (splitIndex == -1) + return false; + + if (!ulong.TryParse(text.Substring(splitIndex + 1, text.Length - splitIndex - 2), NumberStyles.None, CultureInfo.InvariantCulture, out ulong id)) + return false; + + string name = text.Substring(startIndex, splitIndex - startIndex); + result = new Emote(id, name, animated); + return true; + } + return false; + + } + + private string DebuggerDisplay => $"{Name} ({Id})"; + /// + /// Returns the raw representation of the emote. + /// + /// + /// A string representing the raw presentation of the emote (e.g. <:thonkang:282745590985523200>). + /// + public override string ToString() => $"<{(Animated ? "a" : "")}:{Name}:{Id}>"; + + public static implicit operator Emote(string s) => Parse(s); + } +} diff --git a/src/Discord.Net.Core/Entities/Emotes/EmoteProperties.cs b/src/Discord.Net.Core/Entities/Emotes/EmoteProperties.cs new file mode 100644 index 0000000..41679d2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Emotes/EmoteProperties.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Provides properties that are used to modify an with the specified changes. + /// + /// + public class EmoteProperties + { + /// + /// Gets or sets the name of the . + /// + public Optional Name { get; set; } + /// + /// Gets or sets the roles that can access this . + /// + public Optional> Roles { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Emotes/GuildEmote.cs b/src/Discord.Net.Core/Entities/Emotes/GuildEmote.cs new file mode 100644 index 0000000..47ec3a4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Emotes/GuildEmote.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord +{ + /// + /// An image-based emote that is attached to a guild. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class GuildEmote : Emote + { + /// + /// Gets whether this emoji is managed by an integration. + /// + /// + /// A boolean that determines whether or not this emote is managed by a Twitch integration. + /// + public bool IsManaged { get; } + /// + /// Gets whether this emoji must be wrapped in colons. + /// + /// + /// A boolean that determines whether or not this emote requires the use of colons in chat to be used. + /// + public bool RequireColons { get; } + /// + /// Gets the roles that are allowed to use this emoji. + /// + /// + /// A read-only list containing snowflake identifiers for roles that are allowed to use this emoji. + /// + public IReadOnlyList RoleIds { get; } + /// + /// Gets the user ID associated with the creation of this emoji. + /// + /// + /// An snowflake identifier representing the user who created this emoji; + /// if unknown. + /// + public ulong? CreatorId { get; } + + internal GuildEmote(ulong id, string name, bool animated, bool isManaged, bool requireColons, IReadOnlyList roleIds, ulong? userId) : base(id, name, animated) + { + IsManaged = isManaged; + RequireColons = requireColons; + RoleIds = roleIds; + CreatorId = userId; + } + + private string DebuggerDisplay => $"{Name} ({Id})"; + /// + /// Gets the raw representation of the emote. + /// + /// + /// A string representing the raw presentation of the emote (e.g. <:thonkang:282745590985523200>). + /// + public override string ToString() => $"<{(Animated ? "a" : "")}:{Name}:{Id}>"; + } +} diff --git a/src/Discord.Net.Core/Entities/Emotes/IEmote.cs b/src/Discord.Net.Core/Entities/Emotes/IEmote.cs new file mode 100644 index 0000000..9141e85 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Emotes/IEmote.cs @@ -0,0 +1,16 @@ +namespace Discord +{ + /// + /// Represents a general container for any type of emote in a message. + /// + public interface IEmote + { + /// + /// Gets the display name or Unicode representation of this emote. + /// + /// + /// A string representing the display name or the Unicode representation (e.g. 🤔) of this emote. + /// + string Name { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/ForumTags/ForumTag.cs b/src/Discord.Net.Core/Entities/ForumTags/ForumTag.cs new file mode 100644 index 0000000..87d306d --- /dev/null +++ b/src/Discord.Net.Core/Entities/ForumTags/ForumTag.cs @@ -0,0 +1,62 @@ +using System; +#nullable enable + +namespace Discord; + +/// +/// A struct representing a forum channel tag. +/// +public readonly struct ForumTag : ISnowflakeEntity, IForumTag +{ + /// + public ulong Id { get; } + + /// + public string Name { get; } + + /// + public IEmote? Emoji { get; } + + /// + public bool IsModerated { get; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + internal ForumTag(ulong id, string name, ulong? emojiId = null, string? emojiName = null, bool moderated = false) + { + if (emojiId.HasValue && emojiId.Value != 0) + Emoji = new Emote(emojiId.Value, null, false); + else if (emojiName != null) + Emoji = new Emoji(emojiName); + else + Emoji = null; + + Id = id; + Name = name; + IsModerated = moderated; + } + + public override int GetHashCode() => (Id, Name, Emoji, IsModerated).GetHashCode(); + + public override bool Equals(object? obj) + => obj is ForumTag tag && Equals(tag); + + /// + /// Gets whether supplied tag is equals to the current one. + /// + public bool Equals(ForumTag tag) + => Id == tag.Id && + Name == tag.Name && + (Emoji is Emoji emoji && tag.Emoji is Emoji otherEmoji && emoji.Equals(otherEmoji) || + Emoji is Emote emote && tag.Emoji is Emote otherEmote && emote.Equals(otherEmote)) && + IsModerated == tag.IsModerated; + + public static bool operator ==(ForumTag? left, ForumTag? right) + => left?.Equals(right) ?? right is null; + + public static bool operator !=(ForumTag? left, ForumTag? right) => !(left == right); + + /// + readonly ulong? IForumTag.Id => Id; +} diff --git a/src/Discord.Net.Core/Entities/ForumTags/ForumTagBuilder.cs b/src/Discord.Net.Core/Entities/ForumTags/ForumTagBuilder.cs new file mode 100644 index 0000000..f0a2188 --- /dev/null +++ b/src/Discord.Net.Core/Entities/ForumTags/ForumTagBuilder.cs @@ -0,0 +1,191 @@ +#nullable enable +using System; + +namespace Discord; + +public class ForumTagBuilder +{ + private string? _name; + private IEmote? _emoji; + private bool _moderated; + private ulong? _id; + + /// + /// Returns the maximum length of name allowed by Discord. + /// + public const int MaxNameLength = 20; + + /// + /// Gets or sets the snowflake Id of the tag. + /// + /// + /// If set this will update existing tag or will create a new one otherwise. + /// + public ulong? Id + { + get { return _id; } + set { _id = value; } + } + + /// + /// Gets or sets the name of the tag. + /// + /// Name length must be less than or equal to . + public string? Name + { + get { return _name; } + set + { + if (value?.Length > MaxNameLength) + throw new ArgumentException(message: $"Name length must be less than or equal to {MaxNameLength}.", paramName: nameof(Name)); + _name = value; + } + } + + /// + /// Gets or sets the emoji of the tag. + /// + public IEmote? Emoji + { + get { return _emoji; } + set { _emoji = value; } + } + + /// + /// Gets or sets whether this tag can only be added to or removed from threads by a member + /// with the permission + /// + public bool IsModerated + { + get { return _moderated; } + set { _moderated = value; } + } + + /// + /// Initializes a new class. + /// + public ForumTagBuilder() + { + + } + + /// + /// Initializes a new class with values + /// + /// If set existing tag will be updated or a new one will be created otherwise. + /// Name of the tag. + /// Sets whether this tag can only be added to or removed from threads by a member + /// with the permission. + public ForumTagBuilder(string name, ulong? id = null, bool isModerated = false) + { + Name = name; + IsModerated = isModerated; + Id = id; + } + + /// + /// Initializes a new class with values + /// + /// Name of the tag. + /// If set existing tag will be updated or a new one will be created otherwise. + /// Display emoji of the tag. + /// Sets whether this tag can only be added to or removed from threads by a member + /// with the permission. + public ForumTagBuilder(string name, ulong? id = null, bool isModerated = false, IEmote? emoji = null) + { + Name = name; + Emoji = emoji; + IsModerated = isModerated; + Id = id; + } + + /// + /// Initializes a new class with values + /// + /// /// Name of the tag. + /// If set existing tag will be updated or a new one will be created otherwise. + /// The id of custom Display emoji of the tag. + /// Sets whether this tag can only be added to or removed from threads by a member + /// with the permission + public ForumTagBuilder(string name, ulong? id = null, bool isModerated = false, ulong? emoteId = null) + { + Name = name; + if (emoteId is not null) + Emoji = new Emote(emoteId.Value, null, false); + IsModerated = isModerated; + Id = id; + } + + /// + /// Builds the Tag. + /// + /// An instance of + /// "Name must be set to build the tag" + public ForumTagProperties Build() + { + if (_name is null) + throw new ArgumentNullException(nameof(Name), "Name must be set to build the tag."); + return new ForumTagProperties(_id, _name, _emoji, _moderated); + } + + /// + /// Sets the name of the tag. + /// + /// Name length must be less than or equal to . + public ForumTagBuilder WithName(string name) + { + Name = name; + return this; + } + + /// + /// Sets the id of the tag. + /// + /// If set existing tag will be updated or a new one will be created otherwise. + /// Name length must be less than or equal to . + public ForumTagBuilder WithId(ulong? id) + { + Id = id; + return this; + } + + /// + /// Sets the emoji of the tag. + /// + public ForumTagBuilder WithEmoji(IEmote? emoji) + { + Emoji = emoji; + return this; + } + + /// + /// Sets whether this tag can only be added to or removed from threads by a member + /// with the permission + /// + public ForumTagBuilder WithModerated(bool moderated) + { + IsModerated = moderated; + return this; + } + + public override int GetHashCode() => base.GetHashCode(); + + public override bool Equals(object? obj) + => obj is ForumTagBuilder builder && Equals(builder); + + /// + /// Gets whether supplied tag builder is equals to the current one. + /// + public bool Equals(ForumTagBuilder? builder) + => builder is not null && + Id == builder.Id && + Name == builder.Name && + (Emoji is Emoji emoji && builder.Emoji is Emoji otherEmoji && emoji.Equals(otherEmoji) || + Emoji is Emote emote && builder.Emoji is Emote otherEmote && emote.Equals(otherEmote)) && + IsModerated == builder.IsModerated; + + public static bool operator ==(ForumTagBuilder? left, ForumTagBuilder? right) + => left?.Equals(right) ?? right is null; + + public static bool operator !=(ForumTagBuilder? left, ForumTagBuilder? right) => !(left == right); +} diff --git a/src/Discord.Net.Core/Entities/ForumTags/ForumTagBuilderExtensions.cs b/src/Discord.Net.Core/Entities/ForumTags/ForumTagBuilderExtensions.cs new file mode 100644 index 0000000..73a953f --- /dev/null +++ b/src/Discord.Net.Core/Entities/ForumTags/ForumTagBuilderExtensions.cs @@ -0,0 +1,11 @@ +namespace Discord; + +public static class ForumTagBuilderExtensions +{ + public static ForumTagBuilder ToForumTagBuilder(this ForumTag tag) + => new ForumTagBuilder(tag.Name, tag.Id, tag.IsModerated, tag.Emoji); + + public static ForumTagBuilder ToForumTagBuilder(this ForumTagProperties tag) + => new ForumTagBuilder(tag.Name, tag.Id, tag.IsModerated, tag.Emoji); + +} diff --git a/src/Discord.Net.Core/Entities/ForumTags/ForumTagProperties.cs b/src/Discord.Net.Core/Entities/ForumTags/ForumTagProperties.cs new file mode 100644 index 0000000..3dd7bda --- /dev/null +++ b/src/Discord.Net.Core/Entities/ForumTags/ForumTagProperties.cs @@ -0,0 +1,49 @@ +namespace Discord; + +#nullable enable + +public class ForumTagProperties : IForumTag +{ + /// + /// Gets the Id of the tag. + /// + public ulong? Id { get; } + + /// + public string Name { get; } + + /// + public IEmote? Emoji { get; } + + /// + public bool IsModerated { get; } + + internal ForumTagProperties(ulong? id, string name, IEmote? emoji = null, bool isModerated = false) + { + Id = id; + Name = name; + Emoji = emoji; + IsModerated = isModerated; + } + + public override int GetHashCode() => (Id, Name, Emoji, IsModerated).GetHashCode(); + + public override bool Equals(object? obj) + => obj is ForumTagProperties tag && Equals(tag); + + /// + /// Gets whether supplied tag is equals to the current one. + /// + public bool Equals(ForumTagProperties? tag) + => tag is not null && + Id == tag.Id && + Name == tag.Name && + (Emoji is Emoji emoji && tag.Emoji is Emoji otherEmoji && emoji.Equals(otherEmoji) || + Emoji is Emote emote && tag.Emoji is Emote otherEmote && emote.Equals(otherEmote)) && + IsModerated == tag.IsModerated; + + public static bool operator ==(ForumTagProperties? left, ForumTagProperties? right) + => left?.Equals(right) ?? right is null; + + public static bool operator !=(ForumTagProperties? left, ForumTagProperties? right) => !(left == right); +} diff --git a/src/Discord.Net.Core/Entities/ForumTags/IForumTag.cs b/src/Discord.Net.Core/Entities/ForumTags/IForumTag.cs new file mode 100644 index 0000000..4e10ff3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/ForumTags/IForumTag.cs @@ -0,0 +1,37 @@ +namespace Discord; + +#nullable enable + +/// +/// Represents a Discord forum tag +/// +public interface IForumTag +{ + /// + /// Gets the Id of the tag. + /// + /// + /// This property may be if the object is . + /// + ulong? Id { get; } + + /// + /// Gets the name of the tag. + /// + string Name { get; } + + /// + /// Gets the emoji of the tag or if none is set. + /// + /// + /// If the emoji is only the will be populated. + /// Use to get the emoji. + /// + IEmote? Emoji { get; } + + /// + /// Gets whether this tag can only be added to or removed from threads by a member + /// with the permission + /// + bool IsModerated { get; } +} diff --git a/src/Discord.Net.Core/Entities/Gateway/BotGateway.cs b/src/Discord.Net.Core/Entities/Gateway/BotGateway.cs new file mode 100644 index 0000000..c9be0ac --- /dev/null +++ b/src/Discord.Net.Core/Entities/Gateway/BotGateway.cs @@ -0,0 +1,22 @@ +namespace Discord +{ + /// + /// Stores the gateway information related to the current bot. + /// + public class BotGateway + { + /// + /// Gets the WSS URL that can be used for connecting to the gateway. + /// + public string Url { get; internal set; } + /// + /// Gets the recommended number of shards to use when connecting. + /// + public int Shards { get; internal set; } + /// + /// Gets the that contains the information + /// about the current session start limit. + /// + public SessionStartLimit SessionStartLimit { get; internal set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Gateway/SessionStartLimit.cs b/src/Discord.Net.Core/Entities/Gateway/SessionStartLimit.cs new file mode 100644 index 0000000..74ae96a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Gateway/SessionStartLimit.cs @@ -0,0 +1,38 @@ +namespace Discord +{ + /// + /// Stores the information related to the gateway identify request. + /// + public class SessionStartLimit + { + /// + /// Gets the total number of session starts the current user is allowed. + /// + /// + /// The maximum amount of session starts the current user is allowed. + /// + public int Total { get; internal set; } + /// + /// Gets the remaining number of session starts the current user is allowed. + /// + /// + /// The remaining amount of session starts the current user is allowed. + /// + public int Remaining { get; internal set; } + /// + /// Gets the number of milliseconds after which the limit resets. + /// + /// + /// The milliseconds until the limit resets back to the . + /// + public int ResetAfter { get; internal set; } + /// + /// Gets the maximum concurrent identify requests in a time window. + /// + /// + /// The maximum concurrent identify requests in a time window, + /// limited to the same rate limit key. + /// + public int MaxConcurrency { get; internal set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModActionType.cs b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModActionType.cs new file mode 100644 index 0000000..cf84e72 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModActionType.cs @@ -0,0 +1,24 @@ +namespace Discord; + +public enum AutoModActionType +{ + /// + /// Blocks the content of a message according to the rule. + /// + BlockMessage = 1, + + /// + /// Logs user content to a specified channel. + /// + SendAlertMessage = 2, + + /// + /// Timeout user for a specified duration. + /// + Timeout = 3, + + /// + /// Prevents a member from using text, voice, or other interactions. + /// + BlockMemberInteraction = 4 +} diff --git a/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModEventType.cs b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModEventType.cs new file mode 100644 index 0000000..436f90e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModEventType.cs @@ -0,0 +1,17 @@ +namespace Discord; + +/// +/// An enum indecating in what event context a rule should be checked. +/// +public enum AutoModEventType +{ + /// + /// When a member sends or edits a message in the guild. + /// + MessageSend = 1, + + /// + /// When a member edits their profile. + /// + MemberUpdate = 2 +} diff --git a/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModRuleAction.cs b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModRuleAction.cs new file mode 100644 index 0000000..47f8f07 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModRuleAction.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents an action that will be preformed if a user breaks an . + /// + public class AutoModRuleAction + { + /// + /// Gets the type for this action. + /// + public AutoModActionType Type { get; } + + /// + /// Get the channel id on which to post alerts. if no channel has been provided. + /// + public ulong? ChannelId { get; } + + /// + /// Gets the custom message that will be shown to members whenever their message is blocked. + /// if no message has been set. + /// + public Optional CustomMessage { get; set; } + + /// + /// Gets the duration of which a user will be timed out for breaking this rule. if no timeout duration has been provided. + /// + public TimeSpan? TimeoutDuration { get; } + + internal AutoModRuleAction(AutoModActionType type, ulong? channelId, int? duration, string customMessage) + { + Type = type; + ChannelId = channelId; + TimeoutDuration = duration.HasValue ? TimeSpan.FromSeconds(duration.Value) : null; + CustomMessage = customMessage; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModRuleProperties.cs b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModRuleProperties.cs new file mode 100644 index 0000000..d4e86f0 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModRuleProperties.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Provides properties used to modify a . + /// + public class AutoModRuleProperties + { + /// + /// Returns the max keyword count for an AutoMod rule allowed by Discord. + /// + public const int MaxKeywordCount = 1000; + + /// + /// Returns the max keyword length for an AutoMod rule allowed by Discord. + /// + public const int MaxKeywordLength = 60; + + /// + /// Returns the max regex pattern count for an AutoMod rule allowed by Discord. + /// + public const int MaxRegexPatternCount = 10; + + /// + /// Returns the max regex pattern length for an AutoMod rule allowed by Discord. + /// + public const int MaxRegexPatternLength = 260; + + /// + /// Returns the max allowlist keyword count for a AutoMod rule allowed by Discord. + /// + public const int MaxAllowListCountKeyword = 100; + + /// + /// Returns the max allowlist keyword count for a AutoMod rule allowed by Discord. + /// + public const int MaxAllowListCountKeywordPreset = 1000; + + /// + /// Returns the max allowlist keyword length for an AutoMod rule allowed by Discord. + /// + public const int MaxAllowListEntryLength = 60; + + /// + /// Returns the max mention limit for an AutoMod rule allowed by Discord. + /// + public const int MaxMentionLimit = 50; + + /// + /// Returns the max exempt role count for an AutoMod rule allowed by Discord. + /// + public const int MaxExemptRoles = 20; + + /// + /// Returns the max exempt channel count for an AutoMod rule allowed by Discord. + /// + public const int MaxExemptChannels = 50; + + /// + /// Returns the max timeout duration in seconds for an auto moderation rule action. + /// + public const int MaxTimeoutSeconds = 2419200; + + /// + /// Returns the max custom message length AutoMod rule action allowed by Discord. + /// + public const int MaxCustomBlockMessageLength = 50; + + + /// + /// Gets or sets the name for the rule. + /// + public Optional Name { get; set; } + + /// + /// Gets or sets the event type for the rule. + /// + public Optional EventType { get; set; } + + /// + /// Gets or sets the trigger type for the rule. + /// + public Optional TriggerType { get; set; } + + /// + /// Gets or sets the keyword filter for the rule. + /// + public Optional KeywordFilter { get; set; } + + /// + /// Gets or sets regex patterns for the rule. + /// + public Optional RegexPatterns { get; set; } + + /// + /// Gets or sets the allow list for the rule. + /// + public Optional AllowList { get; set; } + + /// + /// Gets or sets total mention limit for the rule. + /// + public Optional MentionLimit { get; set; } + + /// + /// Gets or sets the presets for the rule. Empty if the rule has no presets. + /// + public Optional Presets { get; set; } + + /// + /// Gets or sets the actions for the rule. + /// + public Optional Actions { get; set; } + + /// + /// Gets or sets whether or not the rule is enabled. + /// + public Optional Enabled { get; set; } + + /// + /// Gets or sets the exempt roles for the rule. Empty if the rule has no exempt roles. + /// + public Optional ExemptRoles { get; set; } + + /// + /// Gets or sets the exempt channels for the rule. Empty if the rule has no exempt channels. + /// + public Optional ExemptChannels { get; set; } + } + + /// + /// Provides properties used to modify a . + /// + public class AutoModRuleActionProperties + { + /// + /// Gets or sets the type for this action. + /// + public AutoModActionType Type { get; set; } + + /// + /// Get or sets the channel id on which to post alerts. + /// + public ulong? ChannelId { get; set; } + + /// + /// Gets or sets the duration of which a user will be timed out for breaking this rule. + /// + public TimeSpan? TimeoutDuration { get; set; } + + /// + /// Gets or sets the custom message that will be shown to members whenever their message is blocked. + /// + public Optional CustomMessage { get; set; } + } + +} diff --git a/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModTriggerType.cs b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModTriggerType.cs new file mode 100644 index 0000000..fb25bab --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/AutoModTriggerType.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// An enum representing the type of content which can trigger the rule. + /// + public enum AutoModTriggerType + { + /// + /// Check if content contains words from a user defined list of keywords. + /// + Keyword = 1, + + /// + /// Check if content contains any harmful links. + /// + HarmfulLink = 2, + + /// + /// Check if content represents generic spam. + /// + Spam = 3, + + /// + /// Check if content contains words from internal pre-defined wordsets. + /// + KeywordPreset = 4, + + /// + /// Check if content contains more unique mentions than allowed. + /// + MentionSpam = 5, + + /// + /// Check if member profile contains words from a user defined list of keywords. + /// + MemberProfile = 6 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/AutoModeration/IAutoModRule.cs b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/IAutoModRule.cs new file mode 100644 index 0000000..c87ecf3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/IAutoModRule.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a auto mod rule within a guild. + /// + public interface IAutoModRule : ISnowflakeEntity, IDeletable + { + /// + /// Gets the guild id on which this rule exists. + /// + ulong GuildId { get; } + + /// + /// Get the name of this rule. + /// + string Name { get; } + + /// + /// Gets the id of the user who created this use. + /// + ulong CreatorId { get; } + + /// + /// Gets the event type on which this rule is triggered. + /// + AutoModEventType EventType { get; } + + /// + /// Gets the trigger type on which this rule executes. + /// + AutoModTriggerType TriggerType { get; } + + /// + /// Gets the keyword filter for this rule. + /// + /// + /// This collection will be empty if is not + /// . + /// + public IReadOnlyCollection KeywordFilter { get; } + + /// + /// Gets regex patterns for this rule. Empty if the rule has no regexes. + /// + /// + /// This collection will be empty if is not + /// . + /// + public IReadOnlyCollection RegexPatterns { get; } + + /// + /// Gets the allow list patterns for this rule. Empty if the rule has no allowed terms. + /// + /// + /// This collection will be empty if is not + /// . + /// + public IReadOnlyCollection AllowList { get; } + + /// + /// Gets the preset keyword types for this rule. Empty if the rule has no presets. + /// + /// + /// This collection will be empty if is not + /// . + /// + public IReadOnlyCollection Presets { get; } + + /// + /// Gets the total mention limit for this rule. + /// + /// + /// This property will be if is not + /// . + /// + public int? MentionTotalLimit { get; } + + /// + /// Gets a collection of actions that will be preformed if a user breaks this rule. + /// + IReadOnlyCollection Actions { get; } + + /// + /// Gets whether or not this rule is enabled. + /// + bool Enabled { get; } + + /// + /// Gets a collection of role ids that are exempt from this rule. Empty if the rule has no exempt roles. + /// + IReadOnlyCollection ExemptRoles { get; } + + /// + /// Gets a collection of channel ids that are exempt from this rule. Empty if the rule has no exempt channels. + /// + IReadOnlyCollection ExemptChannels { get; } + + /// + /// Modifies this rule. + /// + /// The delegate containing the properties to modify the rule with. + /// The options to be used when sending the request. + Task ModifyAsync(Action func, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/AutoModeration/KeywordPresetTypes.cs b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/KeywordPresetTypes.cs new file mode 100644 index 0000000..a399cdc --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/AutoModeration/KeywordPresetTypes.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// An enum representing preset filter types. + /// + public enum KeywordPresetTypes + { + /// + /// Words that may be considered forms of swearing or cursing. + /// + Profanity = 1, + + /// + /// Words that refer to sexually explicit behavior or activity. + /// + SexualContent = 2, + + /// + /// Personal insults or words that may be considered hate speech. + /// + Slurs = 3, + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/BulkBanResult.cs b/src/Discord.Net.Core/Entities/Guilds/BulkBanResult.cs new file mode 100644 index 0000000..8e702f0 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/BulkBanResult.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents a result of a bulk ban. +/// +public readonly struct BulkBanResult +{ + /// + /// Gets the collection of user IDs that were successfully banned. + /// + public IReadOnlyCollection BannedUsers { get; } + + /// + /// Gets the collection of user IDs that failed to be banned. + /// + public IReadOnlyCollection FailedUsers { get; } + + internal BulkBanResult(IReadOnlyCollection bannedUsers, IReadOnlyCollection failedUsers) + { + BannedUsers = bannedUsers; + FailedUsers = failedUsers; + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/DefaultMessageNotifications.cs b/src/Discord.Net.Core/Entities/Guilds/DefaultMessageNotifications.cs new file mode 100644 index 0000000..ffcd28c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/DefaultMessageNotifications.cs @@ -0,0 +1,17 @@ +namespace Discord +{ + /// + /// Specifies the default message notification behavior the guild uses. + /// + public enum DefaultMessageNotifications + { + /// + /// By default, all messages will trigger notifications. + /// + AllMessages = 0, + /// + /// By default, only mentions will trigger notifications. + /// + MentionsOnly = 1 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/ExplicitContentFilterLevel.cs b/src/Discord.Net.Core/Entities/Guilds/ExplicitContentFilterLevel.cs new file mode 100644 index 0000000..19057b9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/ExplicitContentFilterLevel.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + public enum ExplicitContentFilterLevel + { + /// No messages will be scanned. + Disabled = 0, + /// Scans messages from all guild members that do not have a role. + /// Recommended option for servers that use roles for trusted membership. + MembersWithoutRoles = 1, + /// Scan messages sent by all guild members. + AllMembers = 2 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildFeature.cs b/src/Discord.Net.Core/Entities/Guilds/GuildFeature.cs new file mode 100644 index 0000000..eab0064 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildFeature.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + [Flags] + public enum GuildFeature : long + { + /// + /// The guild has no features. + /// + None = 0L, + + /// + /// The guild has access to animated banners. + /// + AnimatedBanner = 1L << 0, + + /// + /// The guild has access to set an animated guild icon. + /// + AnimatedIcon = 1L << 1, + + /// + /// The guild has access to set a guild banner image. + /// + Banner = 1L << 2, + + /// + /// The guild has access to channel banners. + /// + ChannelBanner = 1L << 3, + + /// + /// The guild has access to use commerce features (i.e. create store channels). + /// + Commerce = 1L << 4, + + /// + /// The guild can enable welcome screen, Membership Screening, stage channels and discovery, and receives community updates. + /// + /// + /// This feature is mutable. + /// + Community = 1L << 5, + + /// + /// The guild is able to be discovered in the directory. + /// + /// + /// This feature is mutable. + /// + Discoverable = 1L << 6, + + /// + /// The guild has discoverable disabled. + /// + DiscoverableDisabled = 1L << 7, + + /// + /// The guild has enabled discoverable before. + /// + EnabledDiscoverableBefore = 1L << 8, + + /// + /// The guild is able to be featured in the directory. + /// + Featureable = 1L << 9, + + /// + /// The guild has a force relay. + /// + ForceRelay = 1L << 10, + + /// + /// The guild has a directory entry. + /// + HasDirectoryEntry = 1L << 11, + + /// + /// The guild is a hub. + /// + Hub = 1L << 12, + + /// + /// You shouldn't be here... + /// + InternalEmployeeOnly = 1L << 13, + + /// + /// The guild has access to set an invite splash background. + /// + InviteSplash = 1L << 14, + + /// + /// The guild is linked to a hub. + /// + LinkedToHub = 1L << 15, + + /// + /// The guild has member profiles. + /// + MemberProfiles = 1L << 16, + + /// + /// The guild has enabled Membership Screening. + /// + MemberVerificationGateEnabled = 1L << 17, + + /// + /// The guild has enabled monetization. + /// + MonetizationEnabled = 1L << 18, + + /// + /// The guild has more emojis. + /// + MoreEmoji = 1L << 19, + + /// + /// The guild has increased custom sticker slots. + /// + MoreStickers = 1L << 20, + + /// + /// The guild has access to create news channels. + /// + News = 1L << 21, + + /// + /// The guild has new thread permissions. + /// + NewThreadPermissions = 1L << 22, + + /// + /// The guild is partnered. + /// + Partnered = 1L << 23, + + /// + /// The guild has a premium tier three override; guilds made by Discord usually have this. + /// + PremiumTier3Override = 1L << 24, + + /// + /// The guild can be previewed before joining via Membership Screening or the directory. + /// + PreviewEnabled = 1L << 25, + + /// + /// The guild has access to create private threads. + /// + PrivateThreads = 1L << 26, + + /// + /// The guild has relay enabled. + /// + RelayEnabled = 1L << 27, + + /// + /// The guild is able to set role icons. + /// + RoleIcons = 1L << 28, + + /// + /// The guild has role subscriptions available for purchase. + /// + RoleSubscriptionsAvailableForPurchase = 1L << 29, + + /// + /// The guild has role subscriptions enabled. + /// + RoleSubscriptionsEnabled = 1L << 30, + + /// + /// The guild has access to the seven day archive time for threads. + /// + SevenDayThreadArchive = 1L << 31, + + /// + /// The guild has text in voice enabled. + /// + TextInVoiceEnabled = 1L << 32, + + /// + /// The guild has threads enabled. + /// + ThreadsEnabled = 1L << 33, + + /// + /// The guild has testing threads enabled. + /// + ThreadsEnabledTesting = 1L << 34, + + /// + /// The guild has the default thread auto archive. + /// + ThreadsDefaultAutoArchiveDuration = 1L << 35, + + /// + /// The guild has access to the three day archive time for threads. + /// + ThreeDayThreadArchive = 1L << 36, + + /// + /// The guild has enabled ticketed events. + /// + TicketedEventsEnabled = 1L << 37, + + /// + /// The guild has access to set a vanity URL. + /// + VanityUrl = 1L << 38, + + /// + /// The guild is verified. + /// + Verified = 1L << 39, + + /// + /// The guild has access to set 384kbps bitrate in voice (previously VIP voice servers). + /// + VIPRegions = 1L << 40, + + /// + /// The guild has enabled the welcome screen. + /// + WelcomeScreenEnabled = 1L << 41, + + /// + /// The guild has been set as a support server on the App Directory. + /// + DeveloperSupportServer = 1L << 42, + + /// + /// The guild has invites disabled. + /// + /// + /// This feature is mutable. + /// + InvitesDisabled = 1L << 43, + + /// + /// The guild has auto moderation enabled. + /// + AutoModeration = 1L << 44, + + /// + /// This guild has alerts for join raids disabled. + /// + /// + /// This feature is mutable. + /// + RaidAlertsDisabled = 1L << 45, + + /// + /// This guild has Clyde AI enabled. + /// + /// + /// This feature is mutable. + /// + ClydeEnabled = 1L << 46, + + /// + /// This guild has a guild web page vanity url. + /// + GuildWebPageVanityUrl = 1L << 47 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildFeatures.cs b/src/Discord.Net.Core/Entities/Guilds/GuildFeatures.cs new file mode 100644 index 0000000..b553a83 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildFeatures.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public class GuildFeatures + { + /// + /// Gets the flags of recognized features for this guild. + /// + public GuildFeature Value { get; } + + /// + /// Gets a collection of experimental features for this guild. Features that are not contained in are put in here. + /// + public IReadOnlyCollection Experimental { get; } + + /// + /// Gets whether or not the guild has threads enabled. + /// + public bool HasThreads + => HasFeature(GuildFeature.ThreadsEnabled | GuildFeature.ThreadsEnabledTesting); + + /// + /// Gets whether or not the guild has text-in-voice enabled. + /// + public bool HasTextInVoice + => HasFeature(GuildFeature.TextInVoiceEnabled); + + /// + /// Gets whether or not the server is a internal staff server. + /// + /// + /// You shouldn't touch anything here unless you know what you're doing :) + /// + public bool IsStaffServer + => HasFeature(GuildFeature.InternalEmployeeOnly); + + /// + /// Gets whether or not this server is a hub. + /// + public bool IsHub + => HasFeature(GuildFeature.Hub); + + /// + /// Gets whether or this server is linked to a hub server. + /// + public bool IsLinkedToHub + => HasFeature(GuildFeature.LinkedToHub); + + /// + /// Gets whether or not this server is partnered. + /// + public bool IsPartnered + => HasFeature(GuildFeature.Partnered); + + /// + /// Gets whether or not this server is verified. + /// + public bool IsVerified + => HasFeature(GuildFeature.Verified); + + /// + /// Gets whether or not this server has vanity urls enabled. + /// + public bool HasVanityUrl + => HasFeature(GuildFeature.VanityUrl); + + /// + /// Gets whether or not this server has role subscriptions enabled. + /// + public bool HasRoleSubscriptions + => HasFeature(GuildFeature.RoleSubscriptionsEnabled | GuildFeature.RoleSubscriptionsAvailableForPurchase); + + /// + /// Gets whether or not this server has role icons enabled. + /// + public bool HasRoleIcons + => HasFeature(GuildFeature.RoleIcons); + + /// + /// Gets whether or not this server has private threads enabled. + /// + public bool HasPrivateThreads + => HasFeature(GuildFeature.PrivateThreads); + + internal GuildFeatures(GuildFeature value, string[] experimental) + { + Value = value; + Experimental = experimental.ToImmutableArray(); + } + + /// + /// Returns whether or not this guild has a feature. + /// + /// The feature(s) to check for. + /// if this guild has the provided feature(s), otherwise . + public bool HasFeature(GuildFeature feature) + => Value.HasFlag(feature); + + /// + /// Returns whether or not this guild has a feature. + /// + /// The feature to check for. + /// if this guild has the provided feature, otherwise . + public bool HasFeature(string feature) + => Experimental.Contains(feature); + + internal void EnsureFeature(GuildFeature feature) + { + if (!HasFeature(feature)) + { + var vals = Enum.GetValues(typeof(GuildFeature)).Cast(); + + var missingValues = vals.Where(x => feature.HasFlag(x) && !Value.HasFlag(x)); + + throw new InvalidOperationException($"Missing required guild feature{(missingValues.Count() > 1 ? "s" : "")} {string.Join(", ", missingValues.Select(x => x.ToString()))} in order to execute this operation."); + } + } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildIncidentsData.cs b/src/Discord.Net.Core/Entities/Guilds/GuildIncidentsData.cs new file mode 100644 index 0000000..0bd8b25 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildIncidentsData.cs @@ -0,0 +1,16 @@ +using System; + +namespace Discord; + +public class GuildIncidentsData +{ + /// + /// Gets the time when invites get enabled again. if invites are not disabled. + /// + public DateTimeOffset? InvitesDisabledUntil { get; set; } + + /// + /// Gets the time when DMs get enabled again. if DMs are not disabled. + /// + public DateTimeOffset? DmsDisabledUntil { get; set; } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildIncidentsDataProperties.cs b/src/Discord.Net.Core/Entities/Guilds/GuildIncidentsDataProperties.cs new file mode 100644 index 0000000..7855fa2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildIncidentsDataProperties.cs @@ -0,0 +1,22 @@ +using System; + +namespace Discord; + +public class GuildIncidentsDataProperties +{ + /// + /// Gets or set when invites get enabled again, up to 24 hours in the future. + /// + /// + /// Set to to enable invites. + /// + public Optional InvitesDisabledUntil { get; set; } + + /// + /// Gets or set when dms get enabled again, up to 24 hours in the future. + /// + /// + /// Set to to enable dms. + /// + public Optional DmsDisabledUntil { get; set; } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildInventorySettings.cs b/src/Discord.Net.Core/Entities/Guilds/GuildInventorySettings.cs new file mode 100644 index 0000000..f54e27e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildInventorySettings.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public struct GuildInventorySettings +{ + /// + /// Gets whether everyone can collect the pack to use emojis across servers. + /// + public bool IsEmojiPackCollectible { get; } + + internal GuildInventorySettings(bool isEmojiPackCollectible) + { + IsEmojiPackCollectible = isEmojiPackCollectible; + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildProperties.cs b/src/Discord.Net.Core/Entities/Guilds/GuildProperties.cs new file mode 100644 index 0000000..c73c668 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildProperties.cs @@ -0,0 +1,127 @@ +using System.Globalization; + +namespace Discord +{ + /// + /// Provides properties that are used to modify an with the specified changes. + /// + /// + public class GuildProperties + { + /// + /// Gets or sets the name of the guild. Must be within 100 characters. + /// + public Optional Name { get; set; } + /// + /// Gets or sets the region for the guild's voice connections. + /// + public Optional Region { get; set; } + /// + /// Gets or sets the ID of the region for the guild's voice connections. + /// + public Optional RegionId { get; set; } + /// + /// Gets or sets the verification level new users need to achieve before speaking. + /// + public Optional VerificationLevel { get; set; } + /// + /// Gets or sets the default message notification state for the guild. + /// + public Optional DefaultMessageNotifications { get; set; } + /// + /// Gets or sets how many seconds before a user is sent to AFK. This value MUST be one of: (60, 300, 900, + /// 1800, 3600). + /// + public Optional AfkTimeout { get; set; } + /// + /// Gets or sets the icon of the guild. + /// + public Optional Icon { get; set; } + /// + /// Gets or sets the banner of the guild. + /// + public Optional Banner { get; set; } + /// + /// Gets or sets the guild's splash image. + /// + /// + /// The guild must be partnered for this value to have any effect. + /// + public Optional Splash { get; set; } + /// + /// Gets or sets the where AFK users should be sent. + /// + public Optional AfkChannel { get; set; } + /// + /// Gets or sets the ID of the where AFK users should be sent. + /// + public Optional AfkChannelId { get; set; } + /// + /// Gets or sets the where system messages should be sent. + /// + public Optional SystemChannel { get; set; } + /// + /// Gets or sets the ID of the where system messages should be sent. + /// + public Optional SystemChannelId { get; set; } + /// + /// Gets or sets the owner of this guild. + /// + public Optional Owner { get; set; } + /// + /// Gets or sets the ID of the owner of this guild. + /// + public Optional OwnerId { get; set; } + /// + /// Gets or sets the explicit content filter level of this guild. + /// + public Optional ExplicitContentFilter { get; set; } + /// + /// Gets or sets the flags that DISABLE types of system channel messages. + /// + /// + /// These flags are inverted. Setting a flag will disable that system channel message from being sent. + /// A value of will allow all system channel message types to be sent, + /// given that the has also been set. + /// A value of will deny guild boost messages from being sent, and allow all + /// other types of messages. + /// Refer to the extension methods , + /// , , + /// and to check if these system channel message types + /// are enabled, without the need to manipulate the logic of the flag. + /// + public Optional SystemChannelFlags { get; set; } + /// + /// Gets or sets the preferred locale of the guild in IETF BCP 47 language tag format. + /// + /// + /// This property takes precedence over . + /// When it is set, the value of + /// will not be used. + /// + public Optional PreferredLocale { get; set; } + /// + /// Gets or sets the preferred locale of the guild. + /// + /// + /// The property takes precedence + /// over this property. When is set, + /// the value of will be unused. + /// + public Optional PreferredCulture { get; set; } + /// + /// Gets or sets if the boost progress bar is enabled. + /// + public Optional IsBoostProgressBarEnabled { get; set; } + + /// + /// Gets or sets the guild features enabled in this guild. Features that are not mutable will be ignored. + /// + public Optional Features { get; set; } + + /// + /// Gets or sets the ID of the safety alerts channel. + /// + public Optional SafetyAlertsChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventPrivacyLevel.cs b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventPrivacyLevel.cs new file mode 100644 index 0000000..8788110 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventPrivacyLevel.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents the privacy level of a guild scheduled event. + /// + public enum GuildScheduledEventPrivacyLevel + { + /// + /// The scheduled event is public and available in discovery. + /// + [Obsolete("This event type isn't supported yet! check back later.", true)] + Public = 1, + + /// + /// The scheduled event is only accessible to guild members. + /// + Private = 2, + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventStatus.cs b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventStatus.cs new file mode 100644 index 0000000..6e3aa1a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventStatus.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents the status of a guild event. + /// + public enum GuildScheduledEventStatus + { + /// + /// The event is scheduled for a set time. + /// + Scheduled = 1, + + /// + /// The event has started. + /// + Active = 2, + + /// + /// The event was completed. + /// + Completed = 3, + + /// + /// The event was canceled. + /// + Cancelled = 4, + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventType.cs b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventType.cs new file mode 100644 index 0000000..ad741ee --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventType.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents the type of a guild scheduled event. + /// + public enum GuildScheduledEventType + { + /// + /// The event doesn't have a set type. + /// + None = 0, + + /// + /// The event is set in a stage channel. + /// + Stage = 1, + + /// + /// The event is set in a voice channel. + /// + Voice = 2, + + /// + /// The event is set for somewhere externally from discord. + /// + External = 3, + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventsProperties.cs b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventsProperties.cs new file mode 100644 index 0000000..d3be8b7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildScheduledEventsProperties.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Provides properties that are used to modify an with the specified changes. + /// + public class GuildScheduledEventsProperties + { + /// + /// Gets or sets the channel id of the event. + /// + public Optional ChannelId { get; set; } + + /// + /// Gets or sets the location of this event. + /// + public Optional Location { get; set; } + + /// + /// Gets or sets the name of the event. + /// + public Optional Name { get; set; } + + /// + /// Gets or sets the privacy level of the event. + /// + public Optional PrivacyLevel { get; set; } + + /// + /// Gets or sets the start time of the event. + /// + public Optional StartTime { get; set; } + /// + /// Gets or sets the end time of the event. + /// + public Optional EndTime { get; set; } + + /// + /// Gets or sets the description of the event. + /// + public Optional Description { get; set; } + + /// + /// Gets or sets the type of the event. + /// + public Optional Type { get; set; } + + /// + /// Gets or sets the status of the event. + /// + public Optional Status { get; set; } + + /// + /// Gets or sets the banner image of the event. + /// + public Optional CoverImage { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/GuildWidgetProperties.cs b/src/Discord.Net.Core/Entities/Guilds/GuildWidgetProperties.cs new file mode 100644 index 0000000..842bb7f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/GuildWidgetProperties.cs @@ -0,0 +1,21 @@ +namespace Discord +{ + /// + /// Provides properties that are used to modify the widget of an with the specified changes. + /// + public class GuildWidgetProperties + { + /// + /// Sets whether the widget should be enabled. + /// + public Optional Enabled { get; set; } + /// + /// Sets the channel that the invite should place its users in, if not . + /// + public Optional Channel { get; set; } + /// + /// Sets the channel that the invite should place its users in, if not . + /// + public Optional ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/IBan.cs b/src/Discord.Net.Core/Entities/Guilds/IBan.cs new file mode 100644 index 0000000..f384559 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/IBan.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + /// + /// Represents a generic ban object. + /// + public interface IBan + { + /// + /// Gets the banned user. + /// + /// + /// A user that was banned. + /// + IUser User { get; } + /// + /// Gets the reason why the user is banned if specified. + /// + /// + /// A string containing the reason behind the ban; if none is specified. + /// + string Reason { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs new file mode 100644 index 0000000..708e583 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -0,0 +1,1456 @@ +using Discord.Audio; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading.Tasks; + +namespace Discord +{ + // when adding properties to guilds please check if they are returned in audit log events and add them to the + // 'GuildInfo.cs' file for socket and rest audit logs. + /// + /// Represents a generic guild/server. + /// + public interface IGuild : IDeletable, ISnowflakeEntity + { + /// + /// Gets the name of this guild. + /// + /// + /// A string containing the name of this guild. + /// + string Name { get; } + /// + /// Gets the amount of time (in seconds) a user must be inactive in a voice channel for until they are + /// automatically moved to the AFK voice channel. + /// + /// + /// An representing the amount of time in seconds for a user to be marked as inactive + /// and moved into the AFK voice channel. + /// + int AFKTimeout { get; } + /// + /// Gets a value that indicates whether this guild has the widget enabled. + /// + /// + /// if this guild has a widget enabled; otherwise . + /// + bool IsWidgetEnabled { get; } + /// + /// Gets the default message notifications for users who haven't explicitly set their notification settings. + /// + DefaultMessageNotifications DefaultMessageNotifications { get; } + /// + /// Gets the level of Multi-Factor Authentication requirements a user must fulfill before being allowed to + /// perform administrative actions in this guild. + /// + /// + /// The level of MFA requirement. + /// + MfaLevel MfaLevel { get; } + /// + /// Gets the level of requirements a user must fulfill before being allowed to post messages in this guild. + /// + /// + /// The level of requirements. + /// + VerificationLevel VerificationLevel { get; } + /// + /// Gets the level of content filtering applied to user's content in a Guild. + /// + /// + /// The level of explicit content filtering. + /// + ExplicitContentFilterLevel ExplicitContentFilter { get; } + /// + /// Gets the ID of this guild's icon. + /// + /// + /// An identifier for the splash image; if none is set. + /// + string IconId { get; } + /// + /// Gets the URL of this guild's icon. + /// + /// + /// A URL pointing to the guild's icon; if none is set. + /// + string IconUrl { get; } + /// + /// Gets the ID of this guild's splash image. + /// + /// + /// An identifier for the splash image; if none is set. + /// + string SplashId { get; } + /// + /// Gets the URL of this guild's splash image. + /// + /// + /// A URL pointing to the guild's splash image; if none is set. + /// + string SplashUrl { get; } + /// + /// Gets the ID of this guild's discovery splash image. + /// + /// + /// An identifier for the discovery splash image; if none is set. + /// + string DiscoverySplashId { get; } + /// + /// Gets the URL of this guild's discovery splash image. + /// + /// + /// A URL pointing to the guild's discovery splash image; if none is set. + /// + string DiscoverySplashUrl { get; } + /// + /// Determines if this guild is currently connected and ready to be used. + /// + /// + /// + /// This property only applies to a WebSocket-based client. + /// + /// This boolean is used to determine if the guild is currently connected to the WebSocket and is ready to be used/accessed. + /// + /// + /// if this guild is currently connected and ready to be used; otherwise . + /// + bool Available { get; } + + /// + /// Gets the ID of the AFK voice channel for this guild. + /// + /// + /// A representing the snowflake identifier of the AFK voice channel; if + /// none is set. + /// + ulong? AFKChannelId { get; } + /// + /// Gets the ID of the channel assigned to the widget of this guild. + /// + /// + /// A representing the snowflake identifier of the channel assigned to the widget found + /// within the widget settings of this guild; if none is set. + /// + ulong? WidgetChannelId { get; } + /// + /// Gets the ID of the channel assigned to the safety alerts channel of this guild. + /// + /// + /// A representing the snowflake identifier of the safety alerts channel; + /// if none is set. + /// + ulong? SafetyAlertsChannelId { get; } + /// + /// Gets the ID of the channel where randomized welcome messages are sent. + /// + /// + /// A representing the snowflake identifier of the system channel where randomized + /// welcome messages are sent; if none is set. + /// + ulong? SystemChannelId { get; } + /// + /// Gets the ID of the channel with the rules. + /// + /// + /// A representing the snowflake identifier of the channel that contains the rules; + /// if none is set. + /// + ulong? RulesChannelId { get; } + /// + /// Gets the ID of the channel where admins and moderators of Community guilds receive notices from Discord. + /// + /// + /// A representing the snowflake identifier of the channel where admins and moderators + /// of Community guilds receive notices from Discord; if none is set. + /// + ulong? PublicUpdatesChannelId { get; } + /// + /// Gets the ID of the user that owns this guild. + /// + /// + /// A representing the snowflake identifier of the user that owns this guild. + /// + ulong OwnerId { get; } + /// + /// Gets the application ID of the guild creator if it is bot-created. + /// + /// + /// A representing the snowflake identifier of the application ID that created this guild, or if it was not bot-created. + /// + ulong? ApplicationId { get; } + /// + /// Gets the ID of the region hosting this guild's voice channels. + /// + /// + /// A string containing the identifier for the voice region that this guild uses (e.g. eu-central). + /// + string VoiceRegionId { get; } + /// + /// Gets the currently associated with this guild. + /// + /// + /// An currently associated with this guild. + /// + IAudioClient AudioClient { get; } + /// + /// Gets the built-in role containing all users in this guild. + /// + /// + /// A role object that represents an @everyone role in this guild. + /// + IRole EveryoneRole { get; } + /// + /// Gets a collection of all custom emotes for this guild. + /// + /// + /// A read-only collection of all custom emotes for this guild. + /// + IReadOnlyCollection Emotes { get; } + /// + /// Gets a collection of all custom stickers for this guild. + /// + /// + /// A read-only collection of all custom stickers for this guild. + /// + IReadOnlyCollection Stickers { get; } + /// + /// Gets the features for this guild. + /// + /// + /// A flags enum containing all the features for the guild. + /// + GuildFeatures Features { get; } + /// + /// Gets a collection of all roles in this guild. + /// + /// + /// A read-only collection of roles found within this guild. + /// + IReadOnlyCollection Roles { get; } + /// + /// Gets the tier of guild boosting in this guild. + /// + /// + /// The tier of guild boosting in this guild. + /// + PremiumTier PremiumTier { get; } + /// + /// Gets the identifier for this guilds banner image. + /// + /// + /// An identifier for the banner image; if none is set. + /// + string BannerId { get; } + /// + /// Gets the URL of this guild's banner image. + /// + /// + /// A URL pointing to the guild's banner image; if none is set. + /// + string BannerUrl { get; } + /// + /// Gets the code for this guild's vanity invite URL. + /// + /// + /// A string containing the vanity invite code for this guild; if none is set. + /// + string VanityURLCode { get; } + /// + /// Gets the flags for the types of system channel messages that are disabled. + /// + /// + /// The flags for the types of system channel messages that are disabled. + /// + SystemChannelMessageDeny SystemChannelFlags { get; } + /// + /// Gets the description for the guild. + /// + /// + /// The description for the guild; if none is set. + /// + string Description { get; } + /// + /// Gets the number of premium subscribers of this guild. + /// + /// + /// This is the number of users who have boosted this guild. + /// + /// + /// The number of premium subscribers of this guild. + /// + int PremiumSubscriptionCount { get; } + /// + /// Gets the maximum number of presences for the guild. + /// + /// + /// The maximum number of presences for the guild. + /// + int? MaxPresences { get; } + /// + /// Gets the maximum number of members for the guild. + /// + /// + /// The maximum number of members for the guild. + /// + int? MaxMembers { get; } + /// + /// Gets the maximum amount of users in a video channel. + /// + /// + /// The maximum amount of users in a video channel. + /// + int? MaxVideoChannelUsers { get; } + /// + /// Gets the maximum amount of users in a stage video channel. + /// + /// + /// The maximum amount of users in a stage video channel. + /// + int? MaxStageVideoChannelUsers { get; } + /// + /// Gets the approximate number of members in this guild. + /// + /// + /// Only available when getting a guild via REST when `with_counts` is true. + /// + /// + /// The approximate number of members in this guild. + /// + int? ApproximateMemberCount { get; } + /// + /// Gets the approximate number of non-offline members in this guild. + /// + /// + /// Only available when getting a guild via REST when `with_counts` is true. + /// + /// + /// The approximate number of non-offline members in this guild. + /// + int? ApproximatePresenceCount { get; } + /// + /// Gets the max bitrate for voice channels in this guild. + /// + /// + /// A representing the maximum bitrate value allowed by Discord in this guild. + /// + int MaxBitrate { get; } + + /// + /// Gets the preferred locale of this guild in IETF BCP 47 + /// language tag format. + /// + /// + /// The preferred locale of the guild in IETF BCP 47 + /// language tag format. + /// + string PreferredLocale { get; } + + /// + /// Gets the NSFW level of this guild. + /// + /// + /// The NSFW level of this guild. + /// + NsfwLevel NsfwLevel { get; } + + /// + /// Gets the preferred culture of this guild. + /// + /// + /// The preferred culture information of this guild. + /// + CultureInfo PreferredCulture { get; } + /// + /// Gets whether the guild has the boost progress bar enabled. + /// + /// + /// if the boost progress bar is enabled; otherwise . + /// + bool IsBoostProgressBarEnabled { get; } + + /// + /// Gets the upload limit in bytes for this guild. This number is dependent on the guild's boost status. + /// + ulong MaxUploadLimit { get; } + + /// + /// Get the inventory settings on the guild. if not available in the guild. + /// + GuildInventorySettings? InventorySettings { get; } + + /// + /// Gets the incidents data for this guild. + /// + GuildIncidentsData IncidentsData { get; } + + /// + /// Modifies this guild. + /// + /// The delegate containing the properties to modify the guild with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + /// + /// Modifies this guild's widget. + /// + /// The delegate containing the properties to modify the guild widget with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyWidgetAsync(Action func, RequestOptions options = null); + /// + /// Bulk-modifies the order of channels in this guild. + /// + /// The properties used to modify the channel positions with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous reorder operation. + /// + Task ReorderChannelsAsync(IEnumerable args, RequestOptions options = null); + /// + /// Bulk-modifies the order of roles in this guild. + /// + /// The properties used to modify the role positions with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous reorder operation. + /// + Task ReorderRolesAsync(IEnumerable args, RequestOptions options = null); + /// + /// Leaves this guild. + /// + /// + /// This method will make the currently logged-in user leave the guild. + /// + /// If the user is the owner of this guild, use instead. + /// + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous leave operation. + /// + Task LeaveAsync(RequestOptions options = null); + /// + /// Gets amount of bans from the guild ordered by user ID. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// + /// Do not fetch too many bans at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// + /// The amount of bans to get from the guild. + /// The options to be used when sending the request. + /// + /// A paged collection of bans. + /// + IAsyncEnumerable> GetBansAsync(int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null); + /// + /// Gets amount of bans from the guild starting at the provided ordered by user ID. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// + /// Do not fetch too many bans at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// + /// The ID of the user to start to get bans from. + /// The direction of the bans to be gotten. + /// The number of bans to get. + /// The options to be used when sending the request. + /// + /// A paged collection of bans. + /// + IAsyncEnumerable> GetBansAsync(ulong fromUserId, Direction dir, int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null); + /// + /// Gets amount of bans from the guild starting at the provided ordered by user ID. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// + /// Do not fetch too many bans at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// + /// The user to start to get bans from. + /// The direction of the bans to be gotten. + /// The number of bans to get. + /// The options to be used when sending the request. + /// + /// A paged collection of bans. + /// + IAsyncEnumerable> GetBansAsync(IUser fromUser, Direction dir, int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null); + /// + /// Gets a ban object for a banned user. + /// + /// The banned user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a ban object, which + /// contains the user information and the reason for the ban; if the ban entry cannot be found. + /// + Task GetBanAsync(IUser user, RequestOptions options = null); + /// + /// Gets a ban object for a banned user. + /// + /// The snowflake identifier for the banned user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a ban object, which + /// contains the user information and the reason for the ban; if the ban entry cannot be found. + /// + Task GetBanAsync(ulong userId, RequestOptions options = null); + /// + /// Bans the user from this guild and optionally prunes their recent messages. + /// + /// The user to ban. + /// The number of days to remove messages from this user for, and this number must be between [0, 7]. + /// The reason of the ban to be written in the audit log. + /// The options to be used when sending the request. + /// is not between 0 to 7. + /// + /// A task that represents the asynchronous add operation for the ban. + /// + Task AddBanAsync(IUser user, int pruneDays = 0, string reason = null, RequestOptions options = null); + /// + /// Bans the user from this guild and optionally prunes their recent messages. + /// + /// The snowflake ID of the user to ban. + /// The number of days to remove messages from this user for, and this number must be between [0, 7]. + /// The reason of the ban to be written in the audit log. + /// The options to be used when sending the request. + /// is not between 0 to 7. + /// + /// A task that represents the asynchronous add operation for the ban. + /// + Task AddBanAsync(ulong userId, int pruneDays = 0, string reason = null, RequestOptions options = null); + + /// + /// Bans the user from this guild and optionally prunes their recent messages. + /// + /// The user to ban. + /// The number of seconds to remover messages from this user for, between 0 and 604800 + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous add operation for the ban. + /// + Task BanUserAsync(IUser user, uint pruneSeconds = 0, RequestOptions options = null); + + /// + /// Bans the user from this guild and optionally prunes their recent messages. + /// + /// The ID of the user to ban. + /// The number of seconds to remover messages from this user for, between 0 and 604800 + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous add operation for the ban. + /// + Task BanUserAsync(ulong userId, uint pruneSeconds = 0, RequestOptions options = null); + + /// + /// Unbans the user if they are currently banned. + /// + /// The user to be unbanned. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation for the ban. + /// + Task RemoveBanAsync(IUser user, RequestOptions options = null); + /// + /// Unbans the user if they are currently banned. + /// + /// The snowflake identifier of the user to be unbanned. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation for the ban. + /// + Task RemoveBanAsync(ulong userId, RequestOptions options = null); + + /// + /// Gets a collection of all channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// generic channels found within this guild. + /// + Task> GetChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a channel in this guild. + /// + /// The snowflake identifier for the channel. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the generic channel + /// associated with the specified ; if none is found. + /// + Task GetChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of all text channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// message channels found within this guild. + /// + Task> GetTextChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a text channel in this guild. + /// + /// The snowflake identifier for the text channel. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel + /// associated with the specified ; if none is found. + /// + Task GetTextChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of all voice channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// voice channels found within this guild. + /// + Task> GetVoiceChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of all category channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// category channels found within this guild. + /// + Task> GetCategoriesAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a voice channel in this guild. + /// + /// The snowflake identifier for the voice channel. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the voice channel associated + /// with the specified ; if none is found. + /// + Task GetVoiceChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a stage channel in this guild. + /// + /// The snowflake identifier for the stage channel. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the stage channel associated + /// with the specified ; if none is found. + /// + Task GetStageChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of all stage channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// stage channels found within this guild. + /// + Task> GetStageChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the AFK voice channel in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the voice channel that the + /// AFK users will be moved to after they have idled for too long; if none is set. + /// + Task GetAFKChannelAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the system channel where randomized welcome messages are sent in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel where + /// randomized welcome messages will be sent to; if none is set. + /// + Task GetSystemChannelAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the first viewable text channel in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the first viewable text + /// channel in this guild; if none is found. + /// + Task GetDefaultChannelAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the widget channel (i.e. the channel set in the guild's widget settings) in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the widget channel set + /// within the server's widget settings; if none is set. + /// + Task GetWidgetChannelAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the text channel where Community guilds can display rules and/or guidelines. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel + /// where Community guilds can display rules and/or guidelines; if none is set. + /// + Task GetRulesChannelAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the text channel where admins and moderators of Community guilds receive notices from Discord. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel where + /// admins and moderators of Community guilds receive notices from Discord; if none is set. + /// + Task GetPublicUpdatesChannelAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a thread channel within this guild. + /// + /// The id of the thread channel. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the thread channel. + /// + Task GetThreadChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of all thread channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// thread channels found within this guild. + /// + Task> GetThreadChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets a forum channel in this guild. + /// + /// The snowflake identifier for the stage channel. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the stage channel associated + /// with the specified ; if none is found. + /// + Task GetForumChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets a collection of all forum channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// forum channels found within this guild. + /// + Task> GetForumChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets a forum channel in this guild. + /// + /// The snowflake identifier for the stage channel. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the stage channel associated + /// with the specified ; if none is found. + /// + Task GetMediaChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets a collection of all forum channels in this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// media channels found within this guild. + /// + Task> GetMediaChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Creates a new text channel in this guild. + /// + /// + /// The following example creates a new text channel under an existing category named Wumpus with a set topic. + /// + /// + /// The new name for the text channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// text channel. + /// + Task CreateTextChannelAsync(string name, Action func = null, RequestOptions options = null); + /// + /// Creates a new announcement channel in this guild. + /// + /// + /// Announcement channels are only available in Community guilds. + /// + /// The new name for the announcement channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// announcement channel. + /// + Task CreateNewsChannelAsync(string name, Action func = null, RequestOptions options = null); + + /// + /// Creates a new voice channel in this guild. + /// + /// The new name for the voice channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// voice channel. + /// + Task CreateVoiceChannelAsync(string name, Action func = null, RequestOptions options = null); + /// + /// Creates a new stage channel in this guild. + /// + /// The new name for the stage channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// stage channel. + /// + Task CreateStageChannelAsync(string name, Action func = null, RequestOptions options = null); + /// + /// Creates a new channel category in this guild. + /// + /// The new name for the category. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// category channel. + /// + Task CreateCategoryAsync(string name, Action func = null, RequestOptions options = null); + + /// + /// Creates a new channel forum in this guild. + /// + /// The new name for the forum. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// forum channel. + /// + Task CreateForumChannelAsync(string name, Action func = null, RequestOptions options = null); + + /// + /// Creates a new media channel in this guild. + /// + /// The new name for the media channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// forum channel. + /// + Task CreateMediaChannelAsync(string name, Action func = null, RequestOptions options = null); + + /// + /// Gets a collection of all the voice regions this guild can access. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// voice regions the guild can access. + /// + Task> GetVoiceRegionsAsync(RequestOptions options = null); + + /// + /// Gets a collection of all the integrations this guild contains. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// integrations the guild can has. + /// + Task> GetIntegrationsAsync(RequestOptions options = null); + + /// + /// Deletes an integration. + /// + /// The id for the integration. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task DeleteIntegrationAsync(ulong id, RequestOptions options = null); + + /// + /// Gets a collection of all invites in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// invite metadata, each representing information for an invite found within this guild. + /// + Task> GetInvitesAsync(RequestOptions options = null); + /// + /// Gets the vanity invite URL of this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the partial metadata of + /// the vanity invite found within this guild; if none is found. + /// + Task GetVanityInviteAsync(RequestOptions options = null); + + /// + /// Gets a role in this guild. + /// + /// The snowflake identifier for the role. + /// + /// A role that is associated with the specified ; if none is found. + /// + IRole GetRole(ulong id); + /// + /// Creates a new role with the provided name. + /// + /// The new name for the role. + /// The guild permission that the role should possess. + /// The color of the role. + /// Whether the role is separated from others on the sidebar. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// role. + /// + Task CreateRoleAsync(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false, RequestOptions options = null); + // TODO remove CreateRoleAsync overload that does not have isMentionable when breaking change is acceptable + /// + /// Creates a new role with the provided name. + /// + /// The new name for the role. + /// The guild permission that the role should possess. + /// The color of the role. + /// Whether the role is separated from others on the sidebar. + /// Whether the role can be mentioned. + /// The options to be used when sending the request. + /// The icon for the role. + /// The unicode emoji to be used as an icon for the role. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// role. + /// + Task CreateRoleAsync(string name, GuildPermissions? permissions = null, Color? color = null, bool isHoisted = false, bool isMentionable = false, RequestOptions options = null, Image? icon = null, Emoji emoji = null); + + /// + /// Adds a user to this guild. + /// + /// + /// This method requires you have an OAuth2 access token for the user, requested with the guilds.join scope, and that the bot have the MANAGE_INVITES permission in the guild. + /// + /// The snowflake identifier of the user. + /// The OAuth2 access token for the user, requested with the guilds.join scope. + /// The delegate containing the properties to be applied to the user upon being added to the guild. + /// The options to be used when sending the request. + /// A guild user associated with the specified ; if the user is already in the guild. + Task AddGuildUserAsync(ulong userId, string accessToken, Action func = null, RequestOptions options = null); + /// + /// Disconnects the user from its current voice channel. + /// + /// The user to disconnect. + /// A task that represents the asynchronous operation for disconnecting a user. + Task DisconnectAsync(IGuildUser user); + /// + /// Gets a collection of all users in this guild. + /// + /// + /// This method retrieves all users found within this guild. + /// + /// This may return an incomplete collection in the WebSocket implementation due to how Discord does not + /// send a complete user list for large guilds. + /// + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users found within this guild. + /// + Task> GetUsersAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a user from this guild. + /// + /// + /// This method retrieves a user found within this guild. + /// + /// This may return in the WebSocket implementation due to incomplete user collection in + /// large guilds. + /// + /// + /// The snowflake identifier of the user. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the guild user + /// associated with the specified ; if none is found. + /// + Task GetUserAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the current user for this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the currently logged-in + /// user within this guild. + /// + Task GetCurrentUserAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets the owner of this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the owner of this guild. + /// + Task GetOwnerAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Downloads all users for this guild if the current list is incomplete. + /// + /// + /// This method downloads all users found within this guild through the Gateway and caches them. + /// + /// + /// A task that represents the asynchronous download operation. + /// + Task DownloadUsersAsync(); + /// + /// Prunes inactive users. + /// + /// + /// + /// This method removes all users that have not logged on in the provided number of . + /// + /// + /// If is , this method will only return the number of users that + /// would be removed without kicking the users. + /// + /// + /// The number of days required for the users to be kicked. + /// Whether this prune action is a simulation. + /// The options to be used when sending the request. + /// An array of role IDs to be included in the prune of users who do not have any additional roles. + /// + /// A task that represents the asynchronous prune operation. The task result contains the number of users to + /// be or has been removed from this guild. + /// + Task PruneUsersAsync(int days = 30, bool simulate = false, RequestOptions options = null, IEnumerable includeRoleIds = null); + /// + /// Gets a collection of users in this guild that the name or nickname starts with the + /// provided at . + /// + /// + /// The can not be higher than . + /// + /// The partial name or nickname to search. + /// The maximum number of users to be gotten. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users that the name or nickname starts with the provided at . + /// + Task> SearchUsersAsync(string query, int limit = DiscordConfig.MaxUsersPerBatch, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets the specified number of audit log entries for this guild. + /// + /// The number of audit log entries to fetch. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// The audit log entry ID to get entries before. + /// The type of actions to filter. + /// The user ID to filter entries for. + /// The audit log entry ID to get entries after. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of the requested audit log entries. + /// + Task> GetAuditLogsAsync(int limit = DiscordConfig.MaxAuditLogEntriesPerBatch, + CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null, ulong? beforeId = null, ulong? userId = null, + ActionType? actionType = null, ulong? afterId = null); + + /// + /// Gets a webhook found within this guild. + /// + /// The identifier for the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the webhook with the + /// specified ; if none is found. + /// + Task GetWebhookAsync(ulong id, RequestOptions options = null); + /// + /// Gets a collection of all webhook from this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of webhooks found within the guild. + /// + Task> GetWebhooksAsync(RequestOptions options = null); + + /// + /// Gets a collection of emotes from this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of emotes found within the guild. + /// + Task> GetEmotesAsync(RequestOptions options = null); + /// + /// Gets a specific emote from this guild. + /// + /// The snowflake identifier for the guild emote. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the emote found with the + /// specified ; if none is found. + /// + Task GetEmoteAsync(ulong id, RequestOptions options = null); + /// + /// Creates a new in this guild. + /// + /// The name of the guild emote. + /// The image of the new emote. + /// The roles to limit the emote usage to. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created emote. + /// + Task CreateEmoteAsync(string name, Image image, Optional> roles = default(Optional>), RequestOptions options = null); + + /// + /// Modifies an existing in this guild. + /// + /// The emote to be modified. + /// The delegate containing the properties to modify the emote with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. The task result contains the modified + /// emote. + /// + Task ModifyEmoteAsync(GuildEmote emote, Action func, RequestOptions options = null); + + /// + /// Moves the user to the voice channel. + /// + /// The user to move. + /// the channel where the user gets moved to. + /// A task that represents the asynchronous operation for moving a user. + Task MoveAsync(IGuildUser user, IVoiceChannel targetChannel); + + /// + /// Deletes an existing from this guild. + /// + /// The emote to delete. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task DeleteEmoteAsync(GuildEmote emote, RequestOptions options = null); + + /// + /// Creates a new sticker in this guild. + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The image of the new emote. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + Task CreateStickerAsync(string name, Image image, IEnumerable tags, string description = null, RequestOptions options = null); + + /// + /// Creates a new sticker in this guild. + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The path of the file to upload. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + Task CreateStickerAsync(string name, string path, IEnumerable tags, string description = null, RequestOptions options = null); + + /// + /// Creates a new sticker in this guild. + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The stream containing the file data. + /// The name of the file with the extension, ex: image.png. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + Task CreateStickerAsync(string name, Stream stream, string filename, IEnumerable tags, string description = null, RequestOptions options = null); + + /// + /// Gets a specific sticker within this guild. + /// + /// The id of the sticker to get. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the sticker found with the + /// specified ; if none is found. + /// + Task GetStickerAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets a collection of all stickers within this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of stickers found within the guild. + /// + Task> GetStickersAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Deletes a sticker within this guild. + /// + /// The sticker to delete. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task DeleteStickerAsync(ICustomSticker sticker, RequestOptions options = null); + + /// + /// Gets a event within this guild. + /// + /// The id of the event. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. + /// + Task GetEventAsync(ulong id, RequestOptions options = null); + + /// + /// Gets a collection of events within this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. + /// + Task> GetEventsAsync(RequestOptions options = null); + + /// + /// Creates an event within this guild. + /// + /// The name of the event. + /// The privacy level of the event. + /// The start time of the event. + /// The type of the event. + /// The description of the event. + /// The end time of the event. + /// + /// The channel id of the event. + /// + /// The event must have a type of or + /// in order to use this property. + /// + /// + /// The location of the event; links are supported + /// The optional banner image for the event. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous create operation. + /// + Task CreateEventAsync( + string name, + DateTimeOffset startTime, + GuildScheduledEventType type, + GuildScheduledEventPrivacyLevel privacyLevel = GuildScheduledEventPrivacyLevel.Private, + string description = null, + DateTimeOffset? endTime = null, + ulong? channelId = null, + string location = null, + Image? coverImage = null, + RequestOptions options = null); + + /// + /// Gets this guilds application commands. + /// + /// + /// Whether to include full localization dictionaries in the returned objects, + /// instead of the localized name and description fields. + /// + /// The target locale of the localized name and description fields. Sets the X-Discord-Locale header, which takes precedence over Accept-Language. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of application commands found within the guild. + /// + Task> GetApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null); + + /// + /// Gets an application command within this guild with the specified id. + /// + /// The id of the application command to get. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A ValueTask that represents the asynchronous get operation. The task result contains a + /// if found, otherwise . + /// + Task GetApplicationCommandAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, + RequestOptions options = null); + + /// + /// Creates an application command within this guild. + /// + /// The properties to use when creating the command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the command that was created. + /// + Task CreateApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options = null); + + /// + /// Overwrites the application commands within this guild. + /// + /// A collection of properties to use when creating the commands. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains a collection of commands that was created. + /// + Task> BulkOverwriteApplicationCommandsAsync(ApplicationCommandProperties[] properties, + RequestOptions options = null); + + /// + /// Gets the welcome screen of the guild. Returns if the welcome channel is not set. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains a . + /// + Task GetWelcomeScreenAsync(RequestOptions options = null); + + /// + /// Modifies the welcome screen of the guild. Returns if welcome screen is removed. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains a . + /// + Task ModifyWelcomeScreenAsync(bool enabled, WelcomeScreenChannelProperties[] channels, string description = null, RequestOptions options = null); + + /// + /// Get a list of all rules currently configured for the guild. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains a collection of . + /// + Task GetAutoModRulesAsync(RequestOptions options = null); + + /// + /// Gets a single rule configured in a guild. Returns if the rule was not found. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains a . + /// + Task GetAutoModRuleAsync(ulong ruleId, RequestOptions options = null); + + /// + /// Creates a new auto moderation rule. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains the created . + /// + Task CreateAutoModRuleAsync(Action props, RequestOptions options = null); + + /// + /// Gets the onboarding object configured for the guild. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains the created . + /// + Task GetOnboardingAsync(RequestOptions options = null); + + /// + /// Modifies the onboarding object configured for the guild. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains the modified . + /// + Task ModifyOnboardingAsync(Action props, RequestOptions options = null); + + /// + /// Modifies the incident actions of the guild. + /// + /// + /// A task that represents the asynchronous creation operation. The task result contains the modified . + /// + Task ModifyIncidentActionsAsync(Action props, RequestOptions options = null); + + /// + /// Executes a bulk ban on the specified users. + /// + /// A collection of user ids to ban. + /// The number of seconds to delete messages for. Max 604800. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains a . + /// + Task BulkBanAsync(IEnumerable userIds, int? deleteMessageSeconds = null, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuildScheduledEvent.cs b/src/Discord.Net.Core/Entities/Guilds/IGuildScheduledEvent.cs new file mode 100644 index 0000000..5c937f7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/IGuildScheduledEvent.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic guild scheduled event. + /// + public interface IGuildScheduledEvent : IEntity + { + /// + /// Gets the guild this event is scheduled in. + /// + IGuild Guild { get; } + + /// + /// Gets the id of the guild this event is scheduled in. + /// + ulong GuildId { get; } + + /// + /// Gets the optional channel id where this event will be hosted. + /// + ulong? ChannelId { get; } + + /// + /// Gets the user who created the event. + /// + IUser Creator { get; } + + /// + /// Gets the name of the event. + /// + string Name { get; } + + /// + /// Gets the description of the event. + /// + /// + /// This field is when the event doesn't have a description. + /// + string Description { get; } + + /// + /// Gets the banner asset id of the event. + /// + string CoverImageId { get; } + + /// + /// Gets the start time of the event. + /// + DateTimeOffset StartTime { get; } + + /// + /// Gets the optional end time of the event. + /// + DateTimeOffset? EndTime { get; } + + /// + /// Gets the privacy level of the event. + /// + GuildScheduledEventPrivacyLevel PrivacyLevel { get; } + + /// + /// Gets the status of the event. + /// + GuildScheduledEventStatus Status { get; } + + /// + /// Gets the type of the event. + /// + GuildScheduledEventType Type { get; } + + /// + /// Gets the optional entity id of the event. The "entity" of the event + /// can be a stage instance event as is separate from . + /// + ulong? EntityId { get; } + + /// + /// Gets the location of the event if the is external. + /// + string Location { get; } + + /// + /// Gets the user count of the event. + /// + int? UserCount { get; } + + /// + /// Gets this events banner image url. + /// + /// The format to return. + /// The size of the image to return in. This can be any power of two between 16 and 2048. + /// The cover images url. + string GetCoverImageUrl(ImageFormat format = ImageFormat.Auto, ushort size = 1024); + + /// + /// Starts the event. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous start operation. + /// + Task StartAsync(RequestOptions options = null); + /// + /// Ends or cancels the event. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous end operation. + /// + Task EndAsync(RequestOptions options = null); + + /// + /// Modifies the guild event. + /// + /// The delegate containing the properties to modify the event with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Deletes the current event. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous delete operation. + /// + Task DeleteAsync(RequestOptions options = null); + + /// + /// Gets a collection of N users interested in the event. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// This method will attempt to fetch all users that are interested in the event. + /// The library will attempt to split up the requests according to and . + /// In other words, if there are 300 users, and the constant + /// is 100, the request will be split into 3 individual requests; thus returning 3 individual asynchronous + /// responses, hence the need of flattening. + /// + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + IAsyncEnumerable> GetUsersAsync(RequestOptions options = null); + + /// + /// Gets a collection of N users interested in the event. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual users as a + /// collection. + /// + /// + /// Do not fetch too many users at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// This method will attempt to fetch the number of users specified under around + /// the user depending on the . The library will + /// attempt to split up the requests according to your and + /// . In other words, should the user request 500 users, + /// and the constant is 100, the request will + /// be split into 5 individual requests; thus returning 5 individual asynchronous responses, hence the need + /// of flattening. + /// + /// The ID of the starting user to get the users from. + /// The direction of the users to be gotten from. + /// The numbers of users to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + IAsyncEnumerable> GetUsersAsync(ulong fromUserId, Direction dir, int limit = DiscordConfig.MaxGuildEventUsersPerBatch, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/IUserGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IUserGuild.cs new file mode 100644 index 0000000..75ee306 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/IUserGuild.cs @@ -0,0 +1,46 @@ +namespace Discord +{ + public interface IUserGuild : IDeletable, ISnowflakeEntity + { + /// + /// Gets the name of this guild. + /// + string Name { get; } + /// + /// Gets the icon URL associated with this guild, or if one is not set. + /// + string IconUrl { get; } + /// + /// Returns if the current user owns this guild. + /// + bool IsOwner { get; } + /// + /// Returns the current user's permissions for this guild. + /// + GuildPermissions Permissions { get; } + + /// + /// Gets the features for this guild. + /// + /// + /// A flags enum containing all the features for the guild. + /// + GuildFeatures Features { get; } + + /// + /// Gets the approximate number of members in this guild. + /// + /// + /// Only available when getting a guild via REST when `with_counts` is true. + /// + int? ApproximateMemberCount { get; } + + /// + /// Gets the approximate number of non-offline members in this guild. + /// + /// + /// Only available when getting a guild via REST when `with_counts` is true. + /// + int? ApproximatePresenceCount { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/IVoiceRegion.cs b/src/Discord.Net.Core/Entities/Guilds/IVoiceRegion.cs new file mode 100644 index 0000000..8435cc7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/IVoiceRegion.cs @@ -0,0 +1,51 @@ +namespace Discord +{ + /// + /// Represents a region of which the user connects to when using voice. + /// + public interface IVoiceRegion + { + /// + /// Gets the unique identifier for this voice region. + /// + /// + /// A string that represents the identifier for this voice region (e.g. eu-central). + /// + string Id { get; } + /// + /// Gets the name of this voice region. + /// + /// + /// A string that represents the human-readable name of this voice region (e.g. Central Europe). + /// + string Name { get; } + /// + /// Gets a value that indicates whether or not this voice region is exclusive to partnered servers. + /// + /// + /// if this voice region is exclusive to VIP accounts; otherwise . + /// + bool IsVip { get; } + /// + /// Gets a value that indicates whether this voice region is optimal for your client in terms of latency. + /// + /// + /// if this voice region is the closest to your machine; otherwise . + /// + bool IsOptimal { get; } + /// + /// Gets a value that indicates whether this voice region is no longer being maintained. + /// + /// + /// if this is a deprecated voice region; otherwise . + /// + bool IsDeprecated { get; } + /// + /// Gets a value that indicates whether this voice region is custom-made for events. + /// + /// + /// if this is a custom voice region (used for events/etc); otherwise / + /// + bool IsCustom { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/MfaLevel.cs b/src/Discord.Net.Core/Entities/Guilds/MfaLevel.cs new file mode 100644 index 0000000..57edac2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/MfaLevel.cs @@ -0,0 +1,17 @@ +namespace Discord +{ + /// + /// Specifies the guild's Multi-Factor Authentication (MFA) level requirement. + /// + public enum MfaLevel + { + /// + /// Users have no additional MFA restriction on this guild. + /// + Disabled = 0, + /// + /// Users must have MFA enabled on their account to perform administrative actions. + /// + Enabled = 1 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/NsfwLevel.cs b/src/Discord.Net.Core/Entities/Guilds/NsfwLevel.cs new file mode 100644 index 0000000..7e7ce4f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/NsfwLevel.cs @@ -0,0 +1,22 @@ +namespace Discord +{ + public enum NsfwLevel + { + /// + /// Default or unset. + /// + Default = 0, + /// + /// Guild has extremely suggestive or mature content that would only be suitable for users 18 or over. + /// + Explicit = 1, + /// + /// Guild has no content that could be deemed NSFW; in other words, SFW. + /// + Safe = 2, + /// + /// Guild has mildly NSFW content that may not be suitable for users under 18. + /// + AgeRestricted = 3 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingMode.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingMode.cs new file mode 100644 index 0000000..f2d92dd --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingMode.cs @@ -0,0 +1,17 @@ +namespace Discord; + +/// +/// Defines the criteria used to satisfy Onboarding constraints that are required for enabling. +/// +public enum GuildOnboardingMode +{ + /// + /// Counts only Default Channels towards constraints. + /// + Default = 0, + + /// + /// Counts Default Channels and Questions towards constraints. + /// + Advanced = 1, +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptOptionProperties.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptOptionProperties.cs new file mode 100644 index 0000000..38d5cd8 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptOptionProperties.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents properties used to create or modify guild onboarding prompt option. +/// +public class GuildOnboardingPromptOptionProperties +{ + /// + /// Gets or sets the Id of the prompt option. If the value is a new prompt will be created. + /// The existing one will be updated otherwise. + /// + public ulong? Id { get; set; } + + /// + /// Gets or set IDs for channels a member is added to when the option is selected. + /// + public ulong[] ChannelIds { get; set; } + + /// + /// Gets or sets IDs for roles assigned to a member when the option is selected. + /// + public ulong[] RoleIds { get; set; } + + /// + /// Gets or sets the emoji of the option. + /// + public Optional Emoji { get; set; } + + /// + /// Gets or sets the title of the option. + /// + public string Title { get; set; } + + /// + /// Gets or sets the description of the option. + /// + public string Description { get; set; } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptProperties.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptProperties.cs new file mode 100644 index 0000000..c6885f3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptProperties.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents properties used to create or modify guild onboarding prompt. +/// +public class GuildOnboardingPromptProperties +{ + /// + /// Gets or sets the Id of the prompt. If the value is a new prompt will be created. + /// The existing one will be updated otherwise. + /// + public ulong? Id { get; set; } + + /// + /// Gets or sets options available within the prompt. + /// + public GuildOnboardingPromptOptionProperties[] Options { get; set; } + + /// + /// Gets or sets the title of the prompt. + /// + public string Title { get; set; } + + /// + /// Gets or sets whether users are limited to selecting one option for the prompt. + /// + public bool IsSingleSelect { get; set; } + + /// + /// Gets or sets whether the prompt is required before a user completes the onboarding flow. + /// + public bool IsRequired { get; set; } + + /// + /// Gets or sets whether the prompt is present in the onboarding flow. + /// + public bool IsInOnboarding { get; set; } + + /// + /// Gets or set the type of the prompt. + /// + public GuildOnboardingPromptType Type { get; set; } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptType.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptType.cs new file mode 100644 index 0000000..3b5357b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingPromptType.cs @@ -0,0 +1,17 @@ +namespace Discord; + +/// +/// Represents the guild onboarding option type. +/// +public enum GuildOnboardingPromptType +{ + /// + /// The prompt accepts multiple choices. + /// + MultipleChoice = 0, + + /// + /// The prompt uses a dropdown menu. + /// + Dropdown = 1, +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingProperties.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingProperties.cs new file mode 100644 index 0000000..62fe1e5 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/GuildOnboardingProperties.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents properties used to create or modify guild onboarding. +/// +public class GuildOnboardingProperties +{ + /// + /// Gets or sets prompts shown during onboarding and in customize community. + /// + public Optional Prompts { get; set; } + + /// + /// Gets or sets channel IDs that members get opted into automatically. + /// + public Optional ChannelIds { get; set; } + + /// + /// Gets or sets whether onboarding is enabled in the guild. + /// + public Optional IsEnabled { get; set; } + + /// + /// Gets or sets current mode of onboarding. + /// + public Optional Mode { get; set;} +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboarding.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboarding.cs new file mode 100644 index 0000000..5107524 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboarding.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord; + +/// +/// Represents the guild onboarding flow. +/// +public interface IGuildOnboarding +{ + /// + /// Gets the ID of the guild this onboarding is part of. + /// + ulong GuildId { get; } + + /// + /// Gets the guild this onboarding is part of. + /// + IGuild Guild { get; } + + /// + /// Gets prompts shown during onboarding and in customize community. + /// + IReadOnlyCollection Prompts { get; } + + /// + /// Gets IDs of channels that members get opted into automatically. + /// + IReadOnlyCollection DefaultChannelIds { get; } + + /// + /// Gets whether onboarding is enabled in the guild. + /// + bool IsEnabled { get; } + + /// + /// Gets the current mode of onboarding. + /// + GuildOnboardingMode Mode { get; } + + /// + /// Gets whether the server does not meet requirements to enable guild onboarding. + /// + bool IsBelowRequirements { get; } + + /// + /// Modifies the onboarding object. + /// + Task ModifyAsync(Action props, RequestOptions options = null); +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboardingPrompt.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboardingPrompt.cs new file mode 100644 index 0000000..9a9aa01 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboardingPrompt.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the guild onboarding prompt. +/// +public interface IGuildOnboardingPrompt : ISnowflakeEntity +{ + /// + /// Gets options available within the prompt. + /// + IReadOnlyCollection Options { get; } + + /// + /// Gets the title of the prompt. + /// + string Title { get; } + + /// + /// Indicates whether users are limited to selecting one option for the prompt. + /// + bool IsSingleSelect { get; } + + /// + /// Indicates whether the prompt is required before a user completes the onboarding flow. + /// + bool IsRequired { get; } + + /// + /// Indicates whether the prompt is present in the onboarding flow. + /// If , the prompt will only appear in the Channels and Roles tab. + /// + bool IsInOnboarding { get; } + + /// + /// Gets the type of the prompt. + /// + GuildOnboardingPromptType Type { get; } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboardingPromptOption.cs b/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboardingPromptOption.cs new file mode 100644 index 0000000..b7cd01b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/Onboarding/IGuildOnboardingPromptOption.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the guild onboarding prompt option. +/// +public interface IGuildOnboardingPromptOption : ISnowflakeEntity +{ + /// + /// Gets IDs of channels a member is added to when the option is selected. + /// + IReadOnlyCollection ChannelIds { get; } + + /// + /// Gets IDs of roles assigned to a member when the option is selected. + /// + IReadOnlyCollection RoleIds { get; } + + /// + /// Gets the emoji of the option. if none is set. + /// + IEmote Emoji { get; } + + /// + /// Gets the title of the option. + /// + string Title { get; } + + /// + /// Gets the description of the option. if none is set. + /// + string Description { get; } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/PartialGuild.cs b/src/Discord.Net.Core/Entities/Guilds/PartialGuild.cs new file mode 100644 index 0000000..668f5bd --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/PartialGuild.cs @@ -0,0 +1,147 @@ +using System; + +namespace Discord; + +/// +/// Represents a partial guild object. +/// +/// +/// Most of the fields can have value. +/// +public class PartialGuild : ISnowflakeEntity +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public ulong Id { get; internal set; } + + /// + /// Gets the name of this guild. + /// + /// + /// A string containing the name of this guild. + /// + public string Name { get; internal set; } + + /// + /// Gets the description for the guild. + /// + /// + /// The description for the guild; if none is set. + /// + public string Description { get; internal set; } + + /// + /// Gets the ID of this guild's splash image. + /// + /// + /// An identifier for the splash image; if none is set. + /// + public string SplashId { get; internal set; } + + /// + /// Gets the URL of this guild's splash image. + /// + /// + /// A URL pointing to the guild's splash image; if none is set. + /// + public string SplashUrl => CDN.GetGuildSplashUrl(Id, SplashId); + + /// + /// Gets the identifier for this guilds banner image. + /// + /// + /// An identifier for the banner image; if none is set. + /// + public string BannerId { get; internal set; } + + /// + /// Gets the URL of this guild's banner image. + /// + /// + /// A URL pointing to the guild's banner image; if none is set. + /// + public string BannerUrl => CDN.GetGuildBannerUrl(Id, BannerId, ImageFormat.Auto); + + /// + /// Gets the features for this guild. + /// + /// + /// A flags enum containing all the features for the guild. + /// + public GuildFeatures Features { get; internal set; } + + /// + /// Gets the ID of this guild's icon. + /// + /// + /// An identifier for the splash image; if none is set. + /// + public string IconId { get; internal set; } + + /// + /// Gets the URL of this guild's icon. + /// + /// + /// A URL pointing to the guild's icon; if none is set. + /// + public string IconUrl => CDN.GetGuildIconUrl(Id, IconId); + + /// + /// + /// Gets the level of requirements a user must fulfill before being allowed to post messages in this guild. + /// + /// + /// The level of requirements. if none is was returned. + /// + public VerificationLevel? VerificationLevel { get; internal set; } + + /// + /// Gets the code for this guild's vanity invite URL. + /// + /// + /// A string containing the vanity invite code for this guild; if none is set. + /// + public string VanityURLCode { get; internal set; } + + /// + /// Gets the number of premium subscribers of this guild. + /// + /// + /// This is the number of users who have boosted this guild. + /// + /// + /// The number of premium subscribers of this guild; if none was returned. + /// + public int? PremiumSubscriptionCount { get; internal set; } + + /// + /// Gets the NSFW level of this guild. + /// + /// + /// The NSFW level of this guild. if none was returned. + /// + public NsfwLevel? NsfwLevel { get; internal set; } + + /// + /// Gets the Welcome Screen of this guild + /// + /// + /// The welcome screen of this guild. if none is set. + /// + public WelcomeScreen WelcomeScreen { get; internal set; } + + /// + /// Gets the approximate member count in the guild. if none was returned. + /// + public int? ApproximateMemberCount { get; internal set; } + + /// + /// Gets the approximate presence count in the guild. if none was returned. + /// + public int? ApproximatePresenceCount { get; internal set; } + + internal PartialGuild() { } + +} diff --git a/src/Discord.Net.Core/Entities/Guilds/PermissionTarget.cs b/src/Discord.Net.Core/Entities/Guilds/PermissionTarget.cs new file mode 100644 index 0000000..fb759e4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/PermissionTarget.cs @@ -0,0 +1,17 @@ +namespace Discord +{ + /// + /// Specifies the target of the permission. + /// + public enum PermissionTarget + { + /// + /// The target of the permission is a role. + /// + Role = 0, + /// + /// The target of the permission is a user. + /// + User = 1, + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/PremiumTier.cs b/src/Discord.Net.Core/Entities/Guilds/PremiumTier.cs new file mode 100644 index 0000000..b7e4c93 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/PremiumTier.cs @@ -0,0 +1,22 @@ +namespace Discord +{ + public enum PremiumTier + { + /// + /// Used for guilds that have no guild boosts. + /// + None = 0, + /// + /// Used for guilds that have Tier 1 guild boosts. + /// + Tier1 = 1, + /// + /// Used for guilds that have Tier 2 guild boosts. + /// + Tier2 = 2, + /// + /// Used for guilds that have Tier 3 guild boosts. + /// + Tier3 = 3 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/SystemChannelMessageDeny.cs b/src/Discord.Net.Core/Entities/Guilds/SystemChannelMessageDeny.cs new file mode 100644 index 0000000..d50d680 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/SystemChannelMessageDeny.cs @@ -0,0 +1,40 @@ +using System; + +namespace Discord +{ + [Flags] + public enum SystemChannelMessageDeny + { + /// + /// Deny none of the system channel messages. + /// This will enable all of the system channel messages. + /// + None = 0, + /// + /// Deny the messages that are sent when a user joins the guild. + /// + WelcomeMessage = 1 << 0, + /// + /// Deny the messages that are sent when a user boosts the guild. + /// + GuildBoost = 1 << 1, + /// + /// Deny the messages that are related to guild setup. + /// + GuildSetupTip = 1 << 2, + /// + /// Deny the reply with sticker button on welcome messages. + /// + WelcomeMessageReply = 1 << 3, + + /// + /// Deny role subscription purchase and renewal notifications in the guild. + /// + RoleSubscriptionPurchase = 1 << 4, + + /// + /// Hide role subscription sticker reply buttons in the guild. + /// + RoleSubscriptionPurchaseReplies = 1 << 5, + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/VerificationLevel.cs b/src/Discord.Net.Core/Entities/Guilds/VerificationLevel.cs new file mode 100644 index 0000000..3a5ae04 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/VerificationLevel.cs @@ -0,0 +1,29 @@ +namespace Discord +{ + /// + /// Specifies the verification level the guild uses. + /// + public enum VerificationLevel + { + /// + /// Users have no additional restrictions on sending messages to this guild. + /// + None = 0, + /// + /// Users must have a verified email on their account. + /// + Low = 1, + /// + /// Users must fulfill the requirements of Low and be registered on Discord for at least 5 minutes. + /// + Medium = 2, + /// + /// Users must fulfill the requirements of Medium and be a member of this guild for at least 10 minutes. + /// + High = 3, + /// + /// Users must fulfill the requirements of High and must have a verified phone on their Discord account. + /// + Extreme = 4 + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/WelcomeScreen.cs b/src/Discord.Net.Core/Entities/Guilds/WelcomeScreen.cs new file mode 100644 index 0000000..4892560 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/WelcomeScreen.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord; + +public class WelcomeScreen +{ + /// + /// Gets the server description shown in the welcome screen. if not set. + /// + public string Description { get; } + + /// + /// Gets the channels shown in the welcome screen, up to 5 channels. + /// + public IReadOnlyCollection Channels { get; } + + internal WelcomeScreen(string description, IReadOnlyCollection channels) + { + Description = description; + + Channels = channels.ToImmutableArray(); + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/WelcomeScreenChannel.cs b/src/Discord.Net.Core/Entities/Guilds/WelcomeScreenChannel.cs new file mode 100644 index 0000000..431831f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/WelcomeScreenChannel.cs @@ -0,0 +1,41 @@ +using System; + +namespace Discord; + +public class WelcomeScreenChannel : ISnowflakeEntity +{ + /// + /// Gets the channel's id. + /// + public ulong Id { get; } + + /// + /// Gets the description shown for the channel. + /// + public string Description { get; } + + /// + /// Gets the emoji for this channel. if it is unicode emoji, if it is a custom one and if none is set. + /// + /// + /// If the emoji is only the will be populated. + /// Use to get the emoji. + /// + public IEmote Emoji { get; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + internal WelcomeScreenChannel(ulong id, string description, string emojiName = null, ulong? emoteId = null) + { + Id = id; + Description = description; + + if (emoteId.HasValue && emoteId.Value != 0) + Emoji = new Emote(emoteId.Value, emojiName, false); + else if (emojiName != null) + Emoji = new Emoji(emojiName); + else + Emoji = null; + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/WelcomeScreenChannelProperties.cs b/src/Discord.Net.Core/Entities/Guilds/WelcomeScreenChannelProperties.cs new file mode 100644 index 0000000..e4931a9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/WelcomeScreenChannelProperties.cs @@ -0,0 +1,54 @@ +using System; +using System.Xml.Linq; + +namespace Discord; + +public class WelcomeScreenChannelProperties : ISnowflakeEntity +{ + /// + /// Gets or sets the channel's id. + /// + public ulong Id { get; set; } + + /// + /// Gets or sets the description shown for the channel. + /// + public string Description { get; set; } + + /// + /// Gets or sets the emoji for this channel. if it is unicode emoji, if it is a custom one and if none is set. + /// + /// + /// If the emoji is only the will be populated. + /// Use to get the emoji. + /// + public IEmote Emoji { get; set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + /// Initializes a new instance of . + /// + /// Id if a channel. + /// Description for the channel in the welcome screen. + /// The emoji for the channel in the welcome screen. + public WelcomeScreenChannelProperties(ulong id, string description, IEmote emoji = null) + { + Id = id; + Description = description; + Emoji = emoji; + } + + /// + /// Initializes a new instance of . + /// + public WelcomeScreenChannelProperties() { } + /// + /// Initializes a new instance of . + /// + /// A welcome screen channel to modify. + /// A new instance of . + public static WelcomeScreenChannelProperties FromWelcomeScreenChannel(WelcomeScreenChannel channel) + => new(channel.Id, channel.Description, channel.Emoji); +} diff --git a/src/Discord.Net.Core/Entities/IDeletable.cs b/src/Discord.Net.Core/Entities/IDeletable.cs new file mode 100644 index 0000000..9696eb8 --- /dev/null +++ b/src/Discord.Net.Core/Entities/IDeletable.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Determines whether the object is deletable or not. + /// + public interface IDeletable + { + /// + /// Deletes this object and all its children. + /// + /// The options to be used when sending the request. + Task DeleteAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/IEntity.cs b/src/Discord.Net.Core/Entities/IEntity.cs new file mode 100644 index 0000000..0cd692a --- /dev/null +++ b/src/Discord.Net.Core/Entities/IEntity.cs @@ -0,0 +1,17 @@ +using System; + +namespace Discord +{ + public interface IEntity + where TId : IEquatable + { + ///// Gets the IDiscordClient that created this object. + //IDiscordClient Discord { get; } + + /// + /// Gets the unique identifier for this object. + /// + TId Id { get; } + + } +} diff --git a/src/Discord.Net.Core/Entities/IMentionable.cs b/src/Discord.Net.Core/Entities/IMentionable.cs new file mode 100644 index 0000000..2258067 --- /dev/null +++ b/src/Discord.Net.Core/Entities/IMentionable.cs @@ -0,0 +1,16 @@ +namespace Discord +{ + /// + /// Determines whether the object is mentionable or not. + /// + public interface IMentionable + { + /// + /// Returns a special string used to mention this object. + /// + /// + /// A string that is recognized by Discord as a mention (e.g. <@168693960628371456>). + /// + string Mention { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/ISnowflakeEntity.cs b/src/Discord.Net.Core/Entities/ISnowflakeEntity.cs new file mode 100644 index 0000000..6f2c751 --- /dev/null +++ b/src/Discord.Net.Core/Entities/ISnowflakeEntity.cs @@ -0,0 +1,16 @@ +using System; + +namespace Discord +{ + /// Represents a Discord snowflake entity. + public interface ISnowflakeEntity : IEntity + { + /// + /// Gets when the snowflake was created. + /// + /// + /// A representing when the entity was first created. + /// + DateTimeOffset CreatedAt { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/IUpdateable.cs b/src/Discord.Net.Core/Entities/IUpdateable.cs new file mode 100644 index 0000000..d561e57 --- /dev/null +++ b/src/Discord.Net.Core/Entities/IUpdateable.cs @@ -0,0 +1,16 @@ +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Defines whether the object is updateable or not. + /// + public interface IUpdateable + { + /// + /// Updates this object's properties with its current state. + /// + /// The options to be used when sending the request. + Task UpdateAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Image.cs b/src/Discord.Net.Core/Entities/Image.cs new file mode 100644 index 0000000..2043091 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Image.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; + +namespace Discord +{ + /// + /// An image that will be uploaded to Discord. + /// + public struct Image : IDisposable + { + private bool _isDisposed; + + /// + /// Gets the stream to be uploaded to Discord. + /// +#pragma warning disable IDISP008 + public Stream Stream { get; } +#pragma warning restore IDISP008 + /// + /// Create the image with a . + /// + /// + /// The to create the image with. Note that this must be some type of stream + /// with the contents of a file in it. + /// + public Image(Stream stream) + { + _isDisposed = false; + Stream = stream; + } + + /// + /// Create the image from a file path. + /// + /// + /// This file path is NOT validated and is passed directly into a + /// . + /// + /// The path to the file. + /// + /// is a zero-length string, contains only white space, or contains one or more invalid + /// characters as defined by . + /// + /// is . + /// + /// The specified path, file name, or both exceed the system-defined maximum length. For example, on + /// Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 + /// characters. + /// + /// is in an invalid format. + /// + /// The specified is invalid, (for example, it is on an unmapped drive). + /// + /// + /// specified a directory.-or- The caller does not have the required permission. + /// + /// The file specified in was not found. + /// + /// An I/O error occurred while opening the file. + public Image(string path) + { + _isDisposed = false; + Stream = File.OpenRead(path); + } + + /// + public void Dispose() + { + if (!_isDisposed) + { +#pragma warning disable IDISP007 + Stream?.Dispose(); +#pragma warning restore IDISP007 + + _isDisposed = true; + } + } + } +} diff --git a/src/Discord.Net.Core/Entities/ImageFormat.cs b/src/Discord.Net.Core/Entities/ImageFormat.cs new file mode 100644 index 0000000..9c04328 --- /dev/null +++ b/src/Discord.Net.Core/Entities/ImageFormat.cs @@ -0,0 +1,29 @@ +namespace Discord +{ + /// + /// Specifies the type of format the image should return in. + /// + public enum ImageFormat + { + /// + /// Use automatically detected format. + /// + Auto, + /// + /// Use Google's WebP image format. + /// + WebP, + /// + /// Use PNG. + /// + Png, + /// + /// Use JPEG. + /// + Jpeg, + /// + /// Use GIF. + /// + Gif, + } +} diff --git a/src/Discord.Net.Core/Entities/Integrations/IIntegration.cs b/src/Discord.Net.Core/Entities/Integrations/IIntegration.cs new file mode 100644 index 0000000..11d0579 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Integrations/IIntegration.cs @@ -0,0 +1,97 @@ +using System; + +namespace Discord +{ + /// + /// Holds information for an integration feature. + /// Nullable fields not provided for Discord bot integrations, but are for Twitch etc. + /// + public interface IIntegration + { + /// + /// Gets the integration ID. + /// + /// + /// A representing the unique identifier value of this integration. + /// + ulong Id { get; } + /// + /// Gets the integration name. + /// + /// + /// A string containing the name of this integration. + /// + string Name { get; } + /// + /// Gets the integration type (Twitch, YouTube, etc). + /// + /// + /// A string containing the name of the type of integration. + /// + string Type { get; } + /// + /// Gets a value that indicates whether this integration is enabled or not. + /// + /// + /// if this integration is enabled; otherwise . + /// + bool IsEnabled { get; } + /// + /// Gets a value that indicates whether this integration is syncing or not. + /// + /// + /// An integration with syncing enabled will update its "subscribers" on an interval, while one with syncing + /// disabled will not. A user must manually choose when sync the integration if syncing is disabled. + /// + /// + /// if this integration is syncing; otherwise . + /// + bool? IsSyncing { get; } + /// + /// Gets the ID that this integration uses for "subscribers". + /// + ulong? RoleId { get; } + /// + /// Gets whether emoticons should be synced for this integration (twitch only currently). + /// + bool? HasEnabledEmoticons { get; } + /// + /// Gets the behavior of expiring subscribers. + /// + IntegrationExpireBehavior? ExpireBehavior { get; } + /// + /// Gets the grace period before expiring "subscribers". + /// + int? ExpireGracePeriod { get; } + /// + /// Gets the user for this integration. + /// + IUser User { get; } + /// + /// Gets integration account information. + /// + IIntegrationAccount Account { get; } + /// + /// Gets when this integration was last synced. + /// + /// + /// A containing a date and time of day when the integration was last synced. + /// + DateTimeOffset? SyncedAt { get; } + /// + /// Gets how many subscribers this integration has. + /// + int? SubscriberCount { get; } + /// + /// Gets whether this integration been revoked. + /// + bool? IsRevoked { get; } + /// + /// Gets the bot/OAuth2 application for a discord integration. + /// + IIntegrationApplication Application { get; } + + IGuild Guild { get; } + ulong GuildId { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Integrations/IIntegrationAccount.cs b/src/Discord.Net.Core/Entities/Integrations/IIntegrationAccount.cs new file mode 100644 index 0000000..322ffa5 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Integrations/IIntegrationAccount.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + /// + /// Provides the account information for an . + /// + public interface IIntegrationAccount + { + /// + /// Gets the ID of the account. + /// + /// + /// A unique identifier of this integration account. + /// + string Id { get; } + /// + /// Gets the name of the account. + /// + /// + /// A string containing the name of this integration account. + /// + string Name { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Integrations/IIntegrationApplication.cs b/src/Discord.Net.Core/Entities/Integrations/IIntegrationApplication.cs new file mode 100644 index 0000000..9085ae6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Integrations/IIntegrationApplication.cs @@ -0,0 +1,33 @@ +namespace Discord +{ + /// + /// Provides the bot/OAuth2 application for an . + /// + public interface IIntegrationApplication + { + /// + /// Gets the id of the app. + /// + ulong Id { get; } + /// + /// Gets the name of the app. + /// + string Name { get; } + /// + /// Gets the icon hash of the app. + /// + string Icon { get; } + /// + /// Gets the description of the app. + /// + string Description { get; } + /// + /// Gets the summary of the app. + /// + string Summary { get; } + /// + /// Gets the bot associated with this application. + /// + IUser Bot { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Integrations/IntegrationExpireBehavior.cs b/src/Discord.Net.Core/Entities/Integrations/IntegrationExpireBehavior.cs new file mode 100644 index 0000000..642e247 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Integrations/IntegrationExpireBehavior.cs @@ -0,0 +1,17 @@ +namespace Discord +{ + /// + /// The behavior of expiring subscribers for an . + /// + public enum IntegrationExpireBehavior + { + /// + /// Removes a role from an expired subscriber. + /// + RemoveRole = 0, + /// + /// Kicks an expired subscriber from the guild. + /// + Kick = 1 + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOption.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOption.cs new file mode 100644 index 0000000..9ef1802 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOption.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Discord +{ + /// + /// Represents a for making slash commands. + /// + public class ApplicationCommandOptionProperties + { + private string _name; + private string _description; + private IDictionary _nameLocalizations = new Dictionary(); + private IDictionary _descriptionLocalizations = new Dictionary(); + + /// + /// Gets or sets the name of this option. + /// + public string Name + { + get => _name; + set + { + EnsureValidOptionName(value); + _name = value; + } + } + + /// + /// Gets or sets the description of this option. + /// + public string Description + { + get => _description; + set + { + EnsureValidOptionDescription(value); + _description = value; + } + } + + /// + /// Gets or sets the type of this option. + /// + public ApplicationCommandOptionType Type { get; set; } + + /// + /// Gets or sets whether or not this options is the first required option for the user to complete. only one option can be default. + /// + public bool? IsDefault { get; set; } + + /// + /// Gets or sets if the option is required. + /// + public bool? IsRequired { get; set; } + + /// + /// Gets or sets whether or not this option supports autocomplete. + /// + public bool IsAutocomplete { get; set; } + + /// + /// Gets or sets the smallest number value the user can input. + /// + public double? MinValue { get; set; } + + /// + /// Gets or sets the largest number value the user can input. + /// + public double? MaxValue { get; set; } + + /// + /// Gets or sets the minimum allowed length for a string input. + /// + public int? MinLength { get; set; } + + /// + /// Gets or sets the maximum allowed length for a string input. + /// + public int? MaxLength { get; set; } + + /// + /// Gets or sets the choices for string and int types for the user to pick from. + /// + public List Choices { get; set; } + + /// + /// Gets or sets if this option is a subcommand or subcommand group type, these nested options will be the parameters. + /// + public List Options { get; set; } + + /// + /// Gets or sets the allowed channel types for this option. + /// + public List ChannelTypes { get; set; } + + /// + /// Gets or sets the localization dictionary for the name field of this option. + /// + /// Thrown when any of the dictionary keys is an invalid locale. + public IDictionary NameLocalizations + { + get => _nameLocalizations; + set + { + if (value != null) + { + foreach (var (locale, name) in value) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidOptionName(name); + } + } + + _nameLocalizations = value; + } + } + + /// + /// Gets or sets the localization dictionary for the description field of this option. + /// + /// Thrown when any of the dictionary keys is an invalid locale. + public IDictionary DescriptionLocalizations + { + get => _descriptionLocalizations; + set + { + if (value != null) + { + foreach (var (locale, description) in value) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidOptionDescription(description); + } + } + + _descriptionLocalizations = value; + } + } + + private static void EnsureValidOptionName(string name) + { + if (name == null) + throw new ArgumentNullException(nameof(name), $"{nameof(Name)} cannot be null."); + + if (name.Length > 32) + throw new ArgumentOutOfRangeException(nameof(name), "Name length must be less than or equal to 32."); + + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + + if (name.Any(char.IsUpper)) + throw new FormatException("Name cannot contain any uppercase characters."); + } + + private static void EnsureValidOptionDescription(string description) + { + switch (description.Length) + { + case > 100: + throw new ArgumentOutOfRangeException(nameof(description), + "Description length must be less than or equal to 100."); + case 0: + throw new ArgumentOutOfRangeException(nameof(description), "Description length must at least 1."); + } + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOptionChoice.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOptionChoice.cs new file mode 100644 index 0000000..2289b41 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOptionChoice.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Discord +{ + /// + /// Represents a choice for a . This class is used when making new commands. + /// + public class ApplicationCommandOptionChoiceProperties + { + private string _name; + private object _value; + private IDictionary _nameLocalizations = new Dictionary(); + + /// + /// Gets or sets the name of this choice. + /// + public string Name + { + get => _name; + set => _name = value?.Length switch + { + > 100 => throw new ArgumentOutOfRangeException(nameof(value), "Name length must be less than or equal to 100."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Name length must at least 1."), + _ => value + }; + } + + /// + /// Gets the value of this choice. + /// + /// Discord only accepts int, double/floats, and string as the input. + /// + /// + public object Value + { + get => _value; + set + { + if (value != null && value is not string && !value.IsNumericType()) + throw new ArgumentException("The value of a choice must be a string or a numeric type!"); + _value = value; + } + } + + /// + /// Gets or sets the localization dictionary for the name field of this choice. + /// + /// Thrown when any of the dictionary keys is an invalid locale. + public IDictionary NameLocalizations + { + get => _nameLocalizations; + set + { + if (value != null) + { + foreach (var (locale, name) in value) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException("Key values of the dictionary must be valid language codes."); + + switch (name.Length) + { + case > 100: + throw new ArgumentOutOfRangeException(nameof(value), + "Name length must be less than or equal to 100."); + case 0: + throw new ArgumentOutOfRangeException(nameof(value), "Name length must at least 1."); + } + } + } + + _nameLocalizations = value; + } + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOptionType.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOptionType.cs new file mode 100644 index 0000000..2bad7fc --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandOptionType.cs @@ -0,0 +1,63 @@ +namespace Discord +{ + /// + /// The option type of the Slash command parameter, See the discord docs. + /// + public enum ApplicationCommandOptionType : byte + { + /// + /// A sub command. + /// + SubCommand = 1, + + /// + /// A group of sub commands. + /// + SubCommandGroup = 2, + + /// + /// A of text. + /// + String = 3, + + /// + /// An . + /// + Integer = 4, + + /// + /// A . + /// + Boolean = 5, + + /// + /// A . + /// + User = 6, + + /// + /// A . + /// + Channel = 7, + + /// + /// A . + /// + Role = 8, + + /// + /// A or . + /// + Mentionable = 9, + + /// + /// A . + /// + Number = 10, + + /// + /// A . + /// + Attachment = 11 + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandProperties.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandProperties.cs new file mode 100644 index 0000000..9166169 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandProperties.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Discord +{ + /// + /// Represents the base class to create/modify application commands. + /// + public abstract class ApplicationCommandProperties + { + private IReadOnlyDictionary _nameLocalizations; + private IReadOnlyDictionary _descriptionLocalizations; + + internal abstract ApplicationCommandType Type { get; } + + /// + /// Gets or sets the name of this command. + /// + public Optional Name { get; set; } + + /// + /// Gets or sets whether the command is enabled by default when the app is added to a guild. Default is + /// + public Optional IsDefaultPermission { get; set; } + + /// + /// Gets or sets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations + { + get => _nameLocalizations; + set + { + if (value != null) + { + foreach (var (locale, name) in value) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, SlashCommandBuilder.MaxNameLength, nameof(name)); + + if (Type == ApplicationCommandType.Slash && !Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + } + } + + _nameLocalizations = value; + } + } + + /// + /// Gets or sets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations + { + get => _descriptionLocalizations; + set + { + if (value != null) + { + foreach (var (locale, description) in value) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + Preconditions.AtLeast(description.Length, 1, nameof(description)); + Preconditions.AtMost(description.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(description)); + } + } + + _descriptionLocalizations = value; + } + } + + /// + /// Gets or sets whether or not this command can be used in DMs. + /// + public Optional IsDMEnabled { get; set; } + + /// + /// Gets or sets whether or not this command is age restricted. + /// + public Optional IsNsfw { get; set; } + + /// + /// Gets or sets the default permissions required by a user to execute this application command. + /// + public Optional DefaultMemberPermissions { get; set; } + + /// + /// Gets or sets the install method for this command. + /// + public Optional> IntegrationTypes { get; set; } + + /// + /// Gets or sets context types this command can be executed in. + /// + public Optional> ContextTypes { get; set; } + + internal ApplicationCommandProperties() { } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandTypes.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandTypes.cs new file mode 100644 index 0000000..8cd31a4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/ApplicationCommandTypes.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + /// + /// Represents the types of application commands. + /// + public enum ApplicationCommandType : byte + { + /// + /// A Slash command type + /// + Slash = 1, + + /// + /// A Context Menu User command type + /// + User = 2, + + /// + /// A Context Menu Message command type + /// + Message = 3 + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommand.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommand.cs new file mode 100644 index 0000000..60a288a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommand.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// The base command model that belongs to an application. + /// + public interface IApplicationCommand : ISnowflakeEntity, IDeletable + { + /// + /// Gets the unique id of the parent application. + /// + ulong ApplicationId { get; } + + /// + /// Gets the type of the command. + /// + ApplicationCommandType Type { get; } + + /// + /// Gets the name of the command. + /// + string Name { get; } + + /// + /// Gets the description of the command. + /// + string Description { get; } + + /// + /// Gets whether the command is enabled by default when the app is added to a guild. + /// + bool IsDefaultPermission { get; } + + /// + /// Indicates whether the command is available in DMs with the app. + /// + /// + /// Only for globally-scoped commands. + /// + [Obsolete("This property will be deprecated soon. Use ContextTypes instead.")] + bool IsEnabledInDm { get; } + + /// + /// Indicates whether the command is age restricted. + /// + bool IsNsfw { get; } + + /// + /// Set of default required to invoke the command. + /// + GuildPermissions DefaultMemberPermissions { get; } + + /// + /// Gets a collection of options for this application command. + /// + IReadOnlyCollection Options { get; } + + /// + /// Gets the localization dictionary for the name field of this command. + /// + IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localization dictionary for the description field of this command. + /// + IReadOnlyDictionary DescriptionLocalizations { get; } + + /// + /// Gets the localized name of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string NameLocalized { get; } + + /// + /// Gets the localized description of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string DescriptionLocalized { get; } + + /// + /// Gets context types the command can be used in; if not specified. + /// + IReadOnlyCollection ContextTypes { get; } + + /// + /// Gets the install method for the command; if not specified. + /// + IReadOnlyCollection IntegrationTypes { get; } + + /// + /// Modifies the current application command. + /// + /// The new properties to use when modifying the command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Modifies the current application command. + /// + /// The new properties to use when modifying the command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + /// Thrown when you pass in an invalid type. + Task ModifyAsync(Action func, RequestOptions options = null) + where TArg : ApplicationCommandProperties; + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteraction.cs new file mode 100644 index 0000000..b079a47 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteraction.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents an application command interaction. + /// + public interface IApplicationCommandInteraction : IDiscordInteraction + { + /// + /// Gets the data of the application command interaction + /// + new IApplicationCommandInteractionData Data { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteractionData.cs new file mode 100644 index 0000000..428f20f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteractionData.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents data of an Interaction Command, see . + /// + public interface IApplicationCommandInteractionData : IDiscordInteractionData + { + /// + /// Gets the snowflake id of this command. + /// + ulong Id { get; } + + /// + /// Gets the name of this command. + /// + string Name { get; } + + /// + /// Gets the options that the user has provided. + /// + IReadOnlyCollection Options { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteractionDataOption.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteractionDataOption.cs new file mode 100644 index 0000000..072d2b3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandInteractionDataOption.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a option group for a command. + /// + public interface IApplicationCommandInteractionDataOption + { + /// + /// Gets the name of the parameter. + /// + string Name { get; } + + /// + /// Gets the value of the pair. + /// + /// This objects type can be any one of the option types in . + /// + /// + object Value { get; } + + /// + /// Gets the type of this data's option. + /// + ApplicationCommandOptionType Type { get; } + + /// + /// Gets the nested options of this option. + /// + IReadOnlyCollection Options { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandOption.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandOption.cs new file mode 100644 index 0000000..fb179b6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandOption.cs @@ -0,0 +1,101 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Options for the . + /// + public interface IApplicationCommandOption + { + /// + /// Gets the type of this . + /// + ApplicationCommandOptionType Type { get; } + + /// + /// Gets the name of this command option. + /// + string Name { get; } + + /// + /// Gets the description of this command option. + /// + string Description { get; } + + /// + /// Gets whether or not this is the first required option for the user to complete. + /// + bool? IsDefault { get; } + + /// + /// Gets whether or not the parameter is required or optional. + /// + bool? IsRequired { get; } + + /// + /// Gets whether or not the option has autocomplete enabled. + /// + bool? IsAutocomplete { get; } + + /// + /// Gets the smallest number value the user can input. + /// + double? MinValue { get; } + + /// + /// Gets the largest number value the user can input. + /// + double? MaxValue { get; } + + /// + /// Gets the minimum allowed length for a string input. + /// + int? MinLength { get; } + + /// + /// Gets the maximum allowed length for a string input. + /// + int? MaxLength { get; } + + /// + /// Gets the choices for string and int types for the user to pick from. + /// + IReadOnlyCollection Choices { get; } + + /// + /// Gets the sub-options for this command option. + /// + IReadOnlyCollection Options { get; } + + /// + /// Gets the allowed channel types for this option. + /// + IReadOnlyCollection ChannelTypes { get; } + + /// + /// Gets the localization dictionary for the name field of this command option. + /// + IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localization dictionary for the description field of this command option. + /// + IReadOnlyDictionary DescriptionLocalizations { get; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string NameLocalized { get; } + + /// + /// Gets the localized description of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to true when requesting the command. + /// + string DescriptionLocalized { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandOptionChoice.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandOptionChoice.cs new file mode 100644 index 0000000..3f76bae --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/IApplicationCommandOptionChoice.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Specifies choices for command group. + /// + public interface IApplicationCommandOptionChoice + { + /// + /// Gets the choice name. + /// + string Name { get; } + + /// + /// Gets the value of the choice. + /// + object Value { get; } + + /// + /// Gets the localization dictionary for the name field of this command option. + /// + IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + string NameLocalized { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/InteractionContextType.cs b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/InteractionContextType.cs new file mode 100644 index 0000000..ac4e0b2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ApplicationCommands/InteractionContextType.cs @@ -0,0 +1,22 @@ +namespace Discord; + +/// +/// Represents a context in Discord where an interaction can be used. +/// +public enum InteractionContextType +{ + /// + /// The command can be used in guilds. + /// + Guild = 0, + + /// + /// The command can be used in DM channel with the bot. + /// + BotDm = 1, + + /// + /// The command can be used in private channels. + /// + PrivateChannel = 2 +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Autocomplete/AutocompleteOption.cs b/src/Discord.Net.Core/Entities/Interactions/Autocomplete/AutocompleteOption.cs new file mode 100644 index 0000000..eb22a9d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Autocomplete/AutocompleteOption.cs @@ -0,0 +1,36 @@ +namespace Discord +{ + /// + /// Represents an autocomplete option. + /// + public class AutocompleteOption + { + /// + /// Gets the type of this option. + /// + public ApplicationCommandOptionType Type { get; } + + /// + /// Gets the name of the option. + /// + public string Name { get; } + + /// + /// Gets the value of the option. + /// + public object Value { get; } + + /// + /// Gets whether or not this option is focused by the executing user. + /// + public bool Focused { get; } + + internal AutocompleteOption(ApplicationCommandOptionType type, string name, object value, bool focused) + { + Type = type; + Name = name; + Value = value; + Focused = focused; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Autocomplete/AutocompleteResult.cs b/src/Discord.Net.Core/Entities/Interactions/Autocomplete/AutocompleteResult.cs new file mode 100644 index 0000000..6b28a84 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Autocomplete/AutocompleteResult.cs @@ -0,0 +1,73 @@ +using System; + +namespace Discord +{ + /// + /// Represents a result to an autocomplete interaction. + /// + public class AutocompleteResult + { + private object _value; + private string _name; + + /// + /// Gets or sets the name of the result. + /// + /// + /// Name cannot be null and has to be between 1-100 characters in length. + /// + /// + /// + public string Name + { + get => _name; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value), $"{nameof(Name)} cannot be null."); + _name = value.Length switch + { + > 100 => throw new ArgumentOutOfRangeException(nameof(value), "Name length must be less than or equal to 100."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Name length must be at least 1."), + _ => value + }; + } + } + + /// + /// Gets or sets the value of the result. + /// + /// + /// Only , , and are allowed for a value. + /// + /// + /// + public object Value + { + get => _value; + set + { + if (value is not string && !value.IsNumericType()) + throw new ArgumentException($"{nameof(value)} must be a numeric type or a string!"); + + _value = value; + } + } + + /// + /// Creates a new . + /// + public AutocompleteResult() { } + + /// + /// Creates a new with the passed in and . + /// + /// + /// + public AutocompleteResult(string name, object value) + { + Name = name; + Value = value; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteraction.cs new file mode 100644 index 0000000..07d66bf --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteraction.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + /// + /// Represents a Message Command interaction. + /// + public interface IMessageCommandInteraction : IApplicationCommandInteraction + { + /// + /// Gets the data associated with this interaction. + /// + new IMessageCommandInteractionData Data { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteractionData.cs new file mode 100644 index 0000000..311eef2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IMessageCommandInteractionData.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + /// + /// Represents the data tied with the interaction. + /// + public interface IMessageCommandInteractionData : IApplicationCommandInteractionData + { + /// + /// Gets the message associated with this message command. + /// + IMessage Message { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteraction.cs new file mode 100644 index 0000000..2ffdfd9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteraction.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + /// + /// Represents a User Command interaction. + /// + public interface IUserCommandInteraction : IApplicationCommandInteraction + { + /// + /// Gets the data associated with this interaction. + /// + new IUserCommandInteractionData Data { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteractionData.cs new file mode 100644 index 0000000..36e482e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/IUserCommandInteractionData.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + /// + /// Represents the data tied with the interaction. + /// + public interface IUserCommandInteractionData : IApplicationCommandInteractionData + { + /// + /// Gets the user who this command targets. + /// + IUser User { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs new file mode 100644 index 0000000..2aebee6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandBuilder.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Discord +{ + /// + /// A class used to build Message commands. + /// + public class MessageCommandBuilder + { + /// + /// Returns the maximum length a commands name allowed by Discord + /// + public const int MaxNameLength = 32; + + /// + /// Gets or sets the name of this Message command. + /// + public string Name + { + get => _name; + set + { + Preconditions.NotNullOrEmpty(value, nameof(Name)); + Preconditions.AtLeast(value.Length, 1, nameof(Name)); + Preconditions.AtMost(value.Length, MaxNameLength, nameof(Name)); + + _name = value; + } + } + + /// + /// Gets or sets whether the command is enabled by default when the app is added to a guild + /// + public bool IsDefaultPermission { get; set; } = true; + + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + + /// + /// Gets or sets whether or not this command can be used in DMs. + /// + [Obsolete("This property will be deprecated soon. Configure with ContextTypes instead.")] + public bool IsDMEnabled { get; set; } = true; + + /// + /// Gets or sets whether or not this command is age restricted. + /// + public bool IsNsfw { get; set; } = false; + + /// + /// Gets or sets the default permission required to use this slash command. + /// + public GuildPermission? DefaultMemberPermissions { get; set; } + + /// + /// Gets the install method for this command; if not specified. + /// + public HashSet IntegrationTypes { get; set; } = null; + + /// + /// Gets the context types this command can be executed in; if not specified. + /// + public HashSet ContextTypes { get; set; } = null; + + private string _name; + private Dictionary _nameLocalizations; + + /// + /// Build the current builder into a class. + /// + /// + /// A that can be used to create message commands. + /// + public MessageCommandProperties Build() + { + var props = new MessageCommandProperties + { + Name = Name, + IsDefaultPermission = IsDefaultPermission, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = IsDMEnabled, +#pragma warning restore CS0618 // Type or member is obsolete + DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified, + NameLocalizations = NameLocalizations, + IsNsfw = IsNsfw, + IntegrationTypes = IntegrationTypes, + ContextTypes = ContextTypes + }; + + return props; + } + + /// + /// Sets the field name. + /// + /// The value to set the field name to. + /// + /// The current builder. + /// + public MessageCommandBuilder WithName(string name) + { + Name = name; + return this; + } + + /// + /// Sets the default permission of the current command. + /// + /// The default permission value to set. + /// The current builder. + public MessageCommandBuilder WithDefaultPermission(bool isDefaultPermission) + { + IsDefaultPermission = isDefaultPermission; + return this; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command. + /// + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + 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; + } + + /// + /// Sets whether or not this command can be used in dms. + /// + /// if the command is available in dms, otherwise . + /// The current builder. + [Obsolete("This method will be deprecated soon. Configure with WithContextTypes instead.")] + public MessageCommandBuilder WithDMPermission(bool permission) + { + IsDMEnabled = permission; + return this; + } + + /// + /// Sets whether or not this command is age restricted. + /// + /// if the command is age restricted, otherwise . + /// The current builder. + public MessageCommandBuilder WithNsfw(bool permission) + { + IsNsfw = permission; + return this; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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; + } + + private static void EnsureValidCommandName(string name) + { + Preconditions.NotNullOrEmpty(name, nameof(name)); + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, MaxNameLength, nameof(name)); + } + + /// + /// Sets the default member permissions required to use this application command. + /// + /// The permissions required to use this command. + /// The current builder. + public MessageCommandBuilder WithDefaultMemberPermissions(GuildPermission? permissions) + { + DefaultMemberPermissions = permissions; + return this; + } + + /// + /// Sets the install method for this command. + /// + /// Install types for this command. + /// The builder instance. + public MessageCommandBuilder WithIntegrationTypes(params ApplicationIntegrationType[] integrationTypes) + { + IntegrationTypes = integrationTypes is not null + ? new HashSet(integrationTypes) + : null; + return this; + } + + /// + /// Sets context types this command can be executed in. + /// + /// Context types the command can be executed in. + /// The builder instance. + public MessageCommandBuilder WithContextTypes(params InteractionContextType[] contextTypes) + { + ContextTypes = contextTypes is not null + ? new HashSet(contextTypes) + : null; + return this; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandProperties.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandProperties.cs new file mode 100644 index 0000000..356ed23 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/MessageCommandProperties.cs @@ -0,0 +1,10 @@ +namespace Discord +{ + /// + /// A class used to create message commands. + /// + public class MessageCommandProperties : ApplicationCommandProperties + { + internal override ApplicationCommandType Type => ApplicationCommandType.Message; + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs new file mode 100644 index 0000000..6c4b4bd --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandBuilder.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Discord +{ + /// + /// A class used to build user commands. + /// + public class UserCommandBuilder + { + /// + /// Returns the maximum length a commands name allowed by Discord. + /// + public const int MaxNameLength = 32; + + /// + /// Gets or sets the name of this User command. + /// + public string Name + { + get => _name; + set + { + Preconditions.NotNullOrEmpty(value, nameof(Name)); + Preconditions.AtLeast(value.Length, 1, nameof(Name)); + Preconditions.AtMost(value.Length, MaxNameLength, nameof(Name)); + + _name = value; + } + } + + /// + /// Gets or sets whether the command is enabled by default when the app is added to a guild. + /// + public bool IsDefaultPermission { get; set; } = true; + + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + + /// + /// Gets or sets whether or not this command can be used in DMs. + /// + [Obsolete("This property will be deprecated soon. Configure with ContextTypes instead.")] + public bool IsDMEnabled { get; set; } = true; + + /// + /// Gets or sets whether or not this command is age restricted. + /// + public bool IsNsfw { get; set; } = false; + + /// + /// Gets or sets the default permission required to use this slash command. + /// + public GuildPermission? DefaultMemberPermissions { get; set; } + + /// + /// Gets the installation method for this command. if not set. + /// + public HashSet IntegrationTypes { get; set; } + + /// + /// Gets the context types this command can be executed in. if not set. + /// + public HashSet ContextTypes { get; set; } + + private string _name; + private Dictionary _nameLocalizations; + + /// + /// Build the current builder into a class. + /// + /// A that can be used to create user commands. + public UserCommandProperties Build() + { + var props = new UserCommandProperties + { + Name = Name, + IsDefaultPermission = IsDefaultPermission, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = IsDMEnabled, +#pragma warning restore CS0618 // Type or member is obsolete + DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified, + NameLocalizations = NameLocalizations, + IsNsfw = IsNsfw, + ContextTypes = ContextTypes, + IntegrationTypes = IntegrationTypes + }; + + return props; + } + + /// + /// Sets the field name. + /// + /// The value to set the field name to. + /// + /// The current builder. + /// + public UserCommandBuilder WithName(string name) + { + Name = name; + return this; + } + + /// + /// Sets the default permission of the current command. + /// + /// The default permission value to set. + /// The current builder. + public UserCommandBuilder WithDefaultPermission(bool isDefaultPermission) + { + IsDefaultPermission = isDefaultPermission; + return this; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command. + /// The current builder. + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + 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; + } + + /// + /// Sets whether or not this command can be used in dms. + /// + /// if the command is available in dms, otherwise . + /// The current builder. + [Obsolete("This method will be deprecated soon. Configure with WithContextTypes instead.")] + public UserCommandBuilder WithDMPermission(bool permission) + { + IsDMEnabled = permission; + return this; + } + + /// + /// Sets whether or not this command is age restricted. + /// + /// if the command is age restricted, otherwise . + /// The current builder. + public UserCommandBuilder WithNsfw(bool permission) + { + IsNsfw = permission; + return this; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + 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; + } + + private static void EnsureValidCommandName(string name) + { + Preconditions.NotNullOrEmpty(name, nameof(name)); + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, MaxNameLength, nameof(name)); + } + + /// + /// Sets the default member permissions required to use this application command. + /// + /// The permissions required to use this command. + /// The current builder. + public UserCommandBuilder WithDefaultMemberPermissions(GuildPermission? permissions) + { + DefaultMemberPermissions = permissions; + return this; + } + + /// + /// Sets the installation method for this command. + /// + /// Installation types for this command. + /// The builder instance. + public UserCommandBuilder WithIntegrationTypes(params ApplicationIntegrationType[] integrationTypes) + { + IntegrationTypes = integrationTypes is not null + ? new HashSet(integrationTypes) + : null; + return this; + } + + /// + /// Sets context types this command can be executed in. + /// + /// Context types the command can be executed in. + /// The builder instance. + public UserCommandBuilder WithContextTypes(params InteractionContextType[] contextTypes) + { + ContextTypes = contextTypes is not null + ? new HashSet(contextTypes) + : null; + return this; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandProperties.cs b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandProperties.cs new file mode 100644 index 0000000..c42e916 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/ContextMenus/UserCommandProperties.cs @@ -0,0 +1,10 @@ +namespace Discord +{ + /// + /// A class used to create User commands. + /// + public class UserCommandProperties : ApplicationCommandProperties + { + internal override ApplicationCommandType Type => ApplicationCommandType.User; + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs new file mode 100644 index 0000000..73b7db9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteraction.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a discord interaction. + /// + public interface IDiscordInteraction : ISnowflakeEntity + { + /// + /// Gets the id of the interaction. + /// + new ulong Id { get; } + + /// + /// Gets the type of this . + /// + InteractionType Type { get; } + + /// + /// Gets the data sent within this interaction. + /// + IDiscordInteractionData Data { get; } + + /// + /// Gets the continuation token for responding to the interaction. + /// + string Token { get; } + + /// + /// Gets the version of the interaction, always 1. + /// + int Version { get; } + + /// + /// Gets whether or not this interaction has been responded to. + /// + /// + /// This property is locally set -- if you're running multiple bots + /// off the same token then this property won't be in sync with them. + /// + bool HasResponded { get; } + + /// + /// Gets the user who invoked the interaction. + /// + IUser User { get; } + + /// + /// Gets the preferred locale of the invoking User. + /// + /// + /// This property returns if the interaction is a REST ping interaction. + /// + string UserLocale { get; } + + /// + /// Gets the preferred locale of the guild this interaction was executed in. if not executed in a guild. + /// + /// + /// Non-community guilds (With no locale setting available) will have en-US as the default value sent by Discord. + /// + string GuildLocale { get; } + + /// + /// Gets whether or not this interaction was executed in a dm channel. + /// + bool IsDMInteraction { get; } + + /// + /// Gets the ID of the channel this interaction was executed in. + /// + /// + /// This property returns if the interaction is a REST ping interaction. + /// + ulong? ChannelId { get; } + + /// + /// Gets the ID of the guild this interaction was executed in. + /// + /// + /// This property returns if the interaction was not executed in a guild. + /// + ulong? GuildId { get; } + + /// + /// Gets the ID of the application this interaction is for. + /// + ulong ApplicationId { get; } + + /// + /// Gets entitlements for the invoking user. + /// + IReadOnlyCollection Entitlements { get; } + + /// + /// Gets which integrations authorized the interaction. + /// + IReadOnlyDictionary IntegrationOwners { get; } + + /// + /// Gets the context this interaction was created in. if context type is unknown. + /// + InteractionContextType? ContextType { get; } + + /// + /// Gets the permissions the app or bot has within the channel the interaction was sent from. + /// + GuildPermissions Permissions { get; } + + /// + /// Responds to an Interaction with type . + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. + /// + Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, + bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Responds to this interaction with a file attachment. + /// + /// The file to upload. + /// The file name of the attachment. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// +#if NETCOREAPP3_0_OR_GREATER + async Task RespondWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(fileStream, fileName)) + { + await RespondWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } +#else + Task RespondWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); +#endif + /// + /// Responds to this interaction with a file attachment. + /// + /// The file to upload. + /// The file name of the attachment. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// +#if NETCOREAPP3_0_OR_GREATER + async Task RespondWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(filePath, fileName)) + { + await RespondWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } +#else + Task RespondWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); +#endif + /// + /// Responds to this interaction with a file attachment. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// +#if NETCOREAPP3_0_OR_GREATER + Task RespondWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => RespondWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); +#else + Task RespondWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); +#endif + /// + /// Responds to this interaction with a collection of file attachments. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task RespondWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + /// + /// Sends a followup message for this interaction. + /// + /// The file to upload. + /// The file name of the attachment. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// +#if NETCOREAPP3_0_OR_GREATER + async Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(fileStream, fileName)) + { + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } +#else + Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); +#endif + /// + /// Sends a followup message for this interaction. + /// + /// The file to upload. + /// The file name of the attachment. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// +#if NETCOREAPP3_0_OR_GREATER + async Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(filePath, fileName)) + { + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } +#else + Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); +#endif + /// + /// Sends a followup message for this interaction. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// +#if NETCOREAPP3_0_OR_GREATER + Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); +#else + Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); +#endif + /// + /// Sends a followup message for this interaction. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + /// + /// Gets the original response for this interaction. + /// + /// The request options for this request. + /// A that represents the initial response. + Task GetOriginalResponseAsync(RequestOptions options = null); + /// + /// Edits original response for this interaction. + /// + /// A delegate containing the properties to modify the message with. + /// The request options for this request. + /// + /// A task that represents an asynchronous modification operation. The task result + /// contains the updated message. + /// + Task ModifyOriginalResponseAsync(Action func, RequestOptions options = null); + /// + /// Deletes the original response to this interaction. + /// + /// The request options for this request. + /// + /// A task that represents an asynchronous deletion operation. + /// + Task DeleteOriginalResponseAsync(RequestOptions options = null); + /// + /// Acknowledges this interaction. + /// + /// + /// A task that represents the asynchronous operation of deferring the interaction. + /// + Task DeferAsync(bool ephemeral = false, RequestOptions options = null); + + /// + /// Responds to the interaction with a modal. + /// + /// The modal to respond with. + /// The request options for this request. + /// A task that represents the asynchronous operation of responding to the interaction. + Task RespondWithModalAsync(Modal modal, RequestOptions options = null); + + /// + /// Responds to the interaction with an ephemeral message the invoking user, + /// instructing them that whatever they tried to do requires the premium benefits of your app. + /// + /// A task that represents the asynchronous operation of responding to the interaction. + Task RespondWithPremiumRequiredAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/IDiscordInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteractionData.cs new file mode 100644 index 0000000..42b9573 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/IDiscordInteractionData.cs @@ -0,0 +1,7 @@ +namespace Discord +{ + /// + /// Represents an interface used to specify classes that they are a valid data type of a class. + /// + public interface IDiscordInteractionData { } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs new file mode 100644 index 0000000..b52d6b0 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/InteractionResponseType.cs @@ -0,0 +1,56 @@ +using System; + +namespace Discord +{ + /// + /// The response type for an . + /// + /// + /// After receiving an interaction, you must respond to acknowledge it. You can choose to respond with a message immediately using + /// or you can choose to send a deferred response with . If choosing a deferred response, the user will see a loading state for the interaction, + /// and you'll have up to 15 minutes to edit the original deferred response using Edit Original Interaction Response. + /// You can read more about Response types Here. + /// + public enum InteractionResponseType : byte + { + /// + /// ACK a Ping. + /// + Pong = 1, + + /// + /// Respond to an interaction with a message. + /// + ChannelMessageWithSource = 4, + + /// + /// ACK an interaction and edit a response later, the user sees a loading state. + /// + DeferredChannelMessageWithSource = 5, + + /// + /// For components: ACK an interaction and edit the original message later; the user does not see a loading state. + /// + DeferredUpdateMessage = 6, + + /// + /// For components: edit the message the component was attached to. + /// + UpdateMessage = 7, + + /// + /// Respond with a set of choices to a autocomplete interaction. + /// + ApplicationCommandAutocompleteResult = 8, + + /// + /// Respond by showing the user a modal. + /// + Modal = 9, + + /// + /// Respond to an interaction with an upgrade button, only available for apps with monetization enabled. + /// + PremiumRequired = 10 + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/InteractionType.cs b/src/Discord.Net.Core/Entities/Interactions/InteractionType.cs new file mode 100644 index 0000000..811c8c7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/InteractionType.cs @@ -0,0 +1,33 @@ +namespace Discord +{ + /// + /// Represents a type of Interaction from discord. + /// + public enum InteractionType : byte + { + /// + /// A ping from discord. + /// + Ping = 1, + + /// + /// A sent from discord. + /// + ApplicationCommand = 2, + + /// + /// A sent from discord. + /// + MessageComponent = 3, + + /// + /// An autocomplete request sent from discord. + /// + ApplicationCommandAutocomplete = 4, + + /// + /// A modal sent from discord. + /// + ModalSubmit = 5, + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ActionRowComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ActionRowComponent.cs new file mode 100644 index 0000000..202a568 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ActionRowComponent.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a Row for child components to live in. + /// + public class ActionRowComponent : IMessageComponent + { + /// + public ComponentType Type => ComponentType.ActionRow; + + /// + /// Gets the child components in this row. + /// + public IReadOnlyCollection Components { get; internal set; } + + internal ActionRowComponent() { } + + internal ActionRowComponent(List components) + { + Components = components; + } + + string IMessageComponent.CustomId => null; + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ButtonComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ButtonComponent.cs new file mode 100644 index 0000000..4b9fa27 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ButtonComponent.cs @@ -0,0 +1,61 @@ +namespace Discord +{ + /// + /// Represents a Button. + /// + public class ButtonComponent : IMessageComponent + { + /// + public ComponentType Type => ComponentType.Button; + + /// + /// Gets the of this button, example buttons with each style can be found Here. + /// + public ButtonStyle Style { get; } + + /// + /// Gets the label of the button, this is the text that is shown. + /// + public string Label { get; } + + /// + /// Gets the displayed with this button. + /// + public IEmote Emote { get; } + + /// + public string CustomId { get; } + + /// + /// Gets the URL for a button. + /// + /// + /// You cannot have a button with a URL and a CustomId. + /// + public string Url { get; } + + /// + /// Gets whether this button is disabled or not. + /// + public bool IsDisabled { get; } + + /// + /// Turns this button into a button builder. + /// + /// + /// A newly created button builder with the same properties as this button. + /// + public ButtonBuilder ToBuilder() + => new ButtonBuilder(Label, CustomId, Style, Url, Emote, IsDisabled); + + internal ButtonComponent(ButtonStyle style, string label, IEmote emote, string customId, string url, bool isDisabled) + { + Style = style; + Label = label; + Emote = emote; + CustomId = customId; + Url = url; + IsDisabled = isDisabled; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ButtonStyle.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ButtonStyle.cs new file mode 100644 index 0000000..92d48ab --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ButtonStyle.cs @@ -0,0 +1,33 @@ +namespace Discord +{ + /// + /// Represents different styles to use with buttons. You can see an example of the different styles at + /// + public enum ButtonStyle + { + /// + /// A Blurple button + /// + Primary = 1, + + /// + /// A Grey (or gray) button + /// + Secondary = 2, + + /// + /// A Green button + /// + Success = 3, + + /// + /// A Red button + /// + Danger = 4, + + /// + /// A button with a little popup box indicating that this button is a link. + /// + Link = 5 + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs new file mode 100644 index 0000000..1af4741 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentBuilder.cs @@ -0,0 +1,1634 @@ +using Discord.Utils; + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord +{ + /// + /// Represents a builder for creating a . + /// + public class ComponentBuilder + { + /// + /// The max length of a . + /// + public const int MaxCustomIdLength = 100; + + /// + /// The max amount of rows a message can have. + /// + public const int MaxActionRowCount = 5; + + /// + /// Gets or sets the Action Rows for this Component Builder. + /// + /// cannot be null. + /// count exceeds . + public List ActionRows + { + get => _actionRows; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value), $"{nameof(ActionRows)} cannot be null."); + if (value.Count > MaxActionRowCount) + throw new ArgumentOutOfRangeException(nameof(value), $"Action row count must be less than or equal to {MaxActionRowCount}."); + _actionRows = value; + } + } + + private List _actionRows; + + /// + /// Creates a new builder from a message. + /// + /// The message to create the builder from. + /// The newly created builder. + public static ComponentBuilder FromMessage(IMessage message) + => FromComponents(message.Components); + + /// + /// Creates a new builder from the provided list of components. + /// + /// The components to create the builder from. + /// The newly created builder. + public static ComponentBuilder FromComponents(IReadOnlyCollection components) + { + var builder = new ComponentBuilder(); + for (int i = 0; i != components.Count; i++) + { + var component = components.ElementAt(i); + builder.AddComponent(component, i); + } + return builder; + } + + internal void AddComponent(IMessageComponent component, int row) + { + switch (component) + { + case ButtonComponent button: + WithButton(button.Label, button.CustomId, button.Style, button.Emote, button.Url, button.IsDisabled, row); + break; + case ActionRowComponent actionRow: + foreach (var cmp in actionRow.Components) + AddComponent(cmp, row); + break; + case SelectMenuComponent menu: + WithSelectMenu(menu.CustomId, menu.Options.Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.IsDefault)).ToList(), menu.Placeholder, menu.MinValues, menu.MaxValues, menu.IsDisabled, row); + break; + } + } + + /// + /// Removes all components of the given type from the . + /// + /// The to remove. + /// The current builder. + public ComponentBuilder RemoveComponentsOfType(ComponentType t) + { + this.ActionRows.ForEach(ar => ar.Components.RemoveAll(c => c.Type == t)); + return this; + } + + /// + /// Removes a component from the . + /// + /// The custom id of the component. + /// The current builder. + public ComponentBuilder RemoveComponent(string customId) + { + this.ActionRows.ForEach(ar => ar.Components.RemoveAll(c => c.CustomId == customId)); + return this; + } + + /// + /// Removes a Link Button from the based on its URL. + /// + /// The URL of the Link Button. + /// The current builder. + public ComponentBuilder RemoveButtonByURL(string url) + { + this.ActionRows.ForEach(ar => ar.Components.RemoveAll(c => c is ButtonComponent b && b.Url == url)); + return this; + } + + /// + /// Adds a to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The custom id of the menu. + /// The options of the menu. + /// The placeholder of the menu. + /// The min values of the placeholder. + /// The max values of the placeholder. + /// Whether or not the menu is disabled. + /// The row to add the menu to. + /// The type of the select menu. + /// Menus valid channel types (only for ) + /// + public ComponentBuilder WithSelectMenu(string customId, List options = null, + string placeholder = null, int minValues = 1, int maxValues = 1, bool disabled = false, int row = 0, ComponentType type = ComponentType.SelectMenu, + ChannelType[] channelTypes = null, SelectMenuDefaultValue[] defaultValues = null) + { + return WithSelectMenu(new SelectMenuBuilder() + .WithCustomId(customId) + .WithOptions(options) + .WithPlaceholder(placeholder) + .WithMaxValues(maxValues) + .WithMinValues(minValues) + .WithDisabled(disabled) + .WithType(type) + .WithChannelTypes(channelTypes) + .WithDefaultValues(defaultValues), + row); + } + + /// + /// Adds a to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The menu to add. + /// The row to attempt to add this component on. + /// There is no more row to add a menu. + /// must be less than . + /// The current builder. + public ComponentBuilder WithSelectMenu(SelectMenuBuilder menu, int row = 0) + { + Preconditions.LessThan(row, MaxActionRowCount, nameof(row)); + if (menu.Options is not null && menu.Options.Distinct().Count() != menu.Options.Count) + throw new InvalidOperationException("Please make sure that there is no duplicates values."); + + var builtMenu = menu.Build(); + + if (_actionRows == null) + { + _actionRows = new List + { + new ActionRowBuilder().AddComponent(builtMenu) + }; + } + else + { + if (_actionRows.Count == row) + _actionRows.Add(new ActionRowBuilder().AddComponent(builtMenu)); + else + { + ActionRowBuilder actionRow; + if (_actionRows.Count > row) + actionRow = _actionRows.ElementAt(row); + else + { + actionRow = new ActionRowBuilder(); + _actionRows.Add(actionRow); + } + + if (actionRow.CanTakeComponent(builtMenu)) + actionRow.AddComponent(builtMenu); + else if (row < MaxActionRowCount) + WithSelectMenu(menu, row + 1); + else + throw new InvalidOperationException($"There is no more row to add a {nameof(builtMenu)}"); + } + } + + return this; + } + + /// + /// Adds a with specified parameters to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The label text for the newly added button. + /// The style of this newly added button. + /// A to be used with this button. + /// The custom id of the newly added button. + /// A URL to be used only if the is a Link. + /// Whether or not the newly created button is disabled. + /// The row the button should be placed on. + /// The current builder. + public ComponentBuilder WithButton( + string label = null, + string customId = null, + ButtonStyle style = ButtonStyle.Primary, + IEmote emote = null, + string url = null, + bool disabled = false, + int row = 0) + { + var button = new ButtonBuilder() + .WithLabel(label) + .WithStyle(style) + .WithEmote(emote) + .WithCustomId(customId) + .WithUrl(url) + .WithDisabled(disabled); + + return WithButton(button, row); + } + + /// + /// Adds a to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The button to add. + /// The row to add the button. + /// There is no more row to add a button. + /// must be less than . + /// The current builder. + public ComponentBuilder WithButton(ButtonBuilder button, int row = 0) + { + Preconditions.LessThan(row, MaxActionRowCount, nameof(row)); + + var builtButton = button.Build(); + + if (_actionRows == null) + { + _actionRows = new List + { + new ActionRowBuilder().AddComponent(builtButton) + }; + } + else + { + if (_actionRows.Count == row) + _actionRows.Add(new ActionRowBuilder().AddComponent(builtButton)); + else + { + ActionRowBuilder actionRow; + if (_actionRows.Count > row) + actionRow = _actionRows.ElementAt(row); + else + { + actionRow = new ActionRowBuilder(); + _actionRows.Add(actionRow); + } + + if (actionRow.CanTakeComponent(builtButton)) + actionRow.AddComponent(builtButton); + else if (row < MaxActionRowCount) + WithButton(button, row + 1); + else + throw new InvalidOperationException($"There is no more row to add a {nameof(button)}"); + } + } + + return this; + } + + /// + /// Adds a row to this component builder. + /// + /// The row to add. + /// The component builder contains the max amount of rows defined as . + /// The current builder. + public ComponentBuilder AddRow(ActionRowBuilder row) + { + _actionRows ??= new(); + + if (_actionRows.Count >= MaxActionRowCount) + throw new IndexOutOfRangeException("The max amount of rows has been reached"); + + ActionRows.Add(row); + return this; + } + + /// + /// Sets the rows of this component builder to a specified collection. + /// + /// The rows to set. + /// The collection contains more rows then is allowed by discord. + /// The current builder. + public ComponentBuilder WithRows(IEnumerable rows) + { + if (rows.Count() > MaxActionRowCount) + throw new IndexOutOfRangeException($"Cannot have more than {MaxActionRowCount} rows"); + + _actionRows = new List(rows); + return this; + } + + /// + /// Builds this builder into a used to send your components. + /// + /// A that can be sent with . + public MessageComponent Build() + { + if (_actionRows?.SelectMany(x => x.Components)?.Any(x => x.Type == ComponentType.TextInput) ?? false) + throw new ArgumentException("TextInputComponents are not allowed in messages.", nameof(ActionRows)); + + if (_actionRows?.Count > 0) + for (int i = 0; i < _actionRows?.Count; i++) + if (_actionRows[i]?.Components?.Count == 0) + _actionRows.RemoveAt(i); + + return _actionRows != null + ? new MessageComponent(_actionRows.Select(x => x.Build()).ToList()) + : MessageComponent.Empty; + } + } + + /// + /// Represents a class used to build Action rows. + /// + public class ActionRowBuilder + { + /// + /// The max amount of child components this row can hold. + /// + public const int MaxChildCount = 5; + + /// + /// Gets or sets the components inside this row. + /// + /// cannot be null. + /// count exceeds . + public List Components + { + get => _components; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value), $"{nameof(Components)} cannot be null."); + + _components = value.Count switch + { + 0 => throw new ArgumentOutOfRangeException(nameof(value), "There must be at least 1 component in a row."), + > MaxChildCount => throw new ArgumentOutOfRangeException(nameof(value), $"Action row can only contain {MaxChildCount} child components!"), + _ => value + }; + } + } + + private List _components = new List(); + + /// + /// Adds a list of components to the current row. + /// + /// The list of components to add. + /// + /// The current builder. + public ActionRowBuilder WithComponents(List components) + { + Components = components; + return this; + } + + /// + /// Adds a component at the end of the current row. + /// + /// The component to add. + /// Components count reached + /// The current builder. + public ActionRowBuilder AddComponent(IMessageComponent component) + { + if (Components.Count >= MaxChildCount) + throw new InvalidOperationException($"Components count reached {MaxChildCount}"); + + Components.Add(component); + return this; + } + + /// + /// Adds a to the . + /// + /// The custom id of the menu. + /// The options of the menu. + /// The placeholder of the menu. + /// The min values of the placeholder. + /// The max values of the placeholder. + /// Whether or not the menu is disabled. + /// The type of the select menu. + /// Menus valid channel types (only for ) + /// The current builder. + public ActionRowBuilder WithSelectMenu(string customId, List options = null, + string placeholder = null, int minValues = 1, int maxValues = 1, bool disabled = false, + ComponentType type = ComponentType.SelectMenu, ChannelType[] channelTypes = null) + { + return WithSelectMenu(new SelectMenuBuilder() + .WithCustomId(customId) + .WithOptions(options) + .WithPlaceholder(placeholder) + .WithMaxValues(maxValues) + .WithMinValues(minValues) + .WithDisabled(disabled) + .WithType(type) + .WithChannelTypes(channelTypes)); + } + + /// + /// Adds a to the . + /// + /// The menu to add. + /// A Select Menu cannot exist in a pre-occupied ActionRow. + /// The current builder. + public ActionRowBuilder WithSelectMenu(SelectMenuBuilder menu) + { + if (menu.Options is not null && menu.Options.Distinct().Count() != menu.Options.Count) + throw new InvalidOperationException("Please make sure that there is no duplicates values."); + + var builtMenu = menu.Build(); + + if (Components.Count != 0) + throw new InvalidOperationException($"A Select Menu cannot exist in a pre-occupied ActionRow."); + + AddComponent(builtMenu); + + return this; + } + + /// + /// Adds a with specified parameters to the . + /// + /// The label text for the newly added button. + /// The style of this newly added button. + /// A to be used with this button. + /// The custom id of the newly added button. + /// A URL to be used only if the is a Link. + /// Whether or not the newly created button is disabled. + /// The current builder. + public ActionRowBuilder WithButton( + string label = null, + string customId = null, + ButtonStyle style = ButtonStyle.Primary, + IEmote emote = null, + string url = null, + bool disabled = false) + { + var button = new ButtonBuilder() + .WithLabel(label) + .WithStyle(style) + .WithEmote(emote) + .WithCustomId(customId) + .WithUrl(url) + .WithDisabled(disabled); + + return WithButton(button); + } + + /// + /// Adds a to the . + /// + /// The button to add. + /// Components count reached . + /// A button cannot be added to a row with a SelectMenu. + /// The current builder. + public ActionRowBuilder WithButton(ButtonBuilder button) + { + var builtButton = button.Build(); + + if (Components.Count >= 5) + throw new InvalidOperationException($"Components count reached {MaxChildCount}"); + + if (Components.Any(x => x.Type.IsSelectType())) + throw new InvalidOperationException($"A button cannot be added to a row with a SelectMenu"); + + AddComponent(builtButton); + + return this; + } + + /// + /// Builds the current builder to a that can be used within a + /// + /// A that can be used within a + public ActionRowComponent Build() + { + return new ActionRowComponent(_components); + } + + internal bool CanTakeComponent(IMessageComponent component) + { + switch (component.Type) + { + case ComponentType.ActionRow: + return false; + case ComponentType.Button: + if (Components.Any(x => x.Type.IsSelectType())) + return false; + else + return Components.Count < 5; + case ComponentType.SelectMenu: + case ComponentType.ChannelSelect: + case ComponentType.MentionableSelect: + case ComponentType.RoleSelect: + case ComponentType.UserSelect: + return Components.Count == 0; + default: + return false; + } + } + } + + /// + /// Represents a class used to build 's. + /// + public class ButtonBuilder + { + /// + /// The max length of a . + /// + public const int MaxButtonLabelLength = 80; + + /// + /// Gets or sets the label of the current button. + /// + /// length exceeds . + /// length exceeds . + public string Label + { + get => _label; + set => _label = value?.Length switch + { + > MaxButtonLabelLength => throw new ArgumentOutOfRangeException(nameof(value), $"Label length must be less or equal to {MaxButtonLabelLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Label length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the custom id of the current button. + /// + /// length exceeds + /// length subceeds 1. + public string CustomId + { + get => _customId; + set => _customId = value?.Length switch + { + > ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom Id length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom Id length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the of the current button. + /// + public ButtonStyle Style { get; set; } + + /// + /// Gets or sets the of the current button. + /// + public IEmote Emote { get; set; } + + /// + /// Gets or sets the url of the current button. + /// + public string Url { get; set; } + + /// + /// Gets or sets whether the current button is disabled. + /// + public bool IsDisabled { get; set; } + + private string _label; + private string _customId; + + /// + /// Creates a new instance of a . + /// + public ButtonBuilder() { } + + /// + /// Creates a new instance of a . + /// + /// The label to use on the newly created link button. + /// The url of this button. + /// The custom ID of this button. + /// The custom ID of this button. + /// The emote of this button. + /// Disabled this button or not. + public ButtonBuilder(string label = null, string customId = null, ButtonStyle style = ButtonStyle.Primary, string url = null, IEmote emote = null, bool isDisabled = false) + { + CustomId = customId; + Style = style; + Url = url; + Label = label; + IsDisabled = isDisabled; + Emote = emote; + } + + /// + /// Creates a new instance of a from instance of a . + /// + public ButtonBuilder(ButtonComponent button) + { + CustomId = button.CustomId; + Style = button.Style; + Url = button.Url; + Label = button.Label; + IsDisabled = button.IsDisabled; + Emote = button.Emote; + } + + /// + /// Creates a button with the style. + /// + /// The label for this link button. + /// The url for this link button to go to. + /// The emote for this link button. + /// A builder with the newly created button. + public static ButtonBuilder CreateLinkButton(string label, string url, IEmote emote = null) + => new ButtonBuilder(label, null, ButtonStyle.Link, url, emote: emote); + + /// + /// Creates a button with the style. + /// + /// The label for this danger button. + /// The custom id for this danger button. + /// The emote for this danger button. + /// A builder with the newly created button. + public static ButtonBuilder CreateDangerButton(string label, string customId, IEmote emote = null) + => new ButtonBuilder(label, customId, ButtonStyle.Danger, emote: emote); + + /// + /// Creates a button with the style. + /// + /// The label for this primary button. + /// The custom id for this primary button. + /// The emote for this primary button. + /// A builder with the newly created button. + public static ButtonBuilder CreatePrimaryButton(string label, string customId, IEmote emote = null) + => new ButtonBuilder(label, customId, emote: emote); + + /// + /// Creates a button with the style. + /// + /// The label for this secondary button. + /// The custom id for this secondary button. + /// The emote for this secondary button. + /// A builder with the newly created button. + public static ButtonBuilder CreateSecondaryButton(string label, string customId, IEmote emote = null) + => new ButtonBuilder(label, customId, ButtonStyle.Secondary, emote: emote); + + /// + /// Creates a button with the style. + /// + /// The label for this success button. + /// The custom id for this success button. + /// The emote for this success button. + /// A builder with the newly created button. + public static ButtonBuilder CreateSuccessButton(string label, string customId, IEmote emote = null) + => new ButtonBuilder(label, customId, ButtonStyle.Success, emote: emote); + + /// + /// Sets the current buttons label to the specified text. + /// + /// The text for the label. + /// + /// The current builder. + public ButtonBuilder WithLabel(string label) + { + Label = label; + return this; + } + + /// + /// Sets the current buttons style. + /// + /// The style for this builders button. + /// The current builder. + public ButtonBuilder WithStyle(ButtonStyle style) + { + Style = style; + return this; + } + + /// + /// Sets the current buttons emote. + /// + /// The emote to use for the current button. + /// The current builder. + public ButtonBuilder WithEmote(IEmote emote) + { + Emote = emote; + return this; + } + + /// + /// Sets the current buttons url. + /// + /// The url to use for the current button. + /// The current builder. + public ButtonBuilder WithUrl(string url) + { + Url = url; + return this; + } + + /// + /// Sets the custom id of the current button. + /// + /// The id to use for the current button. + /// + /// The current builder. + public ButtonBuilder WithCustomId(string id) + { + CustomId = id; + return this; + } + + /// + /// Sets whether the current button is disabled. + /// + /// Whether the current button is disabled or not. + /// The current builder. + public ButtonBuilder WithDisabled(bool isDisabled) + { + IsDisabled = isDisabled; + return this; + } + + /// + /// Builds this builder into a to be used in a . + /// + /// A to be used in a . + /// A button must contain either a or a , but not both. + /// A button must have an or a . + /// A link button must contain a URL. + /// A URL must include a protocol (http or https). + /// A non-link button must contain a custom id + public ButtonComponent Build() + { + if (string.IsNullOrWhiteSpace(Label) && Emote == null) + throw new InvalidOperationException("A button must have an Emote or a label!"); + + if (!(string.IsNullOrWhiteSpace(Url) ^ string.IsNullOrWhiteSpace(CustomId))) + throw new InvalidOperationException("A button must contain either a URL or a CustomId, but not both!"); + + if (Style == 0) + throw new ArgumentException("A button must have a style.", nameof(Style)); + + if (Style == ButtonStyle.Link) + { + if (string.IsNullOrWhiteSpace(Url)) + throw new InvalidOperationException("Link buttons must have a link associated with them"); + UrlValidation.ValidateButton(Url); + } + else if (string.IsNullOrWhiteSpace(CustomId)) + throw new InvalidOperationException("Non-link buttons must have a custom id associated with them"); + + return new ButtonComponent(Style, Label, Emote, CustomId, Url, IsDisabled); + } + } + + /// + /// Represents a class used to build 's. + /// + public class SelectMenuBuilder + { + /// + /// The max length of a . + /// + public const int MaxPlaceholderLength = 100; + + /// + /// The maximum number of values for the and properties. + /// + public const int MaxValuesCount = 25; + + /// + /// The maximum number of options a can have. + /// + public const int MaxOptionCount = 25; + + /// + /// Gets or sets the custom id of the current select menu. + /// + /// length exceeds + /// length subceeds 1. + public string CustomId + { + get => _customId; + set => _customId = value?.Length switch + { + > ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom Id length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom Id length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the type of the current select menu. + /// + /// Type must be a select menu type. + public ComponentType Type + { + get => _type; + set => _type = value.IsSelectType() + ? value + : throw new ArgumentException("Type must be a select menu type.", nameof(value)); + } + + /// + /// Gets or sets the placeholder text of the current select menu. + /// + /// length exceeds . + /// length subceeds 1. + public string Placeholder + { + get => _placeholder; + set => _placeholder = value?.Length switch + { + > MaxPlaceholderLength => throw new ArgumentOutOfRangeException(nameof(value), $"Placeholder length must be less or equal to {MaxPlaceholderLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Placeholder length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the minimum values of the current select menu. + /// + /// exceeds . + public int MinValues + { + get => _minValues; + set + { + Preconditions.AtMost(value, MaxValuesCount, nameof(MinValues)); + _minValues = value; + } + } + + /// + /// Gets or sets the maximum values of the current select menu. + /// + /// exceeds . + public int MaxValues + { + get => _maxValues; + set + { + Preconditions.AtMost(value, MaxValuesCount, nameof(MaxValues)); + _maxValues = value; + } + } + + /// + /// Gets or sets a collection of for this current select menu. + /// + /// count exceeds . + /// is null. + public List Options + { + get => _options; + set + { + if (value != null) + Preconditions.AtMost(value.Count, MaxOptionCount, nameof(Options)); + + _options = value; + } + } + + /// + /// Gets or sets whether the current menu is disabled. + /// + public bool IsDisabled { get; set; } + + /// + /// Gets or sets the menu's channel types (only valid on s). + /// + public List ChannelTypes { get; set; } + + public List DefaultValues + { + get => _defaultValues; + set + { + if (value != null) + Preconditions.AtMost(value.Count, MaxOptionCount, nameof(DefaultValues)); + + _defaultValues = value; + } + } + + private List _options = new List(); + private int _minValues = 1; + private int _maxValues = 1; + private string _placeholder; + private string _customId; + private ComponentType _type = ComponentType.SelectMenu; + private List _defaultValues = new(); + + /// + /// Creates a new instance of a . + /// + public SelectMenuBuilder() { } + + /// + /// Creates a new instance of a from instance of . + /// + public SelectMenuBuilder(SelectMenuComponent selectMenu) + { + Placeholder = selectMenu.Placeholder; + CustomId = selectMenu.CustomId; + MaxValues = selectMenu.MaxValues; + MinValues = selectMenu.MinValues; + IsDisabled = selectMenu.IsDisabled; + Options = selectMenu.Options? + .Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.IsDefault)) + .ToList(); + DefaultValues = selectMenu.DefaultValues?.ToList(); + } + + /// + /// Creates a new instance of a . + /// + /// The custom id of this select menu. + /// The options for this select menu. + /// The placeholder of this select menu. + /// The max values of this select menu. + /// The min values of this select menu. + /// Disabled this select menu or not. + /// The of this select menu. + /// The types of channels this menu can select (only valid on s) + public SelectMenuBuilder(string customId, List options = null, string placeholder = null, int maxValues = 1, int minValues = 1, + bool isDisabled = false, ComponentType type = ComponentType.SelectMenu, List channelTypes = null, List defaultValues = null) + { + CustomId = customId; + Options = options; + Placeholder = placeholder; + IsDisabled = isDisabled; + MaxValues = maxValues; + MinValues = minValues; + Type = type; + ChannelTypes = channelTypes ?? new(); + DefaultValues = defaultValues ?? new(); + } + + /// + /// Sets the field CustomId. + /// + /// The value to set the field CustomId to. + /// + /// + /// The current builder. + /// + public SelectMenuBuilder WithCustomId(string customId) + { + CustomId = customId; + return this; + } + + /// + /// Sets the field placeholder. + /// + /// The value to set the field placeholder to. + /// + /// + /// The current builder. + /// + public SelectMenuBuilder WithPlaceholder(string placeholder) + { + Placeholder = placeholder; + return this; + } + + /// + /// Sets the field minValues. + /// + /// The value to set the field minValues to. + /// + /// + /// The current builder. + /// + public SelectMenuBuilder WithMinValues(int minValues) + { + MinValues = minValues; + return this; + } + + /// + /// Sets the field maxValues. + /// + /// The value to set the field maxValues to. + /// + /// + /// The current builder. + /// + public SelectMenuBuilder WithMaxValues(int maxValues) + { + MaxValues = maxValues; + return this; + } + + /// + /// Sets the field options. + /// + /// The value to set the field options to. + /// + /// + /// The current builder. + /// + public SelectMenuBuilder WithOptions(List options) + { + Options = options; + return this; + } + + /// + /// Add one option to menu options. + /// + /// The option builder class containing the option properties. + /// Options count reached . + /// + /// The current builder. + /// + public SelectMenuBuilder AddOption(SelectMenuOptionBuilder option) + { + if (Options.Count >= MaxOptionCount) + throw new InvalidOperationException($"Options count reached {MaxOptionCount}."); + + Options.Add(option); + return this; + } + + /// + /// Add one option to menu options. + /// + /// The label for this option. + /// The value of this option. + /// The description of this option. + /// The emote of this option. + /// Render this option as selected by default or not. + /// Options count reached . + /// + /// The current builder. + /// + public SelectMenuBuilder AddOption(string label, string value, string description = null, IEmote emote = null, bool? isDefault = null) + { + AddOption(new SelectMenuOptionBuilder(label, value, description, emote, isDefault)); + return this; + } + + /// + /// Add one default value to menu options. + /// + /// The id of an entity to add. + /// The type of an entity to add. + /// Default values count reached . + /// + /// The current builder. + /// + public SelectMenuBuilder AddDefaultValue(ulong id, SelectDefaultValueType type) + => AddDefaultValue(new(id, type)); + + /// + /// Add one default value to menu options. + /// + /// The default value to add. + /// Default values count reached . + /// + /// The current builder. + /// + public SelectMenuBuilder AddDefaultValue(SelectMenuDefaultValue value) + { + if (DefaultValues.Count >= MaxOptionCount) + throw new InvalidOperationException($"Options count reached {MaxOptionCount}."); + + DefaultValues.Add(value); + return this; + } + + /// + /// Sets the field default values. + /// + /// The value to set the field default values to. + /// + /// The current builder. + /// + public SelectMenuBuilder WithDefaultValues(params SelectMenuDefaultValue[] defaultValues) + { + DefaultValues = defaultValues?.ToList(); + return this; + } + + /// + /// Sets whether the current menu is disabled. + /// + /// Whether the current menu is disabled or not. + /// + /// The current builder. + /// + public SelectMenuBuilder WithDisabled(bool isDisabled) + { + IsDisabled = isDisabled; + return this; + } + + /// + /// Sets the menu's current type. + /// + /// The type of the menu. + /// + /// The current builder. + /// + public SelectMenuBuilder WithType(ComponentType type) + { + Type = type; + return this; + } + + /// + /// Sets the menus valid channel types (only for s). + /// + /// The valid channel types of the menu. + /// + /// The current builder. + /// + public SelectMenuBuilder WithChannelTypes(List channelTypes) + { + ChannelTypes = channelTypes; + return this; + } + + /// + /// Sets the menus valid channel types (only for s). + /// + /// The valid channel types of the menu. + /// + /// The current builder. + /// + public SelectMenuBuilder WithChannelTypes(params ChannelType[] channelTypes) + { + ChannelTypes = channelTypes is null + ? ChannelTypeUtils.AllChannelTypes() + : channelTypes.ToList(); + return this; + } + + /// + /// Builds a + /// + /// The newly built + public SelectMenuComponent Build() + { + var options = Options?.Select(x => x.Build()).ToList(); + + return new SelectMenuComponent(CustomId, options, Placeholder, MinValues, MaxValues, IsDisabled, Type, ChannelTypes, DefaultValues); + } + } + + /// + /// Represents a class used to build 's. + /// + public class SelectMenuOptionBuilder + { + /// + /// The maximum length of a . + /// + public const int MaxSelectLabelLength = 100; + + /// + /// The maximum length of a . + /// + public const int MaxDescriptionLength = 100; + + /// + /// The maximum length of a . + /// + public const int MaxSelectValueLength = 100; + + /// + /// Gets or sets the label of the current select menu. + /// + /// length exceeds + /// length subceeds 1. + public string Label + { + get => _label; + set => _label = value?.Length switch + { + > MaxSelectLabelLength => throw new ArgumentOutOfRangeException(nameof(value), $"Label length must be less or equal to {MaxSelectLabelLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Label length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the value of the current select menu. + /// + /// length exceeds . + /// length subceeds 1. + public string Value + { + get => _value; + set => _value = value?.Length switch + { + > MaxSelectValueLength => throw new ArgumentOutOfRangeException(nameof(value), $"Value length must be less or equal to {MaxSelectValueLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Value length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets this menu options description. + /// + /// length exceeds . + /// length subceeds 1. + public string Description + { + get => _description; + set => _description = value?.Length switch + { + > MaxDescriptionLength => throw new ArgumentOutOfRangeException(nameof(value), $"Description length must be less or equal to {MaxDescriptionLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Description length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the emote of this option. + /// + public IEmote Emote { get; set; } + + /// + /// Gets or sets the whether or not this option will render selected by default. + /// + public bool? IsDefault { get; set; } + + private string _label; + private string _value; + private string _description; + + /// + /// Creates a new instance of a . + /// + public SelectMenuOptionBuilder() { } + + /// + /// Creates a new instance of a . + /// + /// The label for this option. + /// The value of this option. + /// The description of this option. + /// The emote of this option. + /// Render this option as selected by default or not. + public SelectMenuOptionBuilder(string label, string value, string description = null, IEmote emote = null, bool? isDefault = null) + { + Label = label; + Value = value; + Description = description; + Emote = emote; + IsDefault = isDefault; + } + + /// + /// Creates a new instance of a from instance of a . + /// + public SelectMenuOptionBuilder(SelectMenuOption option) + { + Label = option.Label; + Value = option.Value; + Description = option.Description; + Emote = option.Emote; + IsDefault = option.IsDefault; + } + + /// + /// Sets the field label. + /// + /// The value to set the field label to. + /// + /// + /// The current builder. + /// + public SelectMenuOptionBuilder WithLabel(string label) + { + Label = label; + return this; + } + + /// + /// Sets the field value. + /// + /// The value to set the field value to. + /// + /// + /// The current builder. + /// + public SelectMenuOptionBuilder WithValue(string value) + { + Value = value; + return this; + } + + /// + /// Sets the field description. + /// + /// The value to set the field description to. + /// + /// + /// The current builder. + /// + public SelectMenuOptionBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets the field emote. + /// + /// The value to set the field emote to. + /// + /// The current builder. + /// + public SelectMenuOptionBuilder WithEmote(IEmote emote) + { + Emote = emote; + return this; + } + + /// + /// Sets the field default. + /// + /// The value to set the field default to. + /// + /// The current builder. + /// + public SelectMenuOptionBuilder WithDefault(bool isDefault) + { + IsDefault = isDefault; + return this; + } + + /// + /// Builds a . + /// + /// The newly built . + public SelectMenuOption Build() + { + if (string.IsNullOrWhiteSpace(Label)) + throw new ArgumentNullException(nameof(Label), "Option must have a label."); + + Preconditions.AtMost(Label.Length, MaxSelectLabelLength, nameof(Label), $"Label length must be less or equal to {MaxSelectLabelLength}."); + + if (string.IsNullOrWhiteSpace(Value)) + throw new ArgumentNullException(nameof(Value), "Option must have a value."); + + Preconditions.AtMost(Value.Length, MaxSelectValueLength, nameof(Value), $"Value length must be less or equal to {MaxSelectValueLength}."); + + return new SelectMenuOption(Label, Value, Description, Emote, IsDefault); + } + } + + public class TextInputBuilder + { + /// + /// The max length of a . + /// + public const int MaxPlaceholderLength = 100; + public const int LargestMaxLength = 4000; + + /// + /// Gets or sets the custom id of the current text input. + /// + /// length exceeds + /// length subceeds 1. + public string CustomId + { + get => _customId; + set => _customId = value?.Length switch + { + > ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom Id length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom Id length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the style of the current text input. + /// + public TextInputStyle Style { get; set; } = TextInputStyle.Short; + + /// + /// Gets or sets the label of the current text input. + /// + public string Label { get; set; } + + /// + /// Gets or sets the placeholder of the current text input. + /// + /// is longer than characters + public string Placeholder + { + get => _placeholder; + set => _placeholder = (value?.Length ?? 0) <= MaxPlaceholderLength + ? value + : throw new ArgumentException($"Placeholder cannot have more than {MaxPlaceholderLength} characters."); + } + + /// + /// Gets or sets the minimum length of the current text input. + /// + /// is less than 0. + /// is greater than . + /// is greater than . + public int? MinLength + { + get => _minLength; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be less than 0"); + if (value > LargestMaxLength) + throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be greater than {LargestMaxLength}"); + if (value > (MaxLength ?? LargestMaxLength)) + throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must be less than MaxLength"); + _minLength = value; + } + } + + /// + /// Gets or sets the maximum length of the current text input. + /// + /// is less than 0. + /// is greater than . + /// is less than . + public int? MaxLength + { + get => _maxLength; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must not be less than 0"); + if (value > LargestMaxLength) + throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength most not be greater than {LargestMaxLength}"); + if (value < (MinLength ?? -1)) + throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must be greater than MinLength ({MinLength})"); + _maxLength = value; + } + } + + /// + /// Gets or sets whether the user is required to input text. + /// + public bool? Required { get; set; } + + /// + /// Gets or sets the default value of the text input. + /// + /// .Length is less than 0. + /// + /// .Length is greater than or . + /// + /// + /// is and contains a new line character. + /// + public string Value + { + get => _value; + set + { + if (value?.Length > (MaxLength ?? LargestMaxLength)) + throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be longer than {MaxLength ?? LargestMaxLength}."); + if (value?.Length < (MinLength ?? 0)) + throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be shorter than {MinLength}"); + + _value = value; + } + } + + private string _customId; + private int? _maxLength; + private int? _minLength; + private string _placeholder; + private string _value; + + /// + /// Creates a new instance of a . + /// + /// The text input's label. + /// The text input's style. + /// The text input's custom id. + /// The text input's placeholder. + /// The text input's minimum length. + /// The text input's maximum length. + /// The text input's required value. + public TextInputBuilder(string label, string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, + int? minLength = null, int? maxLength = null, bool? required = null, string value = null) + { + Label = label; + Style = style; + CustomId = customId; + Placeholder = placeholder; + MinLength = minLength; + MaxLength = maxLength; + Required = required; + Value = value; + } + + /// + /// Creates a new instance of a . + /// + public TextInputBuilder() + { + + } + + /// + /// Sets the label of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithLabel(string label) + { + Label = label; + return this; + } + + /// + /// Sets the style of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithStyle(TextInputStyle style) + { + Style = style; + return this; + } + + /// + /// Sets the custom id of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithCustomId(string customId) + { + CustomId = customId; + return this; + } + + /// + /// Sets the placeholder of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithPlaceholder(string placeholder) + { + Placeholder = placeholder; + return this; + } + + /// + /// Sets the value of the current builder. + /// + /// The value to set + /// The current builder. + public TextInputBuilder WithValue(string value) + { + Value = value; + return this; + } + + /// + /// Sets the minimum length of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithMinLength(int minLength) + { + MinLength = minLength; + return this; + } + + /// + /// Sets the maximum length of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithMaxLength(int maxLength) + { + MaxLength = maxLength; + return this; + } + + /// + /// Sets the required value of the current builder. + /// + /// The value to set. + /// The current builder. + public TextInputBuilder WithRequired(bool required) + { + Required = required; + return this; + } + + public TextInputComponent Build() + { + if (string.IsNullOrEmpty(CustomId)) + throw new ArgumentException("TextInputComponents must have a custom id.", nameof(CustomId)); + if (string.IsNullOrWhiteSpace(Label)) + throw new ArgumentException("TextInputComponents must have a label.", nameof(Label)); + if (Style == TextInputStyle.Short && Value?.Contains('\n') == true) + throw new ArgumentException($"Value must not contain new line characters when style is {TextInputStyle.Short}.", nameof(Value)); + + return new TextInputComponent(CustomId, Label, Placeholder, MinLength, MaxLength, Style, Required, Value); + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs new file mode 100644 index 0000000..0ad3f74 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs @@ -0,0 +1,48 @@ +namespace Discord +{ + /// + /// Represents a type of a component. + /// + public enum ComponentType + { + /// + /// A container for other components. + /// + ActionRow = 1, + + /// + /// A clickable button. + /// + Button = 2, + + /// + /// A select menu for picking from choices. + /// + SelectMenu = 3, + + /// + /// A box for entering text. + /// + TextInput = 4, + + /// + /// A select menu for picking from users. + /// + UserSelect = 5, + + /// + /// A select menu for picking from roles. + /// + RoleSelect = 6, + + /// + /// A select menu for picking from roles and users. + /// + MentionableSelect = 7, + + /// + /// A select menu for picking from channels. + /// + ChannelSelect = 8, + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteraction.cs new file mode 100644 index 0000000..299ee79 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteraction.cs @@ -0,0 +1,37 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents an interaction type for Message Components. + /// + public interface IComponentInteraction : IDiscordInteraction + { + /// + /// Gets the data received with this component interaction. + /// + new IComponentInteractionData Data { get; } + + /// + /// Gets the message that contained the trigger for this interaction. + /// + IUserMessage Message { get; } + + /// + /// Updates the message which this component resides in with the type + /// + /// A delegate containing the properties to modify the message with. + /// The options to be used when sending the request. + /// A task that represents the asynchronous operation of updating the message. + Task UpdateAsync(Action func, RequestOptions options = null); + + /// + /// Defers an interaction with the response type 5 (). + /// + /// to defer ephemerally, otherwise . + /// The options to be used when sending the request. + /// A task that represents the asynchronous operation of acknowledging the interaction. + Task DeferLoadingAsync(bool ephemeral = false, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs new file mode 100644 index 0000000..3a6526e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IComponentInteractionData.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents the data sent with the . + /// + public interface IComponentInteractionData : IDiscordInteractionData + { + /// + /// Gets the component's Custom Id that was clicked. + /// + string CustomId { get; } + + /// + /// Gets the type of the component clicked. + /// + ComponentType Type { get; } + + /// + /// Gets the value(s) of a interaction response. if select type is different. + /// + IReadOnlyCollection Values { get; } + + /// + /// Gets the channels(s) of a interaction response. if select type is different. + /// + IReadOnlyCollection Channels { get; } + + /// + /// Gets the user(s) of a or interaction response. if select type is different. + /// + IReadOnlyCollection Users { get; } + + /// + /// Gets the roles(s) of a or interaction response. if select type is different. + /// + IReadOnlyCollection Roles { get; } + + /// + /// Gets the guild member(s) of a or interaction response. if type select is different. + /// + IReadOnlyCollection Members { get; } + + /// + /// Gets the value of a interaction response. + /// + public string Value { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IMessageComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IMessageComponent.cs new file mode 100644 index 0000000..9366a44 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IMessageComponent.cs @@ -0,0 +1,18 @@ +namespace Discord +{ + /// + /// Represents a message component on a message. + /// + public interface IMessageComponent + { + /// + /// Gets the of this Message Component. + /// + ComponentType Type { get; } + + /// + /// Gets the custom id of the component if possible; otherwise . + /// + string CustomId { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/MessageComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/MessageComponent.cs new file mode 100644 index 0000000..7205886 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/MessageComponent.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a component object used to send components with messages. + /// + public class MessageComponent + { + /// + /// Gets the components to be used in a message. + /// + public IReadOnlyCollection Components { get; } + + internal MessageComponent(List components) + { + Components = components; + } + + /// + /// Returns a empty . + /// + internal static MessageComponent Empty + => new MessageComponent(new List()); + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectDefaultValueType.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectDefaultValueType.cs new file mode 100644 index 0000000..207a14f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectDefaultValueType.cs @@ -0,0 +1,22 @@ +namespace Discord; + +/// +/// Type of a . +/// +public enum SelectDefaultValueType +{ + /// + /// The select menu default value is a user. + /// + User = 0, + + /// + /// The select menu default value is a role. + /// + Role = 1, + + /// + /// The select menu default value is a channel. + /// + Channel = 2 +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuComponent.cs new file mode 100644 index 0000000..39631de --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuComponent.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord +{ + /// + /// Represents a select menu component defined at + /// + public class SelectMenuComponent : IMessageComponent + { + /// + public ComponentType Type { get; } + + /// + public string CustomId { get; } + + /// + /// Gets the menus options to select from. + /// + public IReadOnlyCollection Options { get; } + + /// + /// Gets the custom placeholder text if nothing is selected. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum number of items that must be chosen. + /// + public int MinValues { get; } + + /// + /// Gets the maximum number of items that can be chosen. + /// + public int MaxValues { get; } + + /// + /// Gets whether this menu is disabled or not. + /// + public bool IsDisabled { get; } + + /// + /// Gets the allowed channel types for this modal + /// + public IReadOnlyCollection ChannelTypes { get; } + + /// + /// Gets default values for auto-populated select menu components. + /// + public IReadOnlyCollection DefaultValues { get; } + + /// + /// Turns this select menu into a builder. + /// + /// + /// A newly create builder with the same properties as this select menu. + /// + public SelectMenuBuilder ToBuilder() + => new SelectMenuBuilder( + CustomId, + Options.Select(x => new SelectMenuOptionBuilder(x.Label, x.Value, x.Description, x.Emote, x.IsDefault)).ToList(), + Placeholder, + MaxValues, + MinValues, + IsDisabled, Type, ChannelTypes.ToList(), + DefaultValues.ToList()); + + internal SelectMenuComponent(string customId, List options, string placeholder, int minValues, int maxValues, + bool disabled, ComponentType type, IEnumerable channelTypes = null, IEnumerable defaultValues = null) + { + CustomId = customId; + Options = options; + Placeholder = placeholder; + MinValues = minValues; + MaxValues = maxValues; + IsDisabled = disabled; + Type = type; + ChannelTypes = channelTypes?.ToArray() ?? Array.Empty(); + DefaultValues = defaultValues?.ToArray() ?? Array.Empty(); + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuDefaultValue.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuDefaultValue.cs new file mode 100644 index 0000000..f2718de --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuDefaultValue.cs @@ -0,0 +1,46 @@ +namespace Discord; + +/// +/// Represents a default value of an auto-populated select menu. +/// +public readonly struct SelectMenuDefaultValue +{ + /// + /// Gets the id of entity this default value refers to. + /// + public ulong Id { get; } + + /// + /// Gets the type of this default value. + /// + public SelectDefaultValueType Type { get; } + + /// + /// Creates a new default value. + /// + /// Id of the target object. + /// Type of the target entity. + public SelectMenuDefaultValue(ulong id, SelectDefaultValueType type) + { + Id = id; + Type = type; + } + + /// + /// Creates a new default value from a . + /// + public static SelectMenuDefaultValue FromChannel(IChannel channel) + => new(channel.Id, SelectDefaultValueType.Channel); + + /// + /// Creates a new default value from a . + /// + public static SelectMenuDefaultValue FromRole(IRole role) + => new(role.Id, SelectDefaultValueType.Role); + + /// + /// Creates a new default value from a . + /// + public static SelectMenuDefaultValue FromUser(IUser user) + => new(user.Id, SelectDefaultValueType.User); +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuOption.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuOption.cs new file mode 100644 index 0000000..6856e1e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/SelectMenuOption.cs @@ -0,0 +1,42 @@ +namespace Discord +{ + /// + /// Represents a choice for a . + /// + public class SelectMenuOption + { + /// + /// Gets the user-facing name of the option. + /// + public string Label { get; } + + /// + /// Gets the dev-define value of the option. + /// + public string Value { get; } + + /// + /// Gets a description of the option. + /// + public string Description { get; } + + /// + /// Gets the displayed with this menu option. + /// + public IEmote Emote { get; } + + /// + /// Gets whether or not this option will render as selected by default. + /// + public bool? IsDefault { get; } + + internal SelectMenuOption(string label, string value, string description, IEmote emote, bool? defaultValue) + { + Label = label; + Value = value; + Description = description; + Emote = emote; + IsDefault = defaultValue; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs new file mode 100644 index 0000000..e2da113 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs @@ -0,0 +1,62 @@ +namespace Discord +{ + /// + /// Represents a text input. + /// + public class TextInputComponent : IMessageComponent + { + /// + public ComponentType Type => ComponentType.TextInput; + + /// + public string CustomId { get; } + + /// + /// Gets the label of the component; this is the text shown above it. + /// + public string Label { get; } + + /// + /// Gets the placeholder of the component. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum length of the inputted text. + /// + public int? MinLength { get; } + + /// + /// Gets the maximum length of the inputted text. + /// + public int? MaxLength { get; } + + /// + /// Gets the style of the component. + /// + public TextInputStyle Style { get; } + + /// + /// Gets whether users are required to input text. + /// + public bool? Required { get; } + + /// + /// Gets the default value of the component. + /// + public string Value { get; } + + internal TextInputComponent(string customId, string label, string placeholder, int? minLength, int? maxLength, + TextInputStyle style, bool? required, string value) + { + CustomId = customId; + Label = label; + Placeholder = placeholder; + MinLength = minLength; + MaxLength = maxLength; + Style = style; + Required = required; + Value = value; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs new file mode 100644 index 0000000..9bbcf68 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputStyle.cs @@ -0,0 +1,14 @@ +namespace Discord +{ + public enum TextInputStyle + { + /// + /// Intended for short, single-line text. + /// + Short = 1, + /// + /// Intended for longer or multiline text. + /// + Paragraph = 2, + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/ApplicationCommandInteractionMetadata.cs b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/ApplicationCommandInteractionMetadata.cs new file mode 100644 index 0000000..11e5d48 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/ApplicationCommandInteractionMetadata.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the metadata of an application command interaction. +/// +public readonly struct ApplicationCommandInteractionMetadata : IMessageInteractionMetadata +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public ulong Id { get; } + + /// + public InteractionType Type { get; } + + /// + public ulong UserId { get; } + + /// + public IReadOnlyDictionary IntegrationOwners { get; } + + /// + public ulong? OriginalResponseMessageId { get; } + + /// + /// Gets the name of the command. + /// + public string Name { get; } + + internal ApplicationCommandInteractionMetadata(ulong id, InteractionType type, ulong userId, IReadOnlyDictionary integrationOwners, + ulong? originalResponseMessageId, string name) + { + Id = id; + Type = type; + UserId = userId; + IntegrationOwners = integrationOwners; + OriginalResponseMessageId = originalResponseMessageId; + Name = name; + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/IMessageInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/IMessageInteractionData.cs new file mode 100644 index 0000000..61c3231 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/IMessageInteractionData.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the metadata of an interaction. +/// +public interface IMessageInteractionMetadata : ISnowflakeEntity +{ + /// + /// Gets the type of the interaction. + /// + InteractionType Type { get; } + + /// + /// Gets the ID of the user who triggered the interaction. + /// + ulong UserId { get; } + + /// + /// Gets the Ids for installation contexts related to the interaction. + /// + IReadOnlyDictionary IntegrationOwners { get; } + + /// + /// Gets the ID of the original response message if the message is a followup. + /// on original response messages. + /// + ulong? OriginalResponseMessageId { get; } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/MessageComponentInteractionMetadata.cs b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/MessageComponentInteractionMetadata.cs new file mode 100644 index 0000000..f9f4455 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/MessageComponentInteractionMetadata.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the metadata of a component interaction. +/// +public readonly struct MessageComponentInteractionMetadata : IMessageInteractionMetadata +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public ulong Id { get; } + + /// + public InteractionType Type { get; } + + /// + public ulong UserId { get; } + + /// + public IReadOnlyDictionary IntegrationOwners { get; } + + /// + public ulong? OriginalResponseMessageId { get; } + + /// + /// Gets the ID of the message that was interacted with to trigger the interaction. + /// + public ulong InteractedMessageId { get; } + + internal MessageComponentInteractionMetadata(ulong id, InteractionType type, ulong userId, IReadOnlyDictionary integrationOwners, + ulong? originalResponseMessageId, ulong interactedMessageId) + { + Id = id; + Type = type; + UserId = userId; + IntegrationOwners = integrationOwners; + OriginalResponseMessageId = originalResponseMessageId; + InteractedMessageId = interactedMessageId; + } +} + diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/ModalInteractionMetadata.cs b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/ModalInteractionMetadata.cs new file mode 100644 index 0000000..12306e6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageInteractionData/ModalInteractionMetadata.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents the metadata of a modal interaction. +/// +public readonly struct ModalSubmitInteractionMetadata :IMessageInteractionMetadata +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public ulong Id { get; } + + /// + public InteractionType Type { get; } + + /// + public ulong UserId { get; } + + /// + public IReadOnlyDictionary IntegrationOwners { get; } + + /// + public ulong? OriginalResponseMessageId { get; } + + /// + /// Gets the interaction metadata of the interaction that responded with the modal. + /// + public IMessageInteractionMetadata TriggeringInteractionMetadata { get; } + + internal ModalSubmitInteractionMetadata(ulong id, InteractionType type, ulong userId, IReadOnlyDictionary integrationOwners, + ulong? originalResponseMessageId, IMessageInteractionMetadata triggeringInteractionMetadata) + { + Id = id; + Type = type; + UserId = userId; + IntegrationOwners = integrationOwners; + OriginalResponseMessageId = originalResponseMessageId; + TriggeringInteractionMetadata = triggeringInteractionMetadata; + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs new file mode 100644 index 0000000..454b48c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteraction.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents an interaction type for Modals. + /// + public interface IModalInteraction : IDiscordInteraction + { + /// + /// Gets the data received with this interaction; contains the clicked button. + /// + new IModalInteractionData Data { get; } + + /// + /// Gets the message the modal originates from. + /// + /// + /// This property is only populated if the modal was created from a message component. + /// + IUserMessage Message { get; } + + /// + /// Updates the message which this modal originates from with the type + /// + /// A delegate containing the properties to modify the message with. + /// The options to be used when sending the request. + /// A task that represents the asynchronous operation of updating the message. + /// + /// This method can be used only if the modal was created from a message component. + /// + Task UpdateAsync(Action func, RequestOptions options = null); + + /// + /// Defers an interaction with the response type 5 (). + /// + /// to defer ephemerally, otherwise . + /// The options to be used when sending the request. + /// A task that represents the asynchronous operation of acknowledging the interaction. + Task DeferLoadingAsync(bool ephemeral = false, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs new file mode 100644 index 0000000..767dd5d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents the data sent with the . + /// + public interface IModalInteractionData : IDiscordInteractionData + { + /// + /// Gets the 's Custom Id. + /// + string CustomId { get; } + + /// + /// Gets the components submitted by the user. + /// + IReadOnlyCollection Components { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs new file mode 100644 index 0000000..a435d33 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a modal interaction. + /// + public class Modal : IMessageComponent + { + /// + public ComponentType Type => throw new NotSupportedException("Modals do not have a component type."); + + /// + /// Gets the title of the modal. + /// + public string Title { get; set; } + + /// + public string CustomId { get; set; } + + /// + /// Gets the components in the modal. + /// + public ModalComponent Component { get; set; } + + internal Modal(string title, string customId, ModalComponent components) + { + Title = title; + CustomId = customId; + Component = components; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs new file mode 100644 index 0000000..c534e9f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord +{ + /// + /// Represents a builder for creating a . + /// + public class ModalBuilder + { + private string _customId; + + public ModalBuilder() { } + + /// + /// Creates a new instance of the . + /// + /// The modal's title. + /// The modal's customId. + /// The modal's components. + /// Only TextInputComponents are allowed. + public ModalBuilder(string title, string customId, ModalComponentBuilder components = null) + { + Title = title; + CustomId = customId; + Components = components ?? new(); + } + + /// + /// Gets or sets the title of the current modal. + /// + public string Title { get; set; } + + /// + /// Gets or sets the custom ID of the current modal. + /// + public string CustomId + { + get => _customId; + set => _customId = value?.Length switch + { + > ComponentBuilder.MaxCustomIdLength => throw new ArgumentOutOfRangeException(nameof(value), $"Custom ID length must be less or equal to {ComponentBuilder.MaxCustomIdLength}."), + 0 => throw new ArgumentOutOfRangeException(nameof(value), "Custom ID length must be at least 1."), + _ => value + }; + } + + /// + /// Gets or sets the components of the current modal. + /// + public ModalComponentBuilder Components { get; set; } = new(); + + /// + /// Sets the title of the current modal. + /// + /// The value to set the title to. + /// The current builder. + public ModalBuilder WithTitle(string title) + { + Title = title; + return this; + } + + /// + /// Sets the custom id of the current modal. + /// + /// The value to set the custom id to. + /// The current builder. + public ModalBuilder WithCustomId(string customId) + { + CustomId = customId; + return this; + } + + /// + /// Adds a component to the current builder. + /// + /// The component to add. + /// The row to add the text input. + /// The current builder. + public ModalBuilder AddTextInput(TextInputBuilder component, int row = 0) + { + Components.WithTextInput(component, row); + return this; + } + + /// + /// Adds a to the current builder. + /// + /// The input's custom id. + /// The input's label. + /// The input's placeholder text. + /// The input's minimum length. + /// The input's maximum length. + /// The input's style. + /// The current builder. + public ModalBuilder AddTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, + string placeholder = "", int? minLength = null, int? maxLength = null, bool? required = null, string value = null) + => AddTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value)); + + /// + /// Adds multiple components to the current builder. + /// + /// The components to add. + /// The current builder + public ModalBuilder AddComponents(List components, int row) + { + components.ForEach(x => Components.AddComponent(x, row)); + return this; + } + + /// + /// Gets a by the specified . + /// + /// The type of the component to get. + /// The of the component to get. + /// + /// The component of type that was found, otherwise. + /// + public TMessageComponent GetComponent(string customId) + where TMessageComponent : class, IMessageComponent + { + Preconditions.NotNull(customId, nameof(customId)); + + return Components.ActionRows + ?.SelectMany(r => r.Components.OfType()) + .FirstOrDefault(c => c?.CustomId == customId); + } + + /// + /// Updates a by the specified . + /// + /// The of the input to update. + /// An action that configures the updated text input. + /// The current builder. + /// + /// Thrown when the to be updated was not found. + /// + public ModalBuilder UpdateTextInput(string customId, Action updateTextInput) + { + Preconditions.NotNull(customId, nameof(customId)); + + var component = GetComponent(customId) ?? throw new ArgumentException($"There is no component of type {nameof(TextInputComponent)} with the specified custom ID in this modal builder.", nameof(customId)); + var row = Components.ActionRows.First(r => r.Components.Contains(component)); + + var builder = new TextInputBuilder + { + Label = component.Label, + CustomId = component.CustomId, + Style = component.Style, + Placeholder = component.Placeholder, + MinLength = component.MinLength, + MaxLength = component.MaxLength, + Required = component.Required, + Value = component.Value + }; + + updateTextInput(builder); + + row.Components.Remove(component); + row.AddComponent(builder.Build()); + + return this; + } + + /// + /// Updates the value of a by the specified . + /// + /// The of the input to update. + /// The new value to put. + /// The current builder. + public ModalBuilder UpdateTextInput(string customId, object value) + { + UpdateTextInput(customId, x => x.Value = value?.ToString()); + return this; + } + + /// + /// Removes a component from this builder by the specified . + /// + /// The of the component to remove. + /// The current builder. + public ModalBuilder RemoveComponent(string customId) + { + Preconditions.NotNull(customId, nameof(customId)); + + Components.ActionRows?.ForEach(r => r.Components.RemoveAll(c => c.CustomId == customId)); + return this; + } + + /// + /// Removes all components of the given from this builder. + /// + /// The to remove. + /// The current builder. + public ModalBuilder RemoveComponentsOfType(ComponentType type) + { + Components.ActionRows?.ForEach(r => r.Components.RemoveAll(c => c.Type == type)); + return this; + } + + /// + /// Builds this builder into a . + /// + /// A with the same values as this builder. + /// Modals must have a custom ID. + /// Modals must have a title. + /// Only components of type are allowed. + public Modal Build() + { + if (string.IsNullOrEmpty(CustomId)) + throw new ArgumentException("Modals must have a custom ID.", nameof(CustomId)); + if (string.IsNullOrWhiteSpace(Title)) + throw new ArgumentException("Modals must have a title.", nameof(Title)); + if (Components.ActionRows?.SelectMany(r => r.Components).Any(c => c.Type != ComponentType.TextInput) ?? false) + throw new ArgumentException($"Only components of type {nameof(TextInputComponent)} are allowed.", nameof(Components)); + + return new(Title, CustomId, Components.Build()); + } + } + + /// + /// Represents a builder for creating a . + /// + public class ModalComponentBuilder + { + /// + /// The max length of a . + /// + public const int MaxCustomIdLength = 100; + + /// + /// The max amount of rows a can have. + /// + public const int MaxActionRowCount = 5; + + /// + /// Gets or sets the Action Rows for this Component Builder. + /// + /// cannot be null. + /// count exceeds . + public List ActionRows + { + get => _actionRows; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value), $"{nameof(ActionRows)} cannot be null."); + if (value.Count > MaxActionRowCount) + throw new ArgumentOutOfRangeException(nameof(value), $"Action row count must be less than or equal to {MaxActionRowCount}."); + _actionRows = value; + } + } + + private List _actionRows; + + /// + /// Creates a new builder from the provided list of components. + /// + /// The components to create the builder from. + /// The newly created builder. + public static ComponentBuilder FromComponents(IReadOnlyCollection components) + { + var builder = new ComponentBuilder(); + for (int i = 0; i != components.Count; i++) + { + var component = components.ElementAt(i); + builder.AddComponent(component, i); + } + return builder; + } + + internal void AddComponent(IMessageComponent component, int row) + { + switch (component) + { + case TextInputComponent text: + WithTextInput(text.Label, text.CustomId, text.Style, text.Placeholder, text.MinLength, text.MaxLength, row); + break; + case ActionRowComponent actionRow: + foreach (var cmp in actionRow.Components) + AddComponent(cmp, row); + break; + } + } + + /// + /// Adds a to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The input's custom id. + /// The input's label. + /// The input's placeholder text. + /// The input's minimum length. + /// The input's maximum length. + /// The input's style. + /// The current builder. + public ModalComponentBuilder WithTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, + string placeholder = null, int? minLength = null, int? maxLength = null, int row = 0, bool? required = null, + string value = null) + => WithTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value), row); + + /// + /// Adds a to the at the specific row. + /// If the row cannot accept the component then it will add it to a row that can. + /// + /// The to add. + /// The row to add the text input. + /// There are no more rows to add a text input to. + /// must be less than . + /// The current builder. + public ModalComponentBuilder WithTextInput(TextInputBuilder text, int row = 0) + { + Preconditions.LessThan(row, MaxActionRowCount, nameof(row)); + + var builtButton = text.Build(); + + if (_actionRows == null) + { + _actionRows = new List + { + new ActionRowBuilder().AddComponent(builtButton) + }; + } + else + { + if (_actionRows.Count == row) + _actionRows.Add(new ActionRowBuilder().AddComponent(builtButton)); + else + { + ActionRowBuilder actionRow; + if (_actionRows.Count > row) + actionRow = _actionRows.ElementAt(row); + else + { + actionRow = new ActionRowBuilder(); + _actionRows.Add(actionRow); + } + + if (actionRow.CanTakeComponent(builtButton)) + actionRow.AddComponent(builtButton); + else if (row < MaxActionRowCount) + WithTextInput(text, row + 1); + else + throw new InvalidOperationException($"There are no more rows to add {nameof(text)} to."); + } + } + + return this; + } + + /// + /// Get a representing the builder. + /// + /// A representing the builder. + public ModalComponent Build() + => new(ActionRows?.Select(x => x.Build()).ToList()); + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs new file mode 100644 index 0000000..ecc9072 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a component object used in s. + /// + public class ModalComponent + { + /// + /// Gets the components to be used in a modal. + /// + public IReadOnlyCollection Components { get; } + + internal ModalComponent(List components) + { + Components = components; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/IAutocompleteInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/IAutocompleteInteraction.cs new file mode 100644 index 0000000..4d89e5e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/IAutocompleteInteraction.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a . + /// + public interface IAutocompleteInteraction : IDiscordInteraction + { + /// + /// Gets the autocomplete data of this interaction. + /// + new IAutocompleteInteractionData Data { get; } + + /// + /// Responds to this interaction with a set of choices. + /// + /// + /// The set of choices for the user to pick from. + /// + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that + /// there is no choices for their autocompleted input. + /// + /// + /// The request options for this response. + Task RespondAsync(IEnumerable result, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/IAutocompleteInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/IAutocompleteInteractionData.cs new file mode 100644 index 0000000..e6d1e9f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/IAutocompleteInteractionData.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents data for a slash commands autocomplete interaction. + /// + public interface IAutocompleteInteractionData : IDiscordInteractionData + { + /// + /// Gets the name of the invoked command. + /// + string CommandName { get; } + + /// + /// Gets the id of the invoked command. + /// + ulong CommandId { get; } + + /// + /// Gets the type of the invoked command. + /// + ApplicationCommandType Type { get; } + + /// + /// Gets the version of the invoked command. + /// + ulong Version { get; } + + /// + /// Gets the current autocomplete option that is actively being filled out. + /// + AutocompleteOption Current { get; } + + /// + /// Gets a collection of all the other options the executing users has filled out. + /// + IReadOnlyCollection Options { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/ISlashCommandInteraction.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/ISlashCommandInteraction.cs new file mode 100644 index 0000000..f28c35e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/ISlashCommandInteraction.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + /// + /// Represents a slash command interaction. + /// + public interface ISlashCommandInteraction : IApplicationCommandInteraction + { + /// + /// Gets the data associated with this interaction. + /// + new IApplicationCommandInteractionData Data { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs new file mode 100644 index 0000000..c6fa663 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandBuilder.cs @@ -0,0 +1,1043 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net.Sockets; +using System.Text.RegularExpressions; + +namespace Discord +{ + /// + /// Represents a class used to build slash commands. + /// + public class SlashCommandBuilder + { + /// + /// Returns the maximum length a commands name allowed by Discord + /// + public const int MaxNameLength = 32; + /// + /// Returns the maximum length of a commands description allowed by Discord. + /// + public const int MaxDescriptionLength = 100; + /// + /// Returns the maximum count of command options allowed by Discord + /// + public const int MaxOptionsCount = 25; + + /// + /// Gets or sets the name of this slash command. + /// + public string Name + { + get => _name; + set + { + EnsureValidCommandName(value); + _name = value; + } + } + + /// + /// Gets or sets a 1-100 length description of this slash command + /// + public string Description + { + get => _description; + set + { + EnsureValidCommandDescription(value); + _description = value; + } + } + + /// + /// Gets or sets the options for this command. + /// + public List Options + { + get => _options; + set + { + Preconditions.AtMost(value?.Count ?? 0, MaxOptionsCount, nameof(value)); + _options = value; + } + } + + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations => _descriptionLocalizations; + + /// + /// Gets the context types this command can be executed in. if not set. + /// + public HashSet ContextTypes { get; set; } + + /// + /// Gets the installation method for this command. if not set. + /// + public HashSet IntegrationTypes { get; set; } + + /// + /// Gets or sets whether the command is enabled by default when the app is added to a guild + /// + public bool IsDefaultPermission { get; set; } = true; + + /// + /// Gets or sets whether or not this command can be used in DMs. + /// + [Obsolete("This property will be deprecated soon. Configure with ContextTypes instead.")] + public bool IsDMEnabled { get; set; } = true; + + /// + /// Gets or sets whether or not this command is age restricted. + /// + public bool IsNsfw { get; set; } = false; + + /// + /// Gets or sets the default permission required to use this slash command. + /// + public GuildPermission? DefaultMemberPermissions { get; set; } + + private string _name; + private string _description; + private Dictionary _nameLocalizations; + private Dictionary _descriptionLocalizations; + private List _options; + + /// + /// Build the current builder into a class. + /// + /// A that can be used to create slash commands. + public SlashCommandProperties Build() + { + // doing ?? 1 for now until we know default values (if these become non-optional) + Preconditions.AtLeast(ContextTypes?.Count ?? 1, 1, nameof(ContextTypes), "At least 1 context type must be specified"); + Preconditions.AtLeast(IntegrationTypes?.Count ?? 1, 1, nameof(IntegrationTypes), "At least 1 integration type must be specified"); + + var props = new SlashCommandProperties + { + Name = Name, + Description = Description, + IsDefaultPermission = IsDefaultPermission, + NameLocalizations = _nameLocalizations, + DescriptionLocalizations = _descriptionLocalizations, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = IsDMEnabled, +#pragma warning restore CS0618 // Type or member is obsolete + DefaultMemberPermissions = DefaultMemberPermissions ?? Optional.Unspecified, + IsNsfw = IsNsfw, + ContextTypes = ContextTypes ?? Optional>.Unspecified, + IntegrationTypes = IntegrationTypes ?? Optional>.Unspecified + }; + + if (Options != null && Options.Any()) + { + var options = new List(); + + Options.OrderByDescending(x => x.IsRequired ?? false).ToList().ForEach(x => options.Add(x.Build())); + + props.Options = options; + } + + return props; + } + + /// + /// Sets the field name. + /// + /// The value to set the field name to. + /// + /// The current builder. + /// + public SlashCommandBuilder WithName(string name) + { + Name = name; + return this; + } + + /// + /// Sets the description of the current command. + /// + /// The description of this command. + /// The current builder. + public SlashCommandBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets the default permission of the current command. + /// + /// The default permission value to set. + /// The current builder. + public SlashCommandBuilder WithDefaultPermission(bool value) + { + IsDefaultPermission = value; + return this; + } + + /// + /// Sets whether or not this command can be used in dms. + /// + /// if the command is available in dms, otherwise . + /// The current builder. + [Obsolete("This method will be deprecated soon. Configure using WithContextTypes instead.")] + public SlashCommandBuilder WithDMPermission(bool permission) + { + IsDMEnabled = permission; + return this; + } + + /// + /// Sets whether or not this command is age restricted. + /// + /// if the command is age restricted, otherwise . + /// The current builder. + public SlashCommandBuilder WithNsfw(bool permission) + { + IsNsfw = permission; + return this; + } + + /// + /// Sets the default member permissions required to use this application command. + /// + /// The permissions required to use this command. + /// The current builder. + public SlashCommandBuilder WithDefaultMemberPermissions(GuildPermission? permissions) + { + DefaultMemberPermissions = permissions; + return this; + } + + /// + /// Sets the installation method for this command. + /// + /// Installation types for this command. + /// The builder instance. + public SlashCommandBuilder WithIntegrationTypes(params ApplicationIntegrationType[] integrationTypes) + { + IntegrationTypes = integrationTypes is not null + ? new HashSet(integrationTypes) + : null; + return this; + } + + /// + /// Sets context types this command can be executed in. + /// + /// Context types the command can be executed in. + /// The builder instance. + public SlashCommandBuilder WithContextTypes(params InteractionContextType[] contextTypes) + { + ContextTypes = contextTypes is not null + ? new HashSet(contextTypes) + : null; + return this; + } + + /// + /// Adds an option to the current slash command. + /// + /// The name of the option to add. + /// The type of this option. + /// The description of this option. + /// If this option is required for this command. + /// If this option is the default option. + /// If this option is set to autocomplete. + /// The options of the option to add. + /// The allowed channel types for this option. + /// Localization dictionary for the name field of this command. + /// Localization dictionary for the description field of this command. + /// The choices of this option. + /// The smallest number value the user can input. + /// The largest number value the user can input. + /// The current builder. + public SlashCommandBuilder AddOption(string name, ApplicationCommandOptionType type, + string description, bool? isRequired = null, bool? isDefault = null, bool isAutocomplete = false, double? minValue = null, double? maxValue = null, + List options = null, List channelTypes = null, IDictionary nameLocalizations = null, + IDictionary descriptionLocalizations = null, + int? minLength = null, int? maxLength = null, params ApplicationCommandOptionChoiceProperties[] choices) + { + Preconditions.Options(name, description); + + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + + // make sure theres only one option with default set to true + if (isDefault == true && Options?.Any(x => x.IsDefault == true) == true) + throw new ArgumentException("There can only be one command option with default set to true!", nameof(isDefault)); + + var option = new SlashCommandOptionBuilder + { + Name = name, + Description = description, + IsRequired = isRequired, + IsDefault = isDefault, + Options = options, + Type = type, + IsAutocomplete = isAutocomplete, + Choices = (choices ?? Array.Empty()).ToList(), + ChannelTypes = channelTypes, + MinValue = minValue, + MaxValue = maxValue, + MinLength = minLength, + MaxLength = maxLength, + }; + + if (nameLocalizations is not null) + option.WithNameLocalizations(nameLocalizations); + + if (descriptionLocalizations is not null) + option.WithDescriptionLocalizations(descriptionLocalizations); + + return AddOption(option); + } + + /// + /// Adds an option to this slash command. + /// + /// The option to add. + /// The current builder. + public SlashCommandBuilder AddOption(SlashCommandOptionBuilder option) + { + Options ??= new List(); + + if (Options.Count >= MaxOptionsCount) + throw new InvalidOperationException($"Cannot have more than {MaxOptionsCount} options!"); + + Preconditions.NotNull(option, nameof(option)); + Preconditions.Options(option.Name, option.Description); // this is a double-check when this method is called via AddOption(string name... ) + + Options.Add(option); + return this; + } + /// + /// Adds a collection of options to the current slash command. + /// + /// The collection of options to add. + /// The current builder. + public SlashCommandBuilder AddOptions(params SlashCommandOptionBuilder[] options) + { + if (options == null) + throw new ArgumentNullException(nameof(options), "Options cannot be null!"); + + Options ??= new List(); + + if (Options.Count + options.Length > MaxOptionsCount) + throw new ArgumentOutOfRangeException(nameof(options), $"Cannot have more than {MaxOptionsCount} options!"); + + foreach (var option in options) + Preconditions.Options(option.Name, option.Description); + + Options.AddRange(options); + return this; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command. + /// + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandBuilder 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; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the description field of this command. + /// + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandBuilder WithDescriptionLocalizations(IDictionary descriptionLocalizations) + { + if (descriptionLocalizations is null) + throw new ArgumentNullException(nameof(descriptionLocalizations)); + + foreach (var (locale, description) in descriptionLocalizations) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandDescription(description); + } + + _descriptionLocalizations = new Dictionary(descriptionLocalizations); + return this; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + public SlashCommandBuilder 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; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the description field. + /// The current builder. + /// Thrown if is an invalid locale string. + public SlashCommandBuilder AddDescriptionLocalization(string locale, string description) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandDescription(description); + + _descriptionLocalizations ??= new(); + _descriptionLocalizations.Add(locale, description); + + 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)); + + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + + if (name.Any(char.IsUpper)) + throw new FormatException("Name cannot contain any uppercase characters."); + } + + internal static void EnsureValidCommandDescription(string description) + { + Preconditions.NotNullOrEmpty(description, nameof(description)); + Preconditions.AtLeast(description.Length, 1, nameof(description)); + Preconditions.AtMost(description.Length, MaxDescriptionLength, nameof(description)); + } + } + + /// + /// Represents a class used to build options for the . + /// + public class SlashCommandOptionBuilder + { + /// + /// The max length of a choice's name allowed by Discord. + /// + public const int ChoiceNameMaxLength = 100; + + /// + /// The maximum number of choices allowed by Discord. + /// + public const int MaxChoiceCount = 25; + + private string _name; + private string _description; + private Dictionary _nameLocalizations; + private Dictionary _descriptionLocalizations; + + /// + /// Gets or sets the name of this option. + /// + public string Name + { + get => _name; + set + { + if (value != null) + { + EnsureValidCommandOptionName(value); + } + + _name = value; + } + } + + /// + /// Gets or sets the description of this option. + /// + public string Description + { + get => _description; + set + { + if (value != null) + { + EnsureValidCommandOptionDescription(value); + } + + _description = value; + } + } + + /// + /// Gets or sets the type of this option. + /// + public ApplicationCommandOptionType Type { get; set; } + + /// + /// Gets or sets whether or not this options is the first required option for the user to complete. only one option can be default. + /// + public bool? IsDefault { get; set; } + + /// + /// Gets or sets if the option is required. + /// + public bool? IsRequired { get; set; } = null; + + /// + /// Gets or sets whether or not this option supports autocomplete. + /// + public bool IsAutocomplete { get; set; } + + /// + /// Gets or sets the smallest number value the user can input. + /// + public double? MinValue { get; set; } + + /// + /// Gets or sets the largest number value the user can input. + /// + public double? MaxValue { get; set; } + + /// + /// Gets or sets the minimum allowed length for a string input. + /// + public int? MinLength { get; set; } + + /// + /// Gets or sets the maximum allowed length for a string input. + /// + public int? MaxLength { get; set; } + + /// + /// Gets or sets the choices for string and int types for the user to pick from. + /// + public List Choices { get; set; } + + /// + /// Gets or sets if this option is a subcommand or subcommand group type, these nested options will be the parameters. + /// + public List Options { get; set; } + + /// + /// Gets or sets the allowed channel types for this option. + /// + public List ChannelTypes { get; set; } + + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations => _nameLocalizations; + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations => _descriptionLocalizations; + + /// + /// Builds the current option. + /// + /// The built version of this option. + public ApplicationCommandOptionProperties Build() + { + bool isSubType = Type == ApplicationCommandOptionType.SubCommandGroup; + bool isIntType = Type == ApplicationCommandOptionType.Integer; + bool isStrType = Type == ApplicationCommandOptionType.String; + + if (isSubType && (Options == null || !Options.Any())) + throw new InvalidOperationException("SubCommands/SubCommandGroups must have at least one option"); + + if (!isSubType && Options != null && Options.Any() && Type != ApplicationCommandOptionType.SubCommand) + throw new InvalidOperationException($"Cannot have options on {Type} type"); + + if (isIntType && MinValue != null && MinValue % 1 != 0) + throw new InvalidOperationException("MinValue cannot have decimals on Integer command options."); + + if (isIntType && MaxValue != null && MaxValue % 1 != 0) + throw new InvalidOperationException("MaxValue cannot have decimals on Integer command options."); + + if (isStrType && MinLength is not null && MinLength < 0) + throw new InvalidOperationException("MinLength cannot be smaller than 0."); + + if (isStrType && MaxLength is not null && MaxLength < 1) + throw new InvalidOperationException("MaxLength cannot be smaller than 1."); + + return new ApplicationCommandOptionProperties + { + Name = Name, + Description = Description, + IsDefault = IsDefault, + IsRequired = IsRequired, + Type = Type, + Options = Options?.Count > 0 + ? Options.OrderByDescending(x => x.IsRequired ?? false).Select(x => x.Build()).ToList() + : new List(), + Choices = Choices, + IsAutocomplete = IsAutocomplete, + ChannelTypes = ChannelTypes, + MinValue = MinValue, + MaxValue = MaxValue, + NameLocalizations = _nameLocalizations, + DescriptionLocalizations = _descriptionLocalizations, + MinLength = MinLength, + MaxLength = MaxLength, + }; + } + + /// + /// Adds an option to the current slash command. + /// + /// The name of the option to add. + /// The type of this option. + /// The description of this option. + /// If this option is required for this command. + /// If this option is the default option. + /// If this option supports autocomplete. + /// The options of the option to add. + /// The allowed channel types for this option. + /// Localization dictionary for the description field of this command. + /// Localization dictionary for the description field of this command. + /// The choices of this option. + /// The smallest number value the user can input. + /// The largest number value the user can input. + /// The current builder. + public SlashCommandOptionBuilder AddOption(string name, ApplicationCommandOptionType type, + string description, bool? isRequired = null, bool isDefault = false, bool isAutocomplete = false, double? minValue = null, double? maxValue = null, + List options = null, List channelTypes = null, IDictionary nameLocalizations = null, + IDictionary descriptionLocalizations = null, + int? minLength = null, int? maxLength = null, params ApplicationCommandOptionChoiceProperties[] choices) + { + Preconditions.Options(name, description); + + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + + // make sure theres only one option with default set to true + if (isDefault && Options?.Any(x => x.IsDefault == true) == true) + throw new ArgumentException("There can only be one command option with default set to true!", nameof(isDefault)); + + var option = new SlashCommandOptionBuilder + { + Name = name, + Description = description, + IsRequired = isRequired, + IsDefault = isDefault, + IsAutocomplete = isAutocomplete, + MinValue = minValue, + MaxValue = maxValue, + MinLength = minLength, + MaxLength = maxLength, + Options = options, + Type = type, + Choices = (choices ?? Array.Empty()).ToList(), + ChannelTypes = channelTypes, + }; + + if (nameLocalizations is not null) + option.WithNameLocalizations(nameLocalizations); + + if (descriptionLocalizations is not null) + option.WithDescriptionLocalizations(descriptionLocalizations); + + return AddOption(option); + } + /// + /// Adds a sub option to the current option. + /// + /// The sub option to add. + /// The current builder. + public SlashCommandOptionBuilder AddOption(SlashCommandOptionBuilder option) + { + Options ??= new List(); + + if (Options.Count >= SlashCommandBuilder.MaxOptionsCount) + throw new InvalidOperationException($"There can only be {SlashCommandBuilder.MaxOptionsCount} options per sub command group!"); + + Preconditions.NotNull(option, nameof(option)); + Preconditions.Options(option.Name, option.Description); // double check again + + Options.Add(option); + return this; + } + + /// + /// Adds a collection of options to the current option. + /// + /// The collection of options to add. + /// The current builder. + public SlashCommandOptionBuilder AddOptions(params SlashCommandOptionBuilder[] options) + { + if (options == null) + throw new ArgumentNullException(nameof(options), "Options cannot be null!"); + + Options ??= new List(); + + if (Options.Count + options.Length > SlashCommandBuilder.MaxOptionsCount) + throw new ArgumentOutOfRangeException(nameof(options), $"There can only be {SlashCommandBuilder.MaxOptionsCount} options per sub command group!"); + + foreach (var option in options) + Preconditions.Options(option.Name, option.Description); + + Options.AddRange(options); + return this; + } + + /// + /// Adds a choice to the current option. + /// + /// The name of the choice. + /// The value of the choice. + /// The localization dictionary for to use the name field of this command option choice. + /// The current builder. + public SlashCommandOptionBuilder AddChoice(string name, int value, IDictionary nameLocalizations = null) + { + return AddChoiceInternal(name, value, nameLocalizations); + } + + /// + /// Adds a choice to the current option. + /// + /// The name of the choice. + /// The value of the choice. + /// The localization dictionary for to use the name field of this command option choice. + /// The current builder. + public SlashCommandOptionBuilder AddChoice(string name, string value, IDictionary nameLocalizations = null) + { + return AddChoiceInternal(name, value, nameLocalizations); + } + + /// + /// Adds a choice to the current option. + /// + /// The name of the choice. + /// The value of the choice. + /// Localization dictionary for the description field of this command. + /// The current builder. + public SlashCommandOptionBuilder AddChoice(string name, double value, IDictionary nameLocalizations = null) + { + return AddChoiceInternal(name, value, nameLocalizations); + } + + /// + /// Adds a choice to the current option. + /// + /// The name of the choice. + /// The value of the choice. + /// The localization dictionary to use for the name field of this command option choice. + /// The current builder. + public SlashCommandOptionBuilder AddChoice(string name, float value, IDictionary nameLocalizations = null) + { + return AddChoiceInternal(name, value, nameLocalizations); + } + + /// + /// Adds a choice to the current option. + /// + /// The name of the choice. + /// The value of the choice. + /// The localization dictionary to use for the name field of this command option choice. + /// The current builder. + public SlashCommandOptionBuilder AddChoice(string name, long value, IDictionary nameLocalizations = null) + { + return AddChoiceInternal(name, value, nameLocalizations); + } + + private SlashCommandOptionBuilder AddChoiceInternal(string name, object value, IDictionary nameLocalizations = null) + { + Choices ??= new List(); + + if (Choices.Count >= MaxChoiceCount) + throw new InvalidOperationException($"Cannot add more than {MaxChoiceCount} choices!"); + + Preconditions.NotNull(name, nameof(name)); + Preconditions.NotNull(value, nameof(value)); + + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, 100, nameof(name)); + + if (value is string str) + { + Preconditions.AtLeast(str.Length, 1, nameof(value)); + Preconditions.AtMost(str.Length, 100, nameof(value)); + } + + Choices.Add(new ApplicationCommandOptionChoiceProperties + { + Name = name, + Value = value, + NameLocalizations = nameLocalizations + }); + + return this; + } + + /// + /// Adds a channel type to the current option. + /// + /// The to add. + /// The current builder. + public SlashCommandOptionBuilder AddChannelType(ChannelType channelType) + { + ChannelTypes ??= new List(); + + ChannelTypes.Add(channelType); + + return this; + } + + /// + /// Sets the current builders name. + /// + /// The name to set the current option builder. + /// The current builder. + public SlashCommandOptionBuilder WithName(string name) + { + Name = name; + + return this; + } + + /// + /// Sets the current builders description. + /// + /// The description to set. + /// The current builder. + public SlashCommandOptionBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets the current builders required field. + /// + /// The value to set. + /// The current builder. + public SlashCommandOptionBuilder WithRequired(bool value) + { + IsRequired = value; + return this; + } + + /// + /// Sets the current builders default field. + /// + /// The value to set. + /// The current builder. + public SlashCommandOptionBuilder WithDefault(bool value) + { + IsDefault = value; + return this; + } + + /// + /// Sets the current builders autocomplete field. + /// + /// The value to set. + /// The current builder. + public SlashCommandOptionBuilder WithAutocomplete(bool value) + { + IsAutocomplete = value; + return this; + } + + /// + /// Sets the current builders min value field. + /// + /// The value to set. + /// The current builder. + public SlashCommandOptionBuilder WithMinValue(double value) + { + MinValue = value; + return this; + } + + /// + /// Sets the current builders max value field. + /// + /// The value to set. + /// The current builder. + public SlashCommandOptionBuilder WithMaxValue(double value) + { + MaxValue = value; + return this; + } + + /// + /// Sets the current builders min length field. + /// + /// The value to set. + /// The current builder. + public SlashCommandOptionBuilder WithMinLength(int length) + { + MinLength = length; + return this; + } + + /// + /// Sets the current builders max length field. + /// + /// The value to set. + /// The current builder. + public SlashCommandOptionBuilder WithMaxLength(int length) + { + MaxLength = length; + return this; + } + + /// + /// Sets the current type of this builder. + /// + /// The type to set. + /// The current builder. + public SlashCommandOptionBuilder WithType(ApplicationCommandOptionType type) + { + Type = type; + return this; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the name field of this command option. + /// The current builder. + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandOptionBuilder 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)); + + EnsureValidCommandOptionName(name); + } + + _nameLocalizations = new Dictionary(nameLocalizations); + return this; + } + + /// + /// Sets the collection. + /// + /// The localization dictionary to use for the description field of this command option. + /// The current builder. + /// Thrown if is null. + /// Thrown if any dictionary key is an invalid locale string. + public SlashCommandOptionBuilder WithDescriptionLocalizations(IDictionary descriptionLocalizations) + { + if (descriptionLocalizations is null) + throw new ArgumentNullException(nameof(descriptionLocalizations)); + + foreach (var (locale, description) in descriptionLocalizations) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandOptionDescription(description); + } + + _descriptionLocalizations = new Dictionary(descriptionLocalizations); + return this; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the name field. + /// The current builder. + /// Thrown if is an invalid locale string. + public SlashCommandOptionBuilder AddNameLocalization(string locale, string name) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandOptionName(name); + + _nameLocalizations ??= new(); + _nameLocalizations.Add(locale, name); + + return this; + } + + /// + /// Adds a new entry to the collection. + /// + /// Locale of the entry. + /// Localized string for the description field. + /// The current builder. + /// Thrown if is an invalid locale string. + public SlashCommandOptionBuilder AddDescriptionLocalization(string locale, string description) + { + if (!Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"Invalid locale: {locale}", nameof(locale)); + + EnsureValidCommandOptionDescription(description); + + _descriptionLocalizations ??= new(); + _descriptionLocalizations.Add(locale, description); + + return this; + } + + private static void EnsureValidCommandOptionName(string name) + { + Preconditions.AtLeast(name.Length, 1, nameof(name)); + Preconditions.AtMost(name.Length, SlashCommandBuilder.MaxNameLength, nameof(name)); + + // https://discord.com/developers/docs/interactions/application-commands + if (!Regex.IsMatch(name, @"^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$")) + throw new ArgumentException(@"Name must match the regex ^[-_\p{L}\p{N}\p{IsDevanagari}\p{IsThai}]{1,32}$", nameof(name)); + } + + private static void EnsureValidCommandOptionDescription(string description) + { + Preconditions.AtLeast(description.Length, 1, nameof(description)); + Preconditions.AtMost(description.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(description)); + } + } +} diff --git a/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandProperties.cs b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandProperties.cs new file mode 100644 index 0000000..d790e8f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/SlashCommands/SlashCommandProperties.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a class used to create slash commands. + /// + public class SlashCommandProperties : ApplicationCommandProperties + { + internal override ApplicationCommandType Type => ApplicationCommandType.Slash; + + /// + /// Gets or sets the description of this command. + /// + public Optional Description { get; set; } + + /// + /// Gets or sets the options for this command. + /// + public Optional> Options { get; set; } + + internal SlashCommandProperties() { } + } +} diff --git a/src/Discord.Net.Core/Entities/Invites/IInvite.cs b/src/Discord.Net.Core/Entities/Invites/IInvite.cs new file mode 100644 index 0000000..37a412f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Invites/IInvite.cs @@ -0,0 +1,125 @@ +using System; + +namespace Discord +{ + /// + /// Represents a generic invite object. + /// + public interface IInvite : IEntity, IDeletable + { + /// + /// Gets the unique identifier for this invite. + /// + /// + /// A string containing the invite code (e.g. FTqNnyS). + /// + string Code { get; } + /// + /// Gets the URL used to accept this invite using . + /// + /// + /// A string containing the full invite URL (e.g. https://discord.gg/FTqNnyS). + /// + string Url { get; } + + /// + /// Gets the user that created this invite. + /// + /// + /// A user that created this invite. + /// + IUser Inviter { get; } + /// + /// Gets the channel this invite is linked to. + /// + /// + /// A generic channel that the invite points to. + /// + IChannel Channel { get; } + /// + /// Gets the type of the channel this invite is linked to. + /// + ChannelType ChannelType { get; } + /// + /// Gets the ID of the channel this invite is linked to. + /// + /// + /// An representing the channel snowflake identifier that the invite points to. + /// + ulong ChannelId { get; } + /// + /// Gets the name of the channel this invite is linked to. + /// + /// + /// A string containing the name of the channel that the invite points to. + /// + string ChannelName { get; } + + /// + /// Gets the guild this invite is linked to. + /// + /// + /// A guild object representing the guild that the invite points to. + /// + IGuild Guild { get; } + + /// + /// Gets the ID of the guild this invite is linked to. + /// + /// + /// An representing the guild snowflake identifier that the invite points to. + /// + ulong? GuildId { get; } + /// + /// Gets the name of the guild this invite is linked to. + /// + /// + /// A string containing the name of the guild that the invite points to. + /// + string GuildName { get; } + /// + /// Gets the approximated count of online members in the guild. + /// + /// + /// An representing the approximated online member count of the guild that the + /// invite points to; if one cannot be obtained. + /// + int? PresenceCount { get; } + /// + /// Gets the approximated count of total members in the guild. + /// + /// + /// An representing the approximated total member count of the guild that the + /// invite points to; if one cannot be obtained. + /// + int? MemberCount { get; } + /// + /// Gets the user this invite is linked to via . + /// + /// + /// A user that is linked to this invite. + /// + IUser TargetUser { get; } + /// + /// Gets the type of the linked for this invite. + /// + /// + /// The type of the linked user that is linked to this invite. + /// + TargetUserType TargetUserType { get; } + + /// + /// Gets the embedded application to open for this voice channel embedded application invite. + /// + /// + /// A partial object. if + /// is not . + /// + IApplication Application { get; } + + /// + /// Gets the expiration date of this invite. if the invite never expires. + /// + DateTimeOffset? ExpiresAt { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Invites/IInviteMetadata.cs b/src/Discord.Net.Core/Entities/Invites/IInviteMetadata.cs new file mode 100644 index 0000000..0f9ff4f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Invites/IInviteMetadata.cs @@ -0,0 +1,49 @@ +using System; + +namespace Discord +{ + /// + /// Represents additional information regarding the generic invite object. + /// + public interface IInviteMetadata : IInvite + { + /// + /// Gets a value that indicates whether the invite is a temporary one. + /// + /// + /// if users accepting this invite will be removed from the guild when they log off; otherwise + /// . + /// + bool IsTemporary { get; } + /// + /// Gets the time (in seconds) until the invite expires. + /// + /// + /// An representing the time in seconds until this invite expires; if this + /// invite never expires. + /// + int? MaxAge { get; } + /// + /// Gets the max number of uses this invite may have. + /// + /// + /// An representing the number of uses this invite may be accepted until it is removed + /// from the guild; if none is set. + /// + int? MaxUses { get; } + /// + /// Gets the number of times this invite has been used. + /// + /// + /// An representing the number of times this invite has been used. + /// + int? Uses { get; } + /// + /// Gets when this invite was created. + /// + /// + /// A representing the time of which the invite was first created. + /// + DateTimeOffset? CreatedAt { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Invites/TargetUserType.cs b/src/Discord.Net.Core/Entities/Invites/TargetUserType.cs new file mode 100644 index 0000000..e1818d7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Invites/TargetUserType.cs @@ -0,0 +1,18 @@ +namespace Discord +{ + public enum TargetUserType + { + /// + /// The invite whose target user type is not defined. + /// + Undefined = 0, + /// + /// The invite is for a Go Live stream. + /// + Stream = 1, + /// + /// The invite is for embedded application. + /// + EmbeddedApplication = 2 + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/AllowedMentionTypes.cs b/src/Discord.Net.Core/Entities/Messages/AllowedMentionTypes.cs new file mode 100644 index 0000000..7e8f05d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/AllowedMentionTypes.cs @@ -0,0 +1,34 @@ +using System; + +namespace Discord +{ + /// + /// Specifies the type of mentions that will be notified from the message content. + /// + [Flags] + public enum AllowedMentionTypes + { + /// + /// No flag is set. + /// + /// + /// This flag is not used to control mentions. + /// + /// It will always be present and does not mean mentions will not be allowed. + /// + /// + None = 0, + /// + /// Controls role mentions. + /// + Roles = 1, + /// + /// Controls user mentions. + /// + Users = 2, + /// + /// Controls @everyone and @here mentions. + /// + Everyone = 4, + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/AllowedMentions.cs b/src/Discord.Net.Core/Entities/Messages/AllowedMentions.cs new file mode 100644 index 0000000..42e3b75 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/AllowedMentions.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Defines which mentions and types of mentions that will notify users from the message content. + /// + public class AllowedMentions + { + private static readonly Lazy none = new Lazy(() => new AllowedMentions()); + private static readonly Lazy all = new Lazy(() => + new AllowedMentions(AllowedMentionTypes.Everyone | AllowedMentionTypes.Users | AllowedMentionTypes.Roles)); + + /// + /// Gets a value which indicates that no mentions in the message content should notify users. + /// + public static AllowedMentions None => none.Value; + + /// + /// Gets a value which indicates that all mentions in the message content should notify users. + /// + public static AllowedMentions All => all.Value; + + /// + /// Gets or sets the type of mentions that will be parsed from the message content. + /// + /// + /// The flag is mutually exclusive with the + /// property, and the flag is mutually exclusive with the + /// property. + /// If , only the ids specified in and will be mentioned. + /// + public AllowedMentionTypes? AllowedTypes { get; set; } + + /// + /// Gets or sets the list of all role ids that will be mentioned. + /// This property is mutually exclusive with the + /// flag of the property. If the flag is set, the value of this property + /// must be or empty. + /// + public List RoleIds { get; set; } = new List(); + + /// + /// Gets or sets the list of all user ids that will be mentioned. + /// This property is mutually exclusive with the + /// flag of the property. If the flag is set, the value of this property + /// must be or empty. + /// + public List UserIds { get; set; } = new List(); + + /// + /// Gets or sets whether to mention the author of the message you are replying to or not. + /// + /// + /// Specifically for inline replies. + /// + public bool? MentionRepliedUser { get; set; } = null; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The types of mentions to parse from the message content. + /// If , only the ids specified in and will be mentioned. + /// + public AllowedMentions(AllowedMentionTypes? allowedTypes = null) + { + AllowedTypes = allowedTypes; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/AttachmentFlags.cs b/src/Discord.Net.Core/Entities/Messages/AttachmentFlags.cs new file mode 100644 index 0000000..83a61ba --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/AttachmentFlags.cs @@ -0,0 +1,27 @@ +using System; + +namespace Discord; + +[Flags] +public enum AttachmentFlags +{ + /// + /// The attachment has no flags. + /// + None = 0, + + /// + /// Indicates that this attachment is a clip. + /// + IsClip = 1 << 0, + + /// + /// Indicates that this attachment is a thumbnail. + /// + IsThumbnail = 1 << 1, + + /// + /// Indicates that this attachment has been edited using the remix feature on mobile. + /// + IsRemix = 1 << 2, +} diff --git a/src/Discord.Net.Core/Entities/Messages/Embed.cs b/src/Discord.Net.Core/Entities/Messages/Embed.cs new file mode 100644 index 0000000..bff33c7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/Embed.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; + +namespace Discord +{ + /// + /// Represents an embed object seen in an . + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class Embed : IEmbed + { + /// + public EmbedType Type { get; } + + /// + public string Description { get; internal set; } + /// + public string Url { get; internal set; } + /// + public string Title { get; internal set; } + /// + public DateTimeOffset? Timestamp { get; internal set; } + /// + public Color? Color { get; internal set; } + /// + public EmbedImage? Image { get; internal set; } + /// + public EmbedVideo? Video { get; internal set; } + /// + public EmbedAuthor? Author { get; internal set; } + /// + public EmbedFooter? Footer { get; internal set; } + /// + public EmbedProvider? Provider { get; internal set; } + /// + public EmbedThumbnail? Thumbnail { get; internal set; } + /// + public ImmutableArray Fields { get; internal set; } + + internal Embed(EmbedType type) + { + Type = type; + Fields = ImmutableArray.Create(); + } + internal Embed(EmbedType type, + string title, + string description, + string url, + DateTimeOffset? timestamp, + Color? color, + EmbedImage? image, + EmbedVideo? video, + EmbedAuthor? author, + EmbedFooter? footer, + EmbedProvider? provider, + EmbedThumbnail? thumbnail, + ImmutableArray fields) + { + Type = type; + Title = title; + Description = description; + Url = url; + Color = color; + Timestamp = timestamp; + Image = image; + Video = video; + Author = author; + Footer = footer; + Provider = provider; + Thumbnail = thumbnail; + Fields = fields; + } + + /// + /// Gets the total length of all embed properties. + /// + public int Length + { + get + { + int titleLength = Title?.Length ?? 0; + int authorLength = Author?.Name?.Length ?? 0; + int descriptionLength = Description?.Length ?? 0; + int footerLength = Footer?.Text?.Length ?? 0; + int fieldSum = Fields.Sum(f => f.Name?.Length + f.Value?.ToString().Length) ?? 0; + return titleLength + authorLength + descriptionLength + footerLength + fieldSum; + } + } + + /// + /// Gets the title of the embed. + /// + public override string ToString() => Title; + private string DebuggerDisplay => $"{Title} ({Type})"; + + public static bool operator ==(Embed left, Embed right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(Embed left, Embed right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is Embed embed && Equals(embed); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(Embed embed) + => GetHashCode() == embed?.GetHashCode(); + + /// + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 23 + (Type, Title, Description, Timestamp, Color, Image, Video, Author, Footer, Provider, Thumbnail).GetHashCode(); + foreach (var field in Fields) + hash = hash * 23 + field.GetHashCode(); + return hash; + } + } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs b/src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs new file mode 100644 index 0000000..fdd51e6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedAuthor.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// + /// A author field of an . + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct EmbedAuthor + { + /// + /// Gets the name of the author field. + /// + public string Name { get; internal set; } + /// + /// Gets the URL of the author field. + /// + public string Url { get; internal set; } + /// + /// Gets the icon URL of the author field. + /// + public string IconUrl { get; internal set; } + /// + /// Gets the proxified icon URL of the author field. + /// + public string ProxyIconUrl { get; internal set; } + + internal EmbedAuthor(string name, string url, string iconUrl, string proxyIconUrl) + { + Name = name; + Url = url; + IconUrl = iconUrl; + ProxyIconUrl = proxyIconUrl; + } + + private string DebuggerDisplay => $"{Name} ({Url})"; + /// + /// Gets the name of the author field. + /// + /// + /// + /// + public override string ToString() => Name; + + public static bool operator ==(EmbedAuthor? left, EmbedAuthor? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedAuthor? left, EmbedAuthor? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedAuthor embedAuthor && Equals(embedAuthor); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedAuthor? embedAuthor) + => GetHashCode() == embedAuthor?.GetHashCode(); + + /// + public override int GetHashCode() + => (Name, Url, IconUrl).GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs b/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs new file mode 100644 index 0000000..56301f4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs @@ -0,0 +1,883 @@ +using Discord.Utils; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord +{ + /// + /// Represents a builder class for creating a . + /// + public class EmbedBuilder + { + private string _title; + private string _description; + private EmbedImage? _image; + private EmbedThumbnail? _thumbnail; + private List _fields; + + /// + /// Returns the maximum number of fields allowed by Discord. + /// + public const int MaxFieldCount = 25; + /// + /// Returns the maximum length of title allowed by Discord. + /// + public const int MaxTitleLength = 256; + /// + /// Returns the maximum length of description allowed by Discord. + /// + public const int MaxDescriptionLength = 4096; + /// + /// Returns the maximum length of total characters allowed by Discord. + /// + public const int MaxEmbedLength = 6000; + + /// Initializes a new class. + public EmbedBuilder() + { + Fields = new List(); + } + + /// Gets or sets the title of an . + /// Title length exceeds . + /// + /// The title of the embed. + public string Title + { + get => _title; + set + { + if (value?.Length > MaxTitleLength) + throw new ArgumentException(message: $"Title length must be less than or equal to {MaxTitleLength}.", paramName: nameof(Title)); + _title = value; + } + } + + /// Gets or sets the description of an . + /// Description length exceeds . + /// The description of the embed. + public string Description + { + get => _description; + set + { + if (value?.Length > MaxDescriptionLength) + throw new ArgumentException(message: $"Description length must be less than or equal to {MaxDescriptionLength}.", paramName: nameof(Description)); + _description = value; + } + } + + /// Gets or sets the URL of an . + /// Url is not a well-formed . + /// The URL of the embed. + public string Url { get; set; } + /// Gets or sets the thumbnail URL of an . + /// Url is not a well-formed . + /// The thumbnail URL of the embed. + public string ThumbnailUrl + { + get => _thumbnail?.Url; + set => _thumbnail = new EmbedThumbnail(value, null, null, null); + } + /// Gets or sets the image URL of an . + /// Url is not a well-formed . + /// The image URL of the embed. + public string ImageUrl + { + get => _image?.Url; + set => _image = new EmbedImage(value, null, null, null); + } + + /// Gets or sets the list of of an . + /// An embed builder's fields collection is set to + /// . + /// Fields count exceeds . + /// + /// The list of existing . + public List Fields + { + get => _fields; + set + { + if (value == null) + throw new ArgumentNullException(paramName: nameof(Fields), message: "Cannot set an embed builder's fields collection to null."); + if (value.Count > MaxFieldCount) + throw new ArgumentException(message: $"Field count must be less than or equal to {MaxFieldCount}.", paramName: nameof(Fields)); + _fields = value; + } + } + + /// + /// Gets or sets the timestamp of an . + /// + /// + /// The timestamp of the embed, or if none is set. + /// + public DateTimeOffset? Timestamp { get; set; } + /// + /// Gets or sets the sidebar color of an . + /// + /// + /// The color of the embed, or if none is set. + /// + public Color? Color { get; set; } + /// + /// Gets or sets the of an . + /// + /// + /// The author field builder of the embed, or if none is set. + /// + public EmbedAuthorBuilder Author { get; set; } + /// + /// Gets or sets the of an . + /// + /// + /// The footer field builder of the embed, or if none is set. + /// + public EmbedFooterBuilder Footer { get; set; } + + /// + /// Gets the total length of all embed properties. + /// + /// + /// The combined length of , , , + /// , , and . + /// + public int Length + { + get + { + int titleLength = Title?.Length ?? 0; + int authorLength = Author?.Name?.Length ?? 0; + int descriptionLength = Description?.Length ?? 0; + int footerLength = Footer?.Text?.Length ?? 0; + int fieldSum = Fields.Sum(f => f.Name.Length + (f.Value?.ToString()?.Length ?? 0)); + + return titleLength + authorLength + descriptionLength + footerLength + fieldSum; + } + } + + /// + /// Sets the title of an . + /// + /// The title to be set. + /// + /// The current builder. + /// + public EmbedBuilder WithTitle(string title) + { + Title = title; + return this; + } + /// + /// Sets the description of an . + /// + /// The description to be set. + /// + /// The current builder. + /// + public EmbedBuilder WithDescription(string description) + { + Description = description; + return this; + } + /// + /// Sets the URL of an . + /// + /// The URL to be set. + /// + /// The current builder. + /// + public EmbedBuilder WithUrl(string url) + { + Url = url; + return this; + } + /// + /// Sets the thumbnail URL of an . + /// + /// The thumbnail URL to be set. + /// + /// The current builder. + /// + public EmbedBuilder WithThumbnailUrl(string thumbnailUrl) + { + ThumbnailUrl = thumbnailUrl; + return this; + } + /// + /// Sets the image URL of an . + /// + /// The image URL to be set. + /// + /// The current builder. + /// + public EmbedBuilder WithImageUrl(string imageUrl) + { + ImageUrl = imageUrl; + return this; + } + /// + /// Sets the timestamp of an to the current time. + /// + /// + /// The current builder. + /// + public EmbedBuilder WithCurrentTimestamp() + { + Timestamp = DateTimeOffset.UtcNow; + return this; + } + /// + /// Sets the timestamp of an . + /// + /// The timestamp to be set. + /// + /// The current builder. + /// + public EmbedBuilder WithTimestamp(DateTimeOffset dateTimeOffset) + { + Timestamp = dateTimeOffset; + return this; + } + /// + /// Sets the sidebar color of an . + /// + /// The color to be set. + /// + /// The current builder. + /// + public EmbedBuilder WithColor(Color color) + { + Color = color; + return this; + } + + /// + /// Sets the of an . + /// + /// The author builder class containing the author field properties. + /// + /// The current builder. + /// + public EmbedBuilder WithAuthor(EmbedAuthorBuilder author) + { + Author = author; + return this; + } + /// + /// Sets the author field of an with the provided properties. + /// + /// The delegate containing the author field properties. + /// + /// The current builder. + /// + public EmbedBuilder WithAuthor(Action action) + { + var author = new EmbedAuthorBuilder(); + action(author); + Author = author; + return this; + } + /// + /// Sets the author field of an with the provided name, icon URL, and URL. + /// + /// The title of the author field. + /// The icon URL of the author field. + /// The URL of the author field. + /// + /// The current builder. + /// + public EmbedBuilder WithAuthor(string name, string iconUrl = null, string url = null) + { + var author = new EmbedAuthorBuilder + { + Name = name, + IconUrl = iconUrl, + Url = url + }; + Author = author; + return this; + } + /// + /// Sets the of an . + /// + /// The footer builder class containing the footer field properties. + /// + /// The current builder. + /// + public EmbedBuilder WithFooter(EmbedFooterBuilder footer) + { + Footer = footer; + return this; + } + /// + /// Sets the footer field of an with the provided properties. + /// + /// The delegate containing the footer field properties. + /// + /// The current builder. + /// + public EmbedBuilder WithFooter(Action action) + { + var footer = new EmbedFooterBuilder(); + action(footer); + Footer = footer; + return this; + } + /// + /// Sets the footer field of an with the provided name, icon URL. + /// + /// The title of the footer field. + /// The icon URL of the footer field. + /// + /// The current builder. + /// + public EmbedBuilder WithFooter(string text, string iconUrl = null) + { + var footer = new EmbedFooterBuilder + { + Text = text, + IconUrl = iconUrl + }; + Footer = footer; + return this; + } + + /// + /// Adds an field with the provided name and value. + /// + /// The title of the field. + /// The value of the field. + /// Indicates whether the field is in-line or not. + /// + /// The current builder. + /// + public EmbedBuilder AddField(string name, object value, bool inline = false) + { + var field = new EmbedFieldBuilder() + .WithIsInline(inline) + .WithName(name) + .WithValue(value); + AddField(field); + return this; + } + + /// + /// Adds a field with the provided to an + /// . + /// + /// The field builder class containing the field properties. + /// Field count exceeds . + /// + /// The current builder. + /// + public EmbedBuilder AddField(EmbedFieldBuilder field) + { + if (Fields.Count >= MaxFieldCount) + { + throw new ArgumentException(message: $"Field count must be less than or equal to {MaxFieldCount}.", paramName: nameof(field)); + } + + Fields.Add(field); + return this; + } + /// + /// Adds an field with the provided properties. + /// + /// The delegate containing the field properties. + /// + /// The current builder. + /// + public EmbedBuilder AddField(Action action) + { + var field = new EmbedFieldBuilder(); + action(field); + AddField(field); + return this; + } + + /// + /// Builds the into a Rich Embed ready to be sent. + /// + /// + /// The built embed object. + /// + /// Total embed length exceeds . + /// Any Url must include its protocols (i.e http:// or https://). + public Embed Build() + { + if (Length > MaxEmbedLength) + throw new InvalidOperationException($"Total embed length must be less than or equal to {MaxEmbedLength}."); + if (!string.IsNullOrEmpty(Url)) + UrlValidation.Validate(Url, true); + if (!string.IsNullOrEmpty(ThumbnailUrl)) + UrlValidation.Validate(ThumbnailUrl, true); + if (!string.IsNullOrEmpty(ImageUrl)) + UrlValidation.Validate(ImageUrl, true); + if (Author != null) + { + if (!string.IsNullOrEmpty(Author.Url)) + UrlValidation.Validate(Author.Url, true); + if (!string.IsNullOrEmpty(Author.IconUrl)) + UrlValidation.Validate(Author.IconUrl, true); + } + if (Footer != null) + { + if (!string.IsNullOrEmpty(Footer.IconUrl)) + UrlValidation.Validate(Footer.IconUrl, true); + } + var fields = ImmutableArray.CreateBuilder(Fields.Count); + for (int i = 0; i < Fields.Count; i++) + fields.Add(Fields[i].Build()); + + return new Embed(EmbedType.Rich, Title, Description, Url, Timestamp, Color, _image, null, Author?.Build(), Footer?.Build(), null, _thumbnail, fields.ToImmutable()); + } + + public static bool operator ==(EmbedBuilder left, EmbedBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedBuilder left, EmbedBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedBuilder embedBuilder && Equals(embedBuilder); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedBuilder embedBuilder) + { + if (embedBuilder is null) + return false; + + if (Fields.Count != embedBuilder.Fields.Count) + return false; + + for (var i = 0; i < _fields.Count; i++) + if (_fields[i] != embedBuilder._fields[i]) + return false; + + return _title == embedBuilder?._title + && _description == embedBuilder?._description + && _image == embedBuilder?._image + && _thumbnail == embedBuilder?._thumbnail + && Timestamp == embedBuilder?.Timestamp + && Color == embedBuilder?.Color + && Author == embedBuilder?.Author + && Footer == embedBuilder?.Footer + && Url == embedBuilder?.Url; + } + + /// + public override int GetHashCode() => base.GetHashCode(); + } + + /// + /// Represents a builder class for an embed field. + /// + public class EmbedFieldBuilder + { + private string _name; + private string _value; + /// + /// Gets the maximum field length for name allowed by Discord. + /// + public const int MaxFieldNameLength = 256; + /// + /// Gets the maximum field length for value allowed by Discord. + /// + public const int MaxFieldValueLength = 1024; + + /// + /// Gets or sets the field name. + /// + /// + /// Field name is , empty or entirely whitespace. + /// - or - + /// Field name length exceeds . + /// + /// + /// The name of the field. + /// + public string Name + { + get => _name; + set + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException(message: "Field name must not be null, empty or entirely whitespace.", paramName: nameof(Name)); + if (value.Length > MaxFieldNameLength) + throw new ArgumentException(message: $"Field name length must be less than or equal to {MaxFieldNameLength}.", paramName: nameof(Name)); + _name = value; + } + } + + /// + /// Gets or sets the field value. + /// + /// + /// Field value is , empty or entirely whitespace. + /// - or - + /// Field value length exceeds . + /// + /// + /// The value of the field. + /// + public object Value + { + get => _value; + set + { + var stringValue = value?.ToString(); + if (string.IsNullOrWhiteSpace(stringValue)) + throw new ArgumentException(message: "Field value must not be null or empty.", paramName: nameof(Value)); + if (stringValue.Length > MaxFieldValueLength) + throw new ArgumentException(message: $"Field value length must be less than or equal to {MaxFieldValueLength}.", paramName: nameof(Value)); + _value = stringValue; + } + } + /// + /// Gets or sets a value that indicates whether the field should be in-line with each other. + /// + public bool IsInline { get; set; } + + /// + /// Sets the field name. + /// + /// The name to set the field name to. + /// + /// The current builder. + /// + public EmbedFieldBuilder WithName(string name) + { + Name = name; + return this; + } + /// + /// Sets the field value. + /// + /// The value to set the field value to. + /// + /// The current builder. + /// + public EmbedFieldBuilder WithValue(object value) + { + Value = value; + return this; + } + /// + /// Determines whether the field should be in-line with each other. + /// + /// + /// The current builder. + /// + public EmbedFieldBuilder WithIsInline(bool isInline) + { + IsInline = isInline; + return this; + } + + /// + /// Builds the field builder into a class. + /// + /// + /// The current builder. + /// + /// + /// or is , empty or entirely whitespace. + /// - or - + /// or exceeds the maximum length allowed by Discord. + /// + public EmbedField Build() + => new EmbedField(Name, Value.ToString(), IsInline); + + public static bool operator ==(EmbedFieldBuilder left, EmbedFieldBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedFieldBuilder left, EmbedFieldBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedFieldBuilder embedFieldBuilder && Equals(embedFieldBuilder); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedFieldBuilder embedFieldBuilder) + => _name == embedFieldBuilder?._name + && _value == embedFieldBuilder?._value + && IsInline == embedFieldBuilder?.IsInline; + + /// + public override int GetHashCode() => base.GetHashCode(); + } + + /// + /// Represents a builder class for a author field. + /// + public class EmbedAuthorBuilder + { + private string _name; + /// + /// Gets the maximum author name length allowed by Discord. + /// + public const int MaxAuthorNameLength = 256; + + /// + /// Gets or sets the author name. + /// + /// + /// Author name length is longer than . + /// + /// + /// The author name. + /// + public string Name + { + get => _name; + set + { + if (value?.Length > MaxAuthorNameLength) + throw new ArgumentException(message: $"Author name length must be less than or equal to {MaxAuthorNameLength}.", paramName: nameof(Name)); + _name = value; + } + } + /// + /// Gets or sets the URL of the author field. + /// + /// Url is not a well-formed . + /// + /// The URL of the author field. + /// + public string Url { get; set; } + /// + /// Gets or sets the icon URL of the author field. + /// + /// Url is not a well-formed . + /// + /// The icon URL of the author field. + /// + public string IconUrl { get; set; } + + /// + /// Sets the name of the author field. + /// + /// The name of the author field. + /// + /// The current builder. + /// + public EmbedAuthorBuilder WithName(string name) + { + Name = name; + return this; + } + /// + /// Sets the URL of the author field. + /// + /// The URL of the author field. + /// + /// The current builder. + /// + public EmbedAuthorBuilder WithUrl(string url) + { + Url = url; + return this; + } + /// + /// Sets the icon URL of the author field. + /// + /// The icon URL of the author field. + /// + /// The current builder. + /// + public EmbedAuthorBuilder WithIconUrl(string iconUrl) + { + IconUrl = iconUrl; + return this; + } + + /// + /// Builds the author field to be used. + /// + /// + /// Author name length is longer than . + /// - or - + /// is not a well-formed . + /// - or - + /// is not a well-formed . + /// + /// + /// The built author field. + /// + public EmbedAuthor Build() + => new EmbedAuthor(Name, Url, IconUrl, null); + + public static bool operator ==(EmbedAuthorBuilder left, EmbedAuthorBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedAuthorBuilder left, EmbedAuthorBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedAuthorBuilder embedAuthorBuilder && Equals(embedAuthorBuilder); + + /// + /// Determines whether the specified is equals to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedAuthorBuilder embedAuthorBuilder) + => _name == embedAuthorBuilder?._name + && Url == embedAuthorBuilder?.Url + && IconUrl == embedAuthorBuilder?.IconUrl; + + /// + public override int GetHashCode() => base.GetHashCode(); + } + + /// + /// Represents a builder class for an embed footer. + /// + public class EmbedFooterBuilder + { + private string _text; + + /// + /// Gets the maximum footer length allowed by Discord. + /// + public const int MaxFooterTextLength = 2048; + + /// + /// Gets or sets the footer text. + /// + /// + /// Author name length is longer than . + /// + /// + /// The footer text. + /// + public string Text + { + get => _text; + set + { + if (value?.Length > MaxFooterTextLength) + throw new ArgumentException(message: $"Footer text length must be less than or equal to {MaxFooterTextLength}.", paramName: nameof(Text)); + _text = value; + } + } + /// + /// Gets or sets the icon URL of the footer field. + /// + /// Url is not a well-formed . + /// + /// The icon URL of the footer field. + /// + public string IconUrl { get; set; } + + /// + /// Sets the name of the footer field. + /// + /// The text of the footer field. + /// + /// The current builder. + /// + public EmbedFooterBuilder WithText(string text) + { + Text = text; + return this; + } + /// + /// Sets the icon URL of the footer field. + /// + /// The icon URL of the footer field. + /// + /// The current builder. + /// + public EmbedFooterBuilder WithIconUrl(string iconUrl) + { + IconUrl = iconUrl; + return this; + } + + /// + /// Builds the footer field to be used. + /// + /// + /// + /// length is longer than . + /// - or - + /// is not a well-formed . + /// + /// + /// A built footer field. + /// + public EmbedFooter Build() + => new EmbedFooter(Text, IconUrl, null); + + public static bool operator ==(EmbedFooterBuilder left, EmbedFooterBuilder right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedFooterBuilder left, EmbedFooterBuilder right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedFooterBuilder embedFooterBuilder && Equals(embedFooterBuilder); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedFooterBuilder embedFooterBuilder) + => _text == embedFooterBuilder?._text + && IconUrl == embedFooterBuilder?.IconUrl; + + /// + public override int GetHashCode() => base.GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedField.cs b/src/Discord.Net.Core/Entities/Messages/EmbedField.cs new file mode 100644 index 0000000..1196869 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedField.cs @@ -0,0 +1,71 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// + /// A field for an . + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct EmbedField + { + /// + /// Gets the name of the field. + /// + public string Name { get; internal set; } + /// + /// Gets the value of the field. + /// + public string Value { get; internal set; } + /// + /// Gets a value that indicates whether the field should be in-line with each other. + /// + public bool Inline { get; internal set; } + + internal EmbedField(string name, string value, bool inline) + { + Name = name; + Value = value; + Inline = inline; + } + + private string DebuggerDisplay => $"{Name} ({Value}"; + /// + /// Gets the name of the field. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Name; + + public static bool operator ==(EmbedField? left, EmbedField? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedField? left, EmbedField? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current object + /// + public override bool Equals(object obj) + => obj is EmbedField embedField && Equals(embedField); + + /// + /// Determines whether the specified is equal to the current + /// + /// + /// + public bool Equals(EmbedField? embedField) + => GetHashCode() == embedField?.GetHashCode(); + + /// + public override int GetHashCode() + => (Name, Value, Inline).GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs b/src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs new file mode 100644 index 0000000..5a1f131 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedFooter.cs @@ -0,0 +1,78 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// A footer field for an . + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct EmbedFooter + { + /// + /// Gets the text of the footer field. + /// + /// + /// A string containing the text of the footer field. + /// + public string Text { get; } + /// + /// Gets the URL of the footer icon. + /// + /// + /// A string containing the URL of the footer icon. + /// + public string IconUrl { get; } + /// + /// Gets the proxied URL of the footer icon link. + /// + /// + /// A string containing the proxied URL of the footer icon. + /// + public string ProxyUrl { get; } + + internal EmbedFooter(string text, string iconUrl, string proxyUrl) + { + Text = text; + IconUrl = iconUrl; + ProxyUrl = proxyUrl; + } + + private string DebuggerDisplay => $"{Text} ({IconUrl})"; + /// + /// Gets the text of the footer field. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Text; + + public static bool operator ==(EmbedFooter? left, EmbedFooter? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedFooter? left, EmbedFooter? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedFooter embedFooter && Equals(embedFooter); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedFooter? embedFooter) + => GetHashCode() == embedFooter?.GetHashCode(); + + /// + public override int GetHashCode() + => (Text, IconUrl, ProxyUrl).GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedImage.cs b/src/Discord.Net.Core/Entities/Messages/EmbedImage.cs new file mode 100644 index 0000000..74f5974 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedImage.cs @@ -0,0 +1,88 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// An image for an . + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct EmbedImage + { + /// + /// Gets the URL of the image. + /// + /// + /// A string containing the URL of the image. + /// + public string Url { get; } + /// + /// Gets a proxied URL of this image. + /// + /// + /// A string containing the proxied URL of this image. + /// + public string ProxyUrl { get; } + /// + /// Gets the height of this image. + /// + /// + /// A representing the height of this image if it can be retrieved; otherwise + /// . + /// + public int? Height { get; } + /// + /// Gets the width of this image. + /// + /// + /// A representing the width of this image if it can be retrieved; otherwise + /// . + /// + public int? Width { get; } + + internal EmbedImage(string url, string proxyUrl, int? height, int? width) + { + Url = url; + ProxyUrl = proxyUrl; + Height = height; + Width = width; + } + + private string DebuggerDisplay => $"{Url} ({(Width != null && Height != null ? $"{Width}x{Height}" : "0x0")})"; + /// + /// Gets the URL of the thumbnail. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Url; + + public static bool operator ==(EmbedImage? left, EmbedImage? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedImage? left, EmbedImage? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedImage embedImage && Equals(embedImage); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedImage? embedImage) + => GetHashCode() == embedImage?.GetHashCode(); + + /// + public override int GetHashCode() + => (Height, Width, Url, ProxyUrl).GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs b/src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs new file mode 100644 index 0000000..f2ee746 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedProvider.cs @@ -0,0 +1,70 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// A provider field for an . + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct EmbedProvider + { + /// + /// Gets the name of the provider. + /// + /// + /// A string representing the name of the provider. + /// + public string Name { get; } + /// + /// Gets the URL of the provider. + /// + /// + /// A string representing the link to the provider. + /// + public string Url { get; } + + internal EmbedProvider(string name, string url) + { + Name = name; + Url = url; + } + + private string DebuggerDisplay => $"{Name} ({Url})"; + /// + /// Gets the name of the provider. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Name; + + public static bool operator ==(EmbedProvider? left, EmbedProvider? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedProvider? left, EmbedProvider? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedProvider embedProvider && Equals(embedProvider); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedProvider? embedProvider) + => GetHashCode() == embedProvider?.GetHashCode(); + + /// + public override int GetHashCode() + => (Name, Url).GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs b/src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs new file mode 100644 index 0000000..dea0742 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedThumbnail.cs @@ -0,0 +1,88 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// A thumbnail featured in an . + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct EmbedThumbnail + { + /// + /// Gets the URL of the thumbnail. + /// + /// + /// A string containing the URL of the thumbnail. + /// + public string Url { get; } + /// + /// Gets a proxied URL of this thumbnail. + /// + /// + /// A string containing the proxied URL of this thumbnail. + /// + public string ProxyUrl { get; } + /// + /// Gets the height of this thumbnail. + /// + /// + /// A representing the height of this thumbnail if it can be retrieved; otherwise + /// . + /// + public int? Height { get; } + /// + /// Gets the width of this thumbnail. + /// + /// + /// A representing the width of this thumbnail if it can be retrieved; otherwise + /// . + /// + public int? Width { get; } + + internal EmbedThumbnail(string url, string proxyUrl, int? height, int? width) + { + Url = url; + ProxyUrl = proxyUrl; + Height = height; + Width = width; + } + + private string DebuggerDisplay => $"{Url} ({(Width != null && Height != null ? $"{Width}x{Height}" : "0x0")})"; + /// + /// Gets the URL of the thumbnail. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Url; + + public static bool operator ==(EmbedThumbnail? left, EmbedThumbnail? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedThumbnail? left, EmbedThumbnail? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedThumbnail embedThumbnail && Equals(embedThumbnail); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedThumbnail? embedThumbnail) + => GetHashCode() == embedThumbnail?.GetHashCode(); + + /// + public override int GetHashCode() + => (Width, Height, Url, ProxyUrl).GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedType.cs b/src/Discord.Net.Core/Entities/Messages/EmbedType.cs new file mode 100644 index 0000000..978f45b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedType.cs @@ -0,0 +1,45 @@ +namespace Discord +{ + /// + /// Specifies the type of embed. + /// + public enum EmbedType + { + /// + /// An unknown embed type. + /// + Unknown = -1, + /// + /// A rich embed type. + /// + Rich, + /// + /// A link embed type. + /// + Link, + /// + /// A video embed type. + /// + Video, + /// + /// An image embed type. + /// + Image, + /// + /// A GIFV embed type. + /// + Gifv, + /// + /// An article embed type. + /// + Article, + /// + /// A tweet embed type. + /// + Tweet, + /// + /// A HTML embed type. + /// + Html, + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs b/src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs new file mode 100644 index 0000000..a0c7249 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/EmbedVideo.cs @@ -0,0 +1,82 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + /// + /// A video featured in an . + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct EmbedVideo + { + /// + /// Gets the URL of the video. + /// + /// + /// A string containing the URL of the image. + /// + public string Url { get; } + /// + /// Gets the height of the video. + /// + /// + /// A representing the height of this video if it can be retrieved; otherwise + /// . + /// + public int? Height { get; } + /// + /// Gets the weight of the video. + /// + /// + /// A representing the width of this video if it can be retrieved; otherwise + /// . + /// + public int? Width { get; } + + internal EmbedVideo(string url, int? height, int? width) + { + Url = url; + Height = height; + Width = width; + } + + private string DebuggerDisplay => $"{Url} ({(Width != null && Height != null ? $"{Width}x{Height}" : "0x0")})"; + /// + /// Gets the URL of the video. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Url; + + public static bool operator ==(EmbedVideo? left, EmbedVideo? right) + => left is null ? right is null + : left.Equals(right); + + public static bool operator !=(EmbedVideo? left, EmbedVideo? right) + => !(left == right); + + /// + /// Determines whether the specified object is equal to the current . + /// + /// + /// If the object passes is an , will be called to compare the 2 instances + /// + /// The object to compare with the current + /// + public override bool Equals(object obj) + => obj is EmbedVideo embedVideo && Equals(embedVideo); + + /// + /// Determines whether the specified is equal to the current + /// + /// The to compare with the current + /// + public bool Equals(EmbedVideo? embedVideo) + => GetHashCode() == embedVideo?.GetHashCode(); + + /// + public override int GetHashCode() + => (Width, Height, Url).GetHashCode(); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/FileAttachment.cs b/src/Discord.Net.Core/Entities/Messages/FileAttachment.cs new file mode 100644 index 0000000..7d7f6a4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/FileAttachment.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; + +namespace Discord +{ + /// + /// Represents an outgoing file attachment used to send a file to discord. + /// + public struct FileAttachment : IDisposable + { + /// + /// Gets or sets the filename. + /// + public string FileName { get; set; } + /// + /// Gets or sets the description of the file. + /// + public string Description { get; set; } + + /// + /// Gets or sets whether this file should be marked as a spoiler. + /// + public bool IsSpoiler { get; set; } + + /// + /// Gets or sets if this file should be a thumbnail for a media channel post. + /// + public bool IsThumbnail { get; set; } + +#pragma warning disable IDISP008 + /// + /// Gets the stream containing the file content. + /// + public Stream Stream { get; } +#pragma warning restore IDISP008 + + private bool _isDisposed; + + /// + /// Creates a file attachment from a stream. + /// + /// The stream to create the attachment from. + /// The name of the attachment. + /// The description of the attachment. + /// Whether or not the attachment is a spoiler. + /// Whether or not this attachment should be a thumbnail for a media channel post. + public FileAttachment(Stream stream, string fileName, string description = null, bool isSpoiler = false, bool isThumbnail = false) + { + _isDisposed = false; + FileName = fileName; + Description = description; + Stream = stream; + IsThumbnail = isThumbnail; + try + { + Stream.Position = 0; + } + catch { } + IsSpoiler = isSpoiler; + } + + /// + /// Create the file attachment from a file path. + /// + /// + /// This file path is NOT validated and is passed directly into a + /// . + /// + /// The path to the file. + /// The name of the attachment. + /// The description of the attachment. + /// Whether or not the attachment is a spoiler. + /// Whether or not this attachment should be a thumbnail for a media channel post. + /// + /// is a zero-length string, contains only white space, or contains one or more invalid + /// characters as defined by . + /// + /// is . + /// + /// The specified path, file name, or both exceed the system-defined maximum length. For example, on + /// Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 + /// characters. + /// + /// is in an invalid format. + /// + /// The specified is invalid, (for example, it is on an unmapped drive). + /// + /// + /// specified a directory.-or- The caller does not have the required permission. + /// + /// The file specified in was not found. + /// + /// An I/O error occurred while opening the file. + public FileAttachment(string path, string fileName = null, string description = null, bool isSpoiler = false, bool isThumbnail = false) + { + _isDisposed = false; + Stream = File.OpenRead(path); + FileName = fileName ?? Path.GetFileName(path); + Description = description; + IsSpoiler = isSpoiler; + IsThumbnail = isThumbnail; + } + + public void Dispose() + { + if (!_isDisposed) + { + Stream?.Dispose(); + _isDisposed = true; + } + } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/IAttachment.cs b/src/Discord.Net.Core/Entities/Messages/IAttachment.cs new file mode 100644 index 0000000..591c958 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/IAttachment.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a message attachment found in a . + /// + public interface IAttachment : ISnowflakeEntity + { + /// + /// Gets the filename of this attachment. + /// + /// + /// A string containing the full filename of this attachment (e.g. textFile.txt). + /// + string Filename { get; } + /// + /// Gets the URL of this attachment. + /// + /// + /// A string containing the URL of this attachment. + /// + string Url { get; } + /// + /// Gets a proxied URL of this attachment. + /// + /// + /// A string containing the proxied URL of this attachment. + /// + string ProxyUrl { get; } + /// + /// Gets the file size of this attachment. + /// + /// + /// The size of this attachment in bytes. + /// + int Size { get; } + /// + /// Gets the height of this attachment. + /// + /// + /// The height of this attachment if it is a picture; otherwise . + /// + int? Height { get; } + /// + /// Gets the width of this attachment. + /// + /// + /// The width of this attachment if it is a picture; otherwise . + /// + int? Width { get; } + /// + /// Gets whether or not this attachment is ephemeral. + /// + /// + /// if the attachment is ephemeral; otherwise . + /// + bool Ephemeral { get; } + /// + /// Gets the description of the attachment; or if there is none set. + /// + string Description { get; } + /// + /// Gets the media's MIME type if present; otherwise . + /// + string ContentType { get; } + + /// + /// Gets the duration of the audio file. if the attachment is not a voice message. + /// + double? Duration { get; } + + /// + /// Gets the base64 encoded bytearray representing a sampled waveform. if the attachment is not a voice message. + /// + public string Waveform { get; } + + /// + /// Gets flags related to this to this attachment. + /// + public AttachmentFlags Flags { get; } + + /// + /// Gets users who participated in the clip. + /// + public IReadOnlyCollection ClipParticipants { get; } + + /// + /// Gets the title of the clip. if the clip has no title set. + /// + public string Title { get; } + + /// + /// Gets the timestamp of the clip. if the attachment is not a clip. + /// + public DateTimeOffset? ClipCreatedAt { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/IEmbed.cs b/src/Discord.Net.Core/Entities/Messages/IEmbed.cs new file mode 100644 index 0000000..1482de2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/IEmbed.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Immutable; + +namespace Discord +{ + /// + /// Represents a Discord embed object. + /// + public interface IEmbed + { + /// + /// Gets the title URL of this embed. + /// + /// + /// A string containing the URL set in a title of the embed. + /// + string Url { get; } + /// + /// Gets the title of this embed. + /// + /// + /// The title of the embed. + /// + string Title { get; } + /// + /// Gets the description of this embed. + /// + /// + /// The description field of the embed. + /// + string Description { get; } + /// + /// Gets the type of this embed. + /// + /// + /// The type of the embed. + /// + EmbedType Type { get; } + /// + /// Gets the timestamp of this embed. + /// + /// + /// A based on the timestamp present at the bottom left of the embed, or + /// if none is set. + /// + DateTimeOffset? Timestamp { get; } + /// + /// Gets the color of this embed. + /// + /// + /// The color of the embed present on the side of the embed, or if none is set. + /// + Color? Color { get; } + /// + /// Gets the image of this embed. + /// + /// + /// The image of the embed, or if none is set. + /// + EmbedImage? Image { get; } + /// + /// Gets the video of this embed. + /// + /// + /// The video of the embed, or if none is set. + /// + EmbedVideo? Video { get; } + /// + /// Gets the author field of this embed. + /// + /// + /// The author field of the embed, or if none is set. + /// + EmbedAuthor? Author { get; } + /// + /// Gets the footer field of this embed. + /// + /// + /// The author field of the embed, or if none is set. + /// + EmbedFooter? Footer { get; } + /// + /// Gets the provider of this embed. + /// + /// + /// The source of the embed, or if none is set. + /// + EmbedProvider? Provider { get; } + /// + /// Gets the thumbnail featured in this embed. + /// + /// + /// The thumbnail featured in the embed, or if none is set. + /// + EmbedThumbnail? Thumbnail { get; } + /// + /// Gets the fields of the embed. + /// + /// + /// An array of the fields of the embed. + /// + ImmutableArray Fields { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/IMessage.cs b/src/Discord.Net.Core/Entities/Messages/IMessage.cs new file mode 100644 index 0000000..7043fec --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/IMessage.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a message object. + /// + public interface IMessage : ISnowflakeEntity, IDeletable + { + /// + /// Gets the type of this message. + /// + MessageType Type { get; } + /// + /// Gets the source type of this message. + /// + MessageSource Source { get; } + /// + /// Gets the value that indicates whether this message was meant to be read-aloud by Discord. + /// + /// + /// if this message was sent as a text-to-speech message; otherwise . + /// + bool IsTTS { get; } + /// + /// Gets the value that indicates whether this message is pinned. + /// + /// + /// if this message was added to its channel's pinned messages; otherwise . + /// + bool IsPinned { get; } + /// + /// Gets the value that indicates whether or not this message's embeds are suppressed. + /// + /// + /// if the embeds in this message have been suppressed (made invisible); otherwise . + /// + bool IsSuppressed { get; } + /// + /// Gets the value that indicates whether this message mentioned everyone. + /// + /// + /// if this message mentioned everyone; otherwise . + /// + bool MentionedEveryone { get; } + /// + /// Gets the content for this message. + /// + /// + /// This will be empty if the privileged is disabled. + /// + /// + /// A string that contains the body of the message; note that this field may be empty if there is an embed. + /// + string Content { get; } + /// + /// Gets the clean content for this message. + /// + /// + /// This will be empty if the privileged is disabled. + /// + /// + /// A string that contains the body of the message stripped of mentions, markdown, emojis and pings; note that this field may be empty if there is an embed. + /// + string CleanContent { get; } + /// + /// Gets the time this message was sent. + /// + /// + /// Time of when the message was sent. + /// + DateTimeOffset Timestamp { get; } + /// + /// Gets the time of this message's last edit. + /// + /// + /// Time of when the message was last edited; if the message is never edited. + /// + DateTimeOffset? EditedTimestamp { get; } + + /// + /// Gets the source channel of the message. + /// + IMessageChannel Channel { get; } + /// + /// Gets the author of this message. + /// + IUser Author { get; } + + /// + /// Gets the thread that was started from this message. + /// + /// + /// An object if this message has thread attached; otherwise . + /// + IThreadChannel Thread { get; } + + /// + /// Gets all attachments included in this message. + /// + /// + /// This property gets a read-only collection of attachments associated with this message. Depending on the + /// user's end-client, a sent message may contain one or more attachments. For example, mobile users may + /// attach more than one file in their message, while the desktop client only allows for one. + /// + /// + /// A read-only collection of attachments. + /// + IReadOnlyCollection Attachments { get; } + /// + /// Gets all embeds included in this message. + /// + /// + /// This property gets a read-only collection of embeds associated with this message. Depending on the + /// message, a sent message may contain one or more embeds. This is usually true when multiple link previews + /// are generated; however, only one can be featured. + /// + /// + /// A read-only collection of embed objects. + /// + IReadOnlyCollection Embeds { get; } + /// + /// Gets all tags included in this message's content. + /// + IReadOnlyCollection Tags { get; } + /// + /// Gets the IDs of channels mentioned in this message. + /// + /// + /// A read-only collection of channel IDs. + /// + IReadOnlyCollection MentionedChannelIds { get; } + /// + /// Gets the IDs of roles mentioned in this message. + /// + /// + /// A read-only collection of role IDs. + /// + IReadOnlyCollection MentionedRoleIds { get; } + /// + /// Gets the IDs of users mentioned in this message. + /// + /// + /// A read-only collection of user IDs. + /// + IReadOnlyCollection MentionedUserIds { get; } + /// + /// Gets the activity associated with a message. + /// + /// + /// Sent with Rich Presence-related chat embeds. This often refers to activity that requires end-user's + /// interaction, such as a Spotify Invite activity. + /// + /// + /// A message's activity, if any is associated. + /// + MessageActivity Activity { get; } + /// + /// Gets the application associated with a message. + /// + /// + /// Sent with Rich-Presence-related chat embeds. + /// + /// + /// A message's application, if any is associated. + /// + MessageApplication Application { get; } + + /// + /// Gets the reference to the original message if it is a crosspost, channel follow add, pin, or reply message. + /// + /// + /// Sent with cross-posted messages, meaning they were published from news channels + /// and received by subscriber channels, channel follow adds, pins, and message replies. + /// + /// + /// A message's reference, if any is associated. + /// + MessageReference Reference { get; } + + /// + /// Gets all reactions included in this message. + /// + IReadOnlyDictionary Reactions { get; } + + /// + /// The 's attached to this message + /// + IReadOnlyCollection Components { get; } + + /// + /// Gets all stickers items included in this message. + /// + /// + /// A read-only collection of sticker item objects. + /// + IReadOnlyCollection Stickers { get; } + + /// + /// Gets the flags related to this message. + /// + /// + /// This value is determined by bitwise OR-ing values together. + /// + /// + /// A message's flags, if any is associated. + /// + MessageFlags? Flags { get; } + + /// + /// Gets the interaction this message is a response to. + /// + /// + /// A if the message is a response to an interaction; otherwise . + /// + [Obsolete("This property will be deprecated soon. Use IUserMessage.InteractionMetadata instead.")] + IMessageInteraction Interaction { get; } + + /// + /// Gets the data of the role subscription purchase or renewal that prompted this message. + /// + /// + /// A if the message is a role subscription purchase message; otherwise . + /// + MessageRoleSubscriptionData RoleSubscriptionData { get; } + + /// + /// Adds a reaction to this message. + /// + /// + /// The following example adds the reaction, 💕, to the message. + /// + /// await msg.AddReactionAsync(new Emoji("\U0001f495")); + /// + /// + /// The emoji used to react to this message. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for adding a reaction to this message. + /// + /// + Task AddReactionAsync(IEmote emote, RequestOptions options = null); + /// + /// Removes a reaction from message. + /// + /// + /// The following example removes the reaction, 💕, added by the message author from the message. + /// + /// await msg.RemoveReactionAsync(new Emoji("\U0001f495"), msg.Author); + /// + /// + /// The emoji used to react to this message. + /// The user that added the emoji. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for removing a reaction to this message. + /// + /// + Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions options = null); + /// + /// Removes a reaction from message. + /// + /// + /// The following example removes the reaction, 💕, added by the user with ID 84291986575613952 from the message. + /// + /// await msg.RemoveReactionAsync(new Emoji("\U0001f495"), 84291986575613952); + /// + /// + /// The emoji used to react to this message. + /// The ID of the user that added the emoji. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for removing a reaction to this message. + /// + /// + Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions options = null); + /// + /// Removes all reactions from this message. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task RemoveAllReactionsAsync(RequestOptions options = null); + /// + /// Removes all reactions with a specific emoji from this message. + /// + /// The emoji used to react to this message. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null); + + /// + /// Gets all users that reacted to a message with a given emote. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the users as a + /// collection. + /// + /// + /// Do not fetch too many users at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// This method will attempt to fetch the number of reactions specified under . + /// The library will attempt to split up the requests according to your and + /// . In other words, should the user request 500 reactions, + /// and the constant is 100, the request will + /// be split into 5 individual requests; thus returning 5 individual asynchronous responses, hence the need + /// of flattening. + /// + /// + /// The following example gets the users that have reacted with the emoji 💕 to the message. + /// + /// var emoji = new Emoji("\U0001f495"); + /// var reactedUsers = await message.GetReactionUsersAsync(emoji, 100).FlattenAsync(); + /// + /// + /// The emoji that represents the reaction that you wish to get. + /// The number of users to request. + /// The options to be used when sending the request. + /// The type of the reaction you wish to get users for. + /// + /// Paged collection of users. + /// + IAsyncEnumerable> GetReactionUsersAsync(IEmote emoji, int limit, RequestOptions options = null, + ReactionType type = ReactionType.Normal); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/IMessageInteraction.cs b/src/Discord.Net.Core/Entities/Messages/IMessageInteraction.cs new file mode 100644 index 0000000..ebd03b6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/IMessageInteraction.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a partial within a message. + /// + public interface IMessageInteraction + { + /// + /// Gets the snowflake id of the interaction. + /// + ulong Id { get; } + + /// + /// Gets the type of the interaction. + /// + InteractionType Type { get; } + + /// + /// Gets the name of the application command used. + /// + string Name { get; } + + /// + /// Gets the who invoked the interaction. + /// + IUser User { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/IReaction.cs b/src/Discord.Net.Core/Entities/Messages/IReaction.cs new file mode 100644 index 0000000..de59164 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/IReaction.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// Represents a generic reaction object. +/// +public interface IReaction +{ + /// + /// The used in the reaction. + /// + IEmote Emote { get; } + + /// + /// Gets colors used for the super reaction. + /// + /// + /// The collection will be empty if the reaction is a normal reaction. + /// + public IReadOnlyCollection BurstColors { get; } +} diff --git a/src/Discord.Net.Core/Entities/Messages/ISystemMessage.cs b/src/Discord.Net.Core/Entities/Messages/ISystemMessage.cs new file mode 100644 index 0000000..89cd17a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/ISystemMessage.cs @@ -0,0 +1,9 @@ +namespace Discord +{ + /// + /// Represents a generic message sent by the system. + /// + public interface ISystemMessage : IMessage + { + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/ITag.cs b/src/Discord.Net.Core/Entities/Messages/ITag.cs new file mode 100644 index 0000000..5d36169 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/ITag.cs @@ -0,0 +1,11 @@ +namespace Discord +{ + public interface ITag + { + int Index { get; } + int Length { get; } + TagType Type { get; } + ulong Key { get; } + object Value { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/IUserMessage.cs b/src/Discord.Net.Core/Entities/Messages/IUserMessage.cs new file mode 100644 index 0000000..757c3e4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/IUserMessage.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic message sent by a user. + /// + public interface IUserMessage : IMessage + { + /// + /// Gets the resolved data if the message has components. otherwise. + /// + MessageResolvedData ResolvedData { get; } + + /// + /// Gets the referenced message if it is a crosspost, channel follow add, pin, or reply message. + /// + /// + /// The referenced message, if any is associated and still exists. + /// + IUserMessage ReferencedMessage { get; } + + /// + /// Gets the interaction metadata for the interaction this message is a response to. + /// + /// + /// Will be if the message is not a response to an interaction. + /// + IMessageInteractionMetadata InteractionMetadata { get; } + + /// + /// Modifies this message. + /// + /// + /// This method modifies this message with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// + /// The following example replaces the content of the message with Hello World!. + /// + /// await msg.ModifyAsync(x => x.Content = "Hello World!"); + /// + /// + /// A delegate containing the properties to modify the message with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + /// + /// Adds this message to its channel's pinned messages. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for pinning this message. + /// + Task PinAsync(RequestOptions options = null); + /// + /// Removes this message from its channel's pinned messages. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for unpinning this message. + /// + Task UnpinAsync(RequestOptions options = null); + + /// + /// Publishes (crossposts) this message. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for publishing this message. + /// + /// + /// + /// This call will throw an if attempted in a non-news channel. + /// + /// This method will publish (crosspost) the message. Please note, publishing (crossposting), is only available in news channels. + /// + Task CrosspostAsync(RequestOptions options = null); + + /// + /// Transforms this message's text into a human-readable form by resolving its tags. + /// + /// Determines how the user tag should be handled. + /// Determines how the channel tag should be handled. + /// Determines how the role tag should be handled. + /// Determines how the @everyone tag should be handled. + /// Determines how the emoji tag should be handled. + string Resolve( + TagHandling userHandling = TagHandling.Name, + TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, + TagHandling everyoneHandling = TagHandling.Ignore, + TagHandling emojiHandling = TagHandling.Name); + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageActivity.cs b/src/Discord.Net.Core/Entities/Messages/MessageActivity.cs new file mode 100644 index 0000000..ff4ae40 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageActivity.cs @@ -0,0 +1,32 @@ +using System.Diagnostics; + +namespace Discord +{ + /// + /// An activity object found in a sent message. + /// + /// + /// + /// This class refers to an activity object, visually similar to an embed within a message. However, a message + /// activity is interactive as opposed to a standard static embed. + /// + /// For example, a Spotify party invitation counts as a message activity. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class MessageActivity + { + /// + /// Gets the type of activity of this message. + /// + public MessageActivityType Type { get; internal set; } + /// + /// Gets the party ID of this activity, if any. + /// + public string PartyId { get; internal set; } + + private string DebuggerDisplay + => $"{Type}{(string.IsNullOrWhiteSpace(PartyId) ? "" : $" {PartyId}")}"; + + public override string ToString() => DebuggerDisplay; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageActivityType.cs b/src/Discord.Net.Core/Entities/Messages/MessageActivityType.cs new file mode 100644 index 0000000..68b99a9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageActivityType.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public enum MessageActivityType + { + Join = 1, + Spectate = 2, + Listen = 3, + JoinRequest = 5 + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageApplication.cs b/src/Discord.Net.Core/Entities/Messages/MessageApplication.cs new file mode 100644 index 0000000..39a599d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageApplication.cs @@ -0,0 +1,38 @@ +using System.Diagnostics; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class MessageApplication + { + /// + /// Gets the snowflake ID of the application. + /// + public ulong Id { get; internal set; } + /// + /// Gets the ID of the embed's image asset. + /// + public string CoverImage { get; internal set; } + /// + /// Gets the application's description. + /// + public string Description { get; internal set; } + /// + /// Gets the ID of the application's icon. + /// + public string Icon { get; internal set; } + /// + /// Gets the Url of the application's icon. + /// + public string IconUrl + => $"https://cdn.discordapp.com/app-icons/{Id}/{Icon}"; + /// + /// Gets the name of the application. + /// + public string Name { get; internal set; } + private string DebuggerDisplay + => $"{Name} ({Id}): {Description}"; + public override string ToString() + => DebuggerDisplay; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageFlags.cs b/src/Discord.Net.Core/Entities/Messages/MessageFlags.cs new file mode 100644 index 0000000..10f1aeb --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageFlags.cs @@ -0,0 +1,61 @@ +using System; + +namespace Discord +{ + [Flags] + public enum MessageFlags + { + /// + /// Default value for flags, when none are given to a message. + /// + None = 0, + /// + /// Flag given to messages that have been published to subscribed + /// channels (via Channel Following). + /// + Crossposted = 1 << 0, + /// + /// Flag given to messages that originated from a message in another + /// channel (via Channel Following). + /// + IsCrosspost = 1 << 1, + /// + /// Flag given to messages that do not display any embeds. + /// + SuppressEmbeds = 1 << 2, + /// + /// Flag given to messages that the source message for this crosspost + /// has been deleted (via Channel Following). + /// + SourceMessageDeleted = 1 << 3, + /// + /// Flag given to messages that came from the urgent message system. + /// + Urgent = 1 << 4, + /// + /// Flag given to messages has an associated thread, with the same id as the message + /// + HasThread = 1 << 5, + /// + /// Flag given to messages that is only visible to the user who invoked the Interaction. + /// + Ephemeral = 1 << 6, + /// + /// Flag given to messages that is an Interaction Response and the bot is "thinking" + /// + Loading = 1 << 7, + /// + /// Flag given to messages that failed to mention some roles and add their members to the thread. + /// + FailedToMentionRolesInThread = 1 << 8, + /// + /// Flag give to messages that will not trigger push and desktop notifications. + /// + SuppressNotification = 1 << 12, + + /// + /// This message is a voice message. + /// + VoiceMessage = 1 << 13, + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageInteraction.cs b/src/Discord.Net.Core/Entities/Messages/MessageInteraction.cs new file mode 100644 index 0000000..cbbebd9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageInteraction.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a partial within a message. + /// + /// The type of the user. + public class MessageInteraction : IMessageInteraction where TUser : IUser + { + /// + /// Gets the snowflake id of the interaction. + /// + public ulong Id { get; } + + /// + /// Gets the type of the interaction. + /// + public InteractionType Type { get; } + + /// + /// Gets the name of the application command used. + /// + public string Name { get; } + + /// + /// Gets the who invoked the interaction. + /// + public TUser User { get; } + + internal MessageInteraction(ulong id, InteractionType type, string name, TUser user) + { + Id = id; + Type = type; + Name = name; + User = user; + } + + IUser IMessageInteraction.User => User; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageProperties.cs b/src/Discord.Net.Core/Entities/Messages/MessageProperties.cs new file mode 100644 index 0000000..1a4eaff --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageProperties.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Properties that are used to modify an with the specified changes. + /// + /// + /// The content of a message can be cleared with if and only if an + /// is present. + /// + /// + public class MessageProperties + { + /// + /// Gets or sets the content of the message. + /// + /// + /// This must be less than the constant defined by . + /// + public Optional Content { get; set; } + + /// + /// Gets or sets a single embed for this message. + /// + /// + /// This property will be added to the array, in the future please use the array rather than this property. + /// + public Optional Embed { get; set; } + + /// + /// Gets or sets the embeds of the message. + /// + public Optional Embeds { get; set; } + + /// + /// Gets or sets the components for this message. + /// + public Optional Components { get; set; } + + /// + /// Gets or sets the flags of the message. + /// + /// + /// Only can be set/unset and you need to be + /// the author of the message. + /// + public Optional Flags { get; set; } + /// + /// Gets or sets the allowed mentions of the message. + /// + public Optional AllowedMentions { get; set; } + + /// + /// Gets or sets the attachments for the message. + /// + public Optional> Attachments { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageReference.cs b/src/Discord.Net.Core/Entities/Messages/MessageReference.cs new file mode 100644 index 0000000..7fdc448 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageReference.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; + +namespace Discord +{ + /// + /// Contains the IDs sent from a crossposted message or inline reply. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class MessageReference + { + /// + /// Gets the Message ID of the original message. + /// + public Optional MessageId { get; internal set; } + + /// + /// Gets the Channel ID of the original message. + /// + /// + /// It only will be the default value (zero) if it was instantiated with a in the constructor. + /// + public ulong ChannelId { get => InternalChannelId.GetValueOrDefault(); } + internal Optional InternalChannelId; + + /// + /// Gets the Guild ID of the original message. + /// + public Optional GuildId { get; internal set; } + + /// + /// Gets whether to error if the referenced message doesn't exist instead of sending as a normal (non-reply) message + /// Defaults to true. + /// + public Optional FailIfNotExists { get; internal set; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The ID of the message that will be referenced. Used to reply to specific messages and the only parameter required for it. + /// + /// + /// The ID of the channel that will be referenced. It will be validated if sent. + /// + /// + /// The ID of the guild that will be referenced. It will be validated if sent. + /// + /// + /// Whether to error if the referenced message doesn't exist instead of sending as a normal (non-reply) message. Defaults to true. + /// + public MessageReference(ulong? messageId = null, ulong? channelId = null, ulong? guildId = null, bool? failIfNotExists = null) + { + MessageId = messageId ?? Optional.Create(); + InternalChannelId = channelId ?? Optional.Create(); + GuildId = guildId ?? Optional.Create(); + FailIfNotExists = failIfNotExists ?? Optional.Create(); + } + + private string DebuggerDisplay + => $"Channel ID: ({ChannelId}){(GuildId.IsSpecified ? $", Guild ID: ({GuildId.Value})" : "")}" + + $"{(MessageId.IsSpecified ? $", Message ID: ({MessageId.Value})" : "")}" + + $"{(FailIfNotExists.IsSpecified ? $", FailIfNotExists: ({FailIfNotExists.Value})" : "")}"; + + public override string ToString() + => DebuggerDisplay; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageResolvedData.cs b/src/Discord.Net.Core/Entities/Messages/MessageResolvedData.cs new file mode 100644 index 0000000..2f6216a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageResolvedData.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Discord; + +public class MessageResolvedData +{ + /// + /// Gets a collection of resolved in the message. + /// + public IReadOnlyCollection Users { get; } + + /// + /// Gets a collection of resolved in the message. + /// + public IReadOnlyCollection Members { get; } + + /// + /// Gets a collection of resolved in the message. + /// + public IReadOnlyCollection Roles { get; } + + /// + /// Gets a collection of resolved in the message. + /// + public IReadOnlyCollection Channels { get; } + + internal MessageResolvedData(IReadOnlyCollection users, IReadOnlyCollection members, IReadOnlyCollection roles, IReadOnlyCollection channels) + { + Users = users; + Members = members; + Roles = roles; + Channels = channels; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageRoleSubscriptionData.cs b/src/Discord.Net.Core/Entities/Messages/MessageRoleSubscriptionData.cs new file mode 100644 index 0000000..2c1a334 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageRoleSubscriptionData.cs @@ -0,0 +1,35 @@ +namespace Discord; + +/// +/// Represents a role subscription data in . +/// +public class MessageRoleSubscriptionData +{ + /// + /// Gets the id of the sku and listing that the user is subscribed to. + /// + public ulong Id { get; } + + /// + /// Gets the name of the tier that the user is subscribed to. + /// + public string TierName { get; } + + /// + /// Gets the cumulative number of months that the user has been subscribed for. + /// + public int MonthsSubscribed { get; } + + /// + /// Gets whether this notification is for a renewal rather than a new purchase. + /// + public bool IsRenewal { get; } + + internal MessageRoleSubscriptionData(ulong id, string tierName, int monthsSubscribed, bool isRenewal) + { + Id = id; + TierName = tierName; + MonthsSubscribed = monthsSubscribed; + IsRenewal = isRenewal; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageSource.cs b/src/Discord.Net.Core/Entities/Messages/MessageSource.cs new file mode 100644 index 0000000..bd4f237 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageSource.cs @@ -0,0 +1,25 @@ +namespace Discord +{ + /// + /// Specifies the source of the Discord message. + /// + public enum MessageSource + { + /// + /// The message is sent by the system. + /// + System, + /// + /// The message is sent by a user. + /// + User, + /// + /// The message is sent by a bot. + /// + Bot, + /// + /// The message is sent by a webhook. + /// + Webhook + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/MessageType.cs b/src/Discord.Net.Core/Entities/Messages/MessageType.cs new file mode 100644 index 0000000..78dc570 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/MessageType.cs @@ -0,0 +1,171 @@ +namespace Discord +{ + /// + /// Specifies the type of message. + /// + public enum MessageType + { + /// + /// The default message type. + /// + Default = 0, + /// + /// The message when a recipient is added. + /// + RecipientAdd = 1, + /// + /// The message when a recipient is removed. + /// + RecipientRemove = 2, + /// + /// The message when a user is called. + /// + Call = 3, + /// + /// The message when a channel name is changed. + /// + ChannelNameChange = 4, + /// + /// The message when a channel icon is changed. + /// + ChannelIconChange = 5, + /// + /// The message when another message is pinned. + /// + ChannelPinnedMessage = 6, + /// + /// The message when a new member joined. + /// + GuildMemberJoin = 7, + /// + /// The message for when a user boosts a guild. + /// + UserPremiumGuildSubscription = 8, + /// + /// The message for when a guild reaches Tier 1 of Nitro boosts. + /// + UserPremiumGuildSubscriptionTier1 = 9, + /// + /// The message for when a guild reaches Tier 2 of Nitro boosts. + /// + UserPremiumGuildSubscriptionTier2 = 10, + /// + /// The message for when a guild reaches Tier 3 of Nitro boosts. + /// + UserPremiumGuildSubscriptionTier3 = 11, + /// + /// The message for when a news channel subscription is added to a text channel. + /// + ChannelFollowAdd = 12, + /// + /// The message for when a guild is disqualified from discovery. + /// + GuildDiscoveryDisqualified = 14, + /// + /// The message for when a guild is requalified for discovery. + /// + GuildDiscoveryRequalified = 15, + /// + /// The message for when the initial warning is sent for the initial grace period discovery. + /// + GuildDiscoveryGracePeriodInitialWarning = 16, + /// + /// The message for when the final warning is sent for the initial grace period discovery. + /// + GuildDiscoveryGracePeriodFinalWarning = 17, + /// + /// The message for when a thread is created. + /// + ThreadCreated = 18, + /// + /// The message is an inline reply. + /// + /// + /// Only available in API v8. + /// + Reply = 19, + /// + /// The message is an Application Command. + /// + /// + /// Only available in API v8. + /// + ApplicationCommand = 20, + /// + /// The message that starts a thread. + /// + /// + /// Only available in API v9. + /// + ThreadStarterMessage = 21, + /// + /// The message for an invite reminder. + /// + GuildInviteReminder = 22, + /// + /// The message for a context menu command. + /// + ContextMenuCommand = 23, + /// + /// The message for an automod action. + /// + AutoModerationAction = 24, + /// + /// The message for a role subscription purchase. + /// + RoleSubscriptionPurchase = 25, + /// + /// The message for an interaction premium upsell. + /// + InteractionPremiumUpsell = 26, + /// + /// The message for a stage start. + /// + StageStart = 27, + /// + /// The message for a stage end. + /// + StageEnd = 28, + /// + /// The message for a stage speaker. + /// + StageSpeaker = 29, + /// + /// The message for a stage raise hand. + /// + StageRaiseHand = 30, + /// + /// The message for a stage raise hand. + /// + StageTopic = 31, + /// + /// The message for a guild application premium subscription. + /// + GuildApplicationPremiumSubscription = 32, + + /// + /// The message for incident alert mode enabled. + /// + IncidentAlertModeEnabled = 36, + + /// + /// The message for incident alert mode disabled. + /// + IncidentAlertModeDisabled = 37, + + /// + /// The message for incident report raid. + /// + IncidentReportRaid = 38, + + /// + /// The message for incident report false alarm. + /// + IncidentReportFalseAlarm = 39, + + /// + /// The message is a purchase notification. + /// + PurchaseNotification = 44 + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/ReactionMetadata.cs b/src/Discord.Net.Core/Entities/Messages/ReactionMetadata.cs new file mode 100644 index 0000000..1fcea9f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/ReactionMetadata.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Discord; + +/// +/// A metadata containing reaction information. +/// +public struct ReactionMetadata +{ + /// + /// Gets the number of reactions. + /// + /// + /// An representing the number of this reactions that has been added to this message. + /// + public int ReactionCount { get; internal set; } + + /// + /// Gets a value that indicates whether the current user has reacted to this. + /// + /// + /// if the user has reacted to the message; otherwise . + /// + public bool IsMe { get; internal set; } + + /// + /// Gets the number of burst reactions added to this message. + /// + public int BurstCount { get; internal set; } + + /// + /// Gets the number of normal reactions added to this message. + /// + public int NormalCount { get; internal set; } + + /// + /// Gets colors used for super reaction. + /// + public IReadOnlyCollection BurstColors { get; internal set; } +} diff --git a/src/Discord.Net.Core/Entities/Messages/ReactionType.cs b/src/Discord.Net.Core/Entities/Messages/ReactionType.cs new file mode 100644 index 0000000..d69e15c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/ReactionType.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum ReactionType +{ + /// + /// The reaction is a normal reaction. + /// + Normal = 0, + + /// + /// The reaction is a super reaction. + /// + Burst = 1, +} diff --git a/src/Discord.Net.Core/Entities/Messages/StickerFormatType.cs b/src/Discord.Net.Core/Entities/Messages/StickerFormatType.cs new file mode 100644 index 0000000..82e6b15 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/StickerFormatType.cs @@ -0,0 +1,25 @@ +namespace Discord +{ + /// + /// Defines the types of formats for stickers. + /// + public enum StickerFormatType + { + /// + /// Default value for a sticker format type. + /// + None = 0, + /// + /// The sticker format type is png. + /// + Png = 1, + /// + /// The sticker format type is apng. + /// + Apng = 2, + /// + /// The sticker format type is lottie. + /// + Lottie = 3 + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/Tag.cs b/src/Discord.Net.Core/Entities/Messages/Tag.cs new file mode 100644 index 0000000..9053480 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/Tag.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class Tag : ITag + { + public TagType Type { get; } + public int Index { get; } + public int Length { get; } + public ulong Key { get; } + public T Value { get; } + + internal Tag(TagType type, int index, int length, ulong key, T value) + { + Type = type; + Index = index; + Length = length; + Key = key; + Value = value; + } + + private string DebuggerDisplay => $"{Value?.ToString() ?? "null"} ({Type})"; + public override string ToString() => $"{Value?.ToString() ?? "null"} ({Type})"; + + object ITag.Value => Value; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/TagHandling.cs b/src/Discord.Net.Core/Entities/Messages/TagHandling.cs new file mode 100644 index 0000000..eaadd64 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/TagHandling.cs @@ -0,0 +1,39 @@ +namespace Discord +{ + /// + /// Specifies the handling type the tag should use. + /// + /// + /// + public enum TagHandling + { + /// + /// Tag handling is ignored (e.g. <@53905483156684800> -> <@53905483156684800>). + /// + Ignore = 0, + /// + /// Removes the tag entirely. + /// + Remove, + /// + /// Resolves to username (e.g. <@53905483156684800> -> @Voltana). + /// + Name, + /// + /// Resolves to username without mention prefix (e.g. <@53905483156684800> -> Voltana). + /// + NameNoPrefix, + /// + /// Resolves to username with discriminator value. (e.g. <@53905483156684800> -> @Voltana#8252). + /// + FullName, + /// + /// Resolves to username with discriminator value without mention prefix. (e.g. <@53905483156684800> -> Voltana#8252). + /// + FullNameNoPrefix, + /// + /// Sanitizes the tag (e.g. <@53905483156684800> -> <@53905483156684800> (w/ nbsp)). + /// + Sanitize + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/TagType.cs b/src/Discord.Net.Core/Entities/Messages/TagType.cs new file mode 100644 index 0000000..1771572 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/TagType.cs @@ -0,0 +1,19 @@ +namespace Discord +{ + /// Specifies the type of Discord tag. + public enum TagType + { + /// The object is an user mention. + UserMention, + /// The object is a channel mention. + ChannelMention, + /// The object is a role mention. + RoleMention, + /// The object is an everyone mention. + EveryoneMention, + /// The object is a here mention. + HereMention, + /// The object is an emoji. + Emoji + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/TimestampTag.cs b/src/Discord.Net.Core/Entities/Messages/TimestampTag.cs new file mode 100644 index 0000000..1ca6dc4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/TimestampTag.cs @@ -0,0 +1,91 @@ +using System; + +namespace Discord +{ + /// + /// Represents a class used to make timestamps in messages. see . + /// + public readonly struct TimestampTag + { + /// + /// Gets the time for this timestamp tag. + /// + public DateTimeOffset Time { get; } + + /// + /// Gets the style of this tag. if none was provided. + /// + public TimestampTagStyles? Style { get; } + + /// + /// Creates a new from the provided time. + /// + /// The time for this timestamp tag. + /// The style for this timestamp tag. + public TimestampTag(DateTimeOffset time, TimestampTagStyles? style = null) + { + Time = time; + Style = style; + } + + /// + /// Converts the current timestamp tag to the string representation supported by discord. + /// + /// If the is null then the default 0 will be used. + /// + /// + /// + /// Will use the provided if provided. If this value is null, it will default to . + /// + /// A string that is compatible in a discord message, ex: <t:1625944201:f> + public override string ToString() + => ToString(Style ?? TimestampTagStyles.ShortDateTime); + + /// + /// Converts the current timestamp tag to the string representation supported by discord. + /// + /// If the is null then the default 0 will be used. + /// + /// + /// The formatting style for this tag. + /// A string that is compatible in a discord message, ex: <t:1625944201:f> + public string ToString(TimestampTagStyles style) + => $""; + + /// + /// Creates a new timestamp tag with the specified object. + /// + /// The time of this timestamp tag. + /// The style for this timestamp tag. + /// The newly create timestamp tag. + public static TimestampTag FromDateTime(DateTime time, TimestampTagStyles? style = null) + => new(time, style); + + /// + /// Creates a new timestamp tag with the specified object. + /// + /// The time of this timestamp tag. + /// The style for this timestamp tag. + /// The newly create timestamp tag. + public static TimestampTag FromDateTimeOffset(DateTimeOffset time, TimestampTagStyles? style = null) + => new(time, style); + + /// + /// Immediately formats the provided time and style into a timestamp string. + /// + /// The time of this timestamp tag. + /// The style for this timestamp tag. + /// The newly create timestamp string. + public static string FormatFromDateTime(DateTime time, TimestampTagStyles style) + => FormatFromDateTimeOffset(time, style); + + /// + /// Immediately formats the provided time and style into a timestamp string. + /// + /// The time of this timestamp tag. + /// The style for this timestamp tag. + /// The newly create timestamp string. + public static string FormatFromDateTimeOffset(DateTimeOffset time, TimestampTagStyles style) + => $""; + } +} diff --git a/src/Discord.Net.Core/Entities/Messages/TimestampTagStyle.cs b/src/Discord.Net.Core/Entities/Messages/TimestampTagStyle.cs new file mode 100644 index 0000000..89f3c79 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Messages/TimestampTagStyle.cs @@ -0,0 +1,43 @@ +namespace Discord +{ + /// + /// Represents a set of styles to use with a + /// + public enum TimestampTagStyles + { + /// + /// A short time string: 16:20 + /// + ShortTime = 116, + + /// + /// A long time string: 16:20:30 + /// + LongTime = 84, + + /// + /// A short date string: 20/04/2021 + /// + ShortDate = 100, + + /// + /// A long date string: 20 April 2021 + /// + LongDate = 68, + + /// + /// A short datetime string: 20 April 2021 16:20 + /// + ShortDateTime = 102, + + /// + /// A long datetime string: Tuesday, 20 April 2021 16:20 + /// + LongDateTime = 70, + + /// + /// The relative time to the user: 2 months ago + /// + Relative = 82 + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/ApplicationCommandPermissionTarget.cs b/src/Discord.Net.Core/Entities/Permissions/ApplicationCommandPermissionTarget.cs new file mode 100644 index 0000000..aa81ac6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/ApplicationCommandPermissionTarget.cs @@ -0,0 +1,21 @@ +namespace Discord +{ + /// + /// Specifies the target of the permission. + /// + public enum ApplicationCommandPermissionTarget + { + /// + /// The target of the permission is a role. + /// + Role = 1, + /// + /// The target of the permission is a user. + /// + User = 2, + /// + /// The target of the permission is a channel. + /// + Channel = 3 + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/ApplicationCommandPermissions.cs b/src/Discord.Net.Core/Entities/Permissions/ApplicationCommandPermissions.cs new file mode 100644 index 0000000..3c25fe3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/ApplicationCommandPermissions.cs @@ -0,0 +1,118 @@ +namespace Discord +{ + /// + /// Application command permissions allow you to enable or disable commands for specific users or roles within a guild. + /// + public class ApplicationCommandPermission + { + /// + /// The id of the role or user. + /// + public ulong TargetId { get; } + + /// + /// The target of this permission. + /// + public ApplicationCommandPermissionTarget TargetType { get; } + + /// + /// to allow, otherwise . + /// + public bool Permission { get; } + + internal ApplicationCommandPermission() { } + + /// + /// Creates a new . + /// + /// The id you want to target this permission value for. + /// The type of the targetId parameter. + /// The value of this permission. + public ApplicationCommandPermission(ulong targetId, ApplicationCommandPermissionTarget targetType, bool allow) + { + TargetId = targetId; + TargetType = targetType; + Permission = allow; + } + + /// + /// Creates a new targeting . + /// + /// The user you want to target this permission value for. + /// The value of this permission. + public ApplicationCommandPermission(IUser target, bool allow) + { + TargetId = target.Id; + Permission = allow; + TargetType = ApplicationCommandPermissionTarget.User; + } + + /// + /// Creates a new targeting . + /// + /// The role you want to target this permission value for. + /// The value of this permission. + public ApplicationCommandPermission(IRole target, bool allow) + { + TargetId = target.Id; + Permission = allow; + TargetType = ApplicationCommandPermissionTarget.Role; + } + + /// + /// Creates a new targeting . + /// + /// The channel you want to target this permission value for. + /// The value of this permission. + public ApplicationCommandPermission(IChannel channel, bool allow) + { + TargetId = channel.Id; + Permission = allow; + TargetType = ApplicationCommandPermissionTarget.Channel; + } + + /// + /// Creates a new targeting @everyone in a guild. + /// + /// Id of the target guild. + /// The value of this permission. + /// + /// Instance of targeting @everyone in a guild. + /// + public static ApplicationCommandPermission ForEveryone(ulong guildId, bool allow) => + new(guildId, ApplicationCommandPermissionTarget.User, allow); + + /// + /// Creates a new targeting @everyone in a guild. + /// + /// Target guild. + /// The value of this permission. + /// + /// Instance of targeting @everyone in a guild. + /// + public static ApplicationCommandPermission ForEveryone(IGuild guild, bool allow) => + ForEveryone(guild.Id, allow); + + /// + /// Creates a new targeting every channel in a guild. + /// + /// Id of the target guild. + /// The value of this permission. + /// + /// Instance of targeting every channel in a guild. + /// + public static ApplicationCommandPermission ForAllChannels(ulong guildId, bool allow) => + new(guildId - 1, ApplicationCommandPermissionTarget.Channel, allow); + + /// + /// Creates a new targeting every channel in a guild. + /// + /// Target guild. + /// The value of this permission. + /// + /// Instance of targeting every channel in a guild. + /// + public static ApplicationCommandPermission ForAllChannels(IGuild guild, bool allow) => + ForAllChannels(guild.Id, allow); + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs b/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs new file mode 100644 index 0000000..72b4917 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs @@ -0,0 +1,196 @@ +using System; + +namespace Discord +{ + /// Defines the available permissions for a channel. + [Flags] + public enum ChannelPermission : ulong + { + // General + /// + /// Allows creation of instant invites. + /// + CreateInstantInvite = 1L << 0, + + /// + /// Allows management and editing of channels. + /// + ManageChannels = 1L << 4, + + // Text + /// + /// Allows for the addition of reactions to messages. + /// + AddReactions = 1L << 6, + + /// + /// Allows guild members to view a channel, which includes reading messages in text channels. + /// + ViewChannel = 1L << 10, + + /// + /// Allows for sending messages in a channel. + /// + SendMessages = 1L << 11, + + /// + /// Allows for sending of text-to-speech messages. + /// + SendTTSMessages = 1L << 12, + + /// + /// Allows for deletion of other users messages. + /// + ManageMessages = 1L << 13, + + /// + /// Allows links sent by users with this permission will be auto-embedded. + /// + EmbedLinks = 1L << 14, + + /// + /// Allows for uploading images and files. + /// + AttachFiles = 1L << 15, + + /// + /// Allows for reading of message history. + /// + ReadMessageHistory = 1L << 16, + + /// + /// Allows for using the @everyone tag to notify all users in a channel, and the @here tag to notify all + /// online users in a channel. + /// + MentionEveryone = 1L << 17, + + /// + /// Allows the usage of custom emojis from other servers. + /// + UseExternalEmojis = 1L << 18, + + + // Voice + + /// + /// Allows for joining of a voice channel. + /// + Connect = 1L << 20, + + /// + /// Allows for speaking in a voice channel. + /// + Speak = 1L << 21, + + /// + /// Allows for muting members in a voice channel. + /// + MuteMembers = 1L << 22, + + /// + /// Allows for deafening of members in a voice channel. + /// + DeafenMembers = 1L << 23, + + /// + /// Allows for moving of members between voice channels. + /// + MoveMembers = 1L << 24, + + /// + /// Allows for using voice-activity-detection in a voice channel. + /// + UseVAD = 1L << 25, + + /// + /// Allows for using priority speaker in a voice channel. + /// + PrioritySpeaker = 1L << 8, + + /// + /// Allows video streaming in a voice channel. + /// + Stream = 1L << 9, + + // More General + /// + /// Allows management and editing of roles. + /// + ManageRoles = 1L << 28, + + /// + /// Allows management and editing of webhooks. + /// + ManageWebhooks = 1L << 29, + + /// + /// Allows management and editing of emojis. + /// + ManageEmojis = 1L << 30, + + /// + /// Allows members to use slash commands in text channels. + /// + UseApplicationCommands = 1L << 31, + + /// + /// Allows for requesting to speak in stage channels. (This permission is under active development and may be changed or removed.) + /// + RequestToSpeak = 1L << 32, + + /// + /// Allows for deleting and archiving threads, and viewing all private threads + /// + ManageThreads = 1L << 34, + + /// + /// Allows for creating public threads. + /// + CreatePublicThreads = 1L << 35, + + /// + /// Allows for creating private threads. + /// + CreatePrivateThreads = 1L << 36, + + /// + /// Allows the usage of custom stickers from other servers. + /// + UseExternalStickers = 1L << 37, + + /// + /// Allows for sending messages in threads. + /// + SendMessagesInThreads = 1L << 38, + + /// + /// Allows for launching activities (applications with the EMBEDDED flag) in a voice channel. + /// + StartEmbeddedActivities = 1L << 39, + + /// + /// Allows for using the soundboard in a voice channel. + /// + UseSoundboard = 1L << 42, + + /// + /// Allows members to edit and cancel events in this channel. + /// + CreateEvents = 1L << 44, + + /// + /// Allows sending voice messages. + /// + SendVoiceMessages = 1L << 46, + + /// + /// Allows members to interact with the Clyde AI bot. + /// + UseClydeAI = 1L << 47, + + /// + /// Allows setting voice channel status. + /// + SetVoiceChannelStatus = 1L << 48, + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/ChannelPermissions.cs b/src/Discord.Net.Core/Entities/Permissions/ChannelPermissions.cs new file mode 100644 index 0000000..4b527e3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/ChannelPermissions.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public struct ChannelPermissions + { + /// + /// Gets a blank that grants no permissions. + /// + /// + /// A structure that does not contain any set permissions. + /// + public static readonly ChannelPermissions None = new(); + + /// + /// Gets a that grants all permissions for text channels. + /// + public static readonly ChannelPermissions Text = new(0b110001_001111_110010_110011_111101_111111_111101_010001); + + /// + /// Gets a that grants all permissions for voice channels. + /// + public static readonly ChannelPermissions Voice = new(0b1_110001_001010_001010_110011_111101_111111_111101_010001); + + /// + /// Gets a that grants all permissions for stage channels. + /// + public static readonly ChannelPermissions Stage = new(0b110000_000010_001110_010001_010101_111111_111001_010001); + + /// + /// Gets a that grants all permissions for category channels. + /// + public static readonly ChannelPermissions Category = new(0b011001_001111_111110_110011_111101_111111_111101_010001); + + /// + /// Gets a that grants all permissions for direct message channels. + /// + public static readonly ChannelPermissions DM = new(0b00000_1000110_1011100110001_000000); + + /// + /// Gets a that grants all permissions for group channels. + /// + public static readonly ChannelPermissions Group = new(0b00000_1000110_0001101100000_000000); + + /// + /// Gets a that grants all permissions for forum channels. + /// + public static readonly ChannelPermissions Forum = new(0b000001_001110_010010_110011_111101_111111_111101_010001); + + /// + /// Gets a that grants all permissions for media channels. + /// + public static readonly ChannelPermissions Media = new(0b01_001110_010010_110011_111101_111111_111101_010001); + + /// + /// Gets a that grants all permissions for a given channel type. + /// + /// Unknown channel type. + public static ChannelPermissions All(IChannel channel) + { + return channel switch + { + IStageChannel _ => Stage, + IVoiceChannel _ => Voice, + ITextChannel _ => Text, + ICategoryChannel _ => Category, + IDMChannel _ => DM, + IGroupChannel _ => Group, + IMediaChannel _ => Media, + IForumChannel => Forum, + _ => throw new ArgumentException(message: "Unknown channel type.", paramName: nameof(channel)), + }; + } + + /// Gets a packed value representing all the permissions in this . + public ulong RawValue { get; } + + /// If , a user may create invites. + public bool CreateInstantInvite => Permissions.GetValue(RawValue, ChannelPermission.CreateInstantInvite); + /// If , a user may create, delete and modify this channel. + public bool ManageChannel => Permissions.GetValue(RawValue, ChannelPermission.ManageChannels); + + /// If , a user may add reactions. + public bool AddReactions => Permissions.GetValue(RawValue, ChannelPermission.AddReactions); + /// If , a user may view channels. + public bool ViewChannel => Permissions.GetValue(RawValue, ChannelPermission.ViewChannel); + + /// If , a user may send messages. + public bool SendMessages => Permissions.GetValue(RawValue, ChannelPermission.SendMessages); + /// If , a user may send text-to-speech messages. + public bool SendTTSMessages => Permissions.GetValue(RawValue, ChannelPermission.SendTTSMessages); + /// If , a user may delete messages. + public bool ManageMessages => Permissions.GetValue(RawValue, ChannelPermission.ManageMessages); + /// If , Discord will auto-embed links sent by this user. + public bool EmbedLinks => Permissions.GetValue(RawValue, ChannelPermission.EmbedLinks); + /// If , a user may send files. + public bool AttachFiles => Permissions.GetValue(RawValue, ChannelPermission.AttachFiles); + /// If , a user may read previous messages. + public bool ReadMessageHistory => Permissions.GetValue(RawValue, ChannelPermission.ReadMessageHistory); + /// If , a user may mention @everyone. + public bool MentionEveryone => Permissions.GetValue(RawValue, ChannelPermission.MentionEveryone); + /// If , a user may use custom emoji from other guilds. + public bool UseExternalEmojis => Permissions.GetValue(RawValue, ChannelPermission.UseExternalEmojis); + + /// If , a user may connect to a voice channel. + public bool Connect => Permissions.GetValue(RawValue, ChannelPermission.Connect); + /// If , a user may speak in a voice channel. + public bool Speak => Permissions.GetValue(RawValue, ChannelPermission.Speak); + /// If , a user may mute users. + public bool MuteMembers => Permissions.GetValue(RawValue, ChannelPermission.MuteMembers); + /// If , a user may deafen users. + public bool DeafenMembers => Permissions.GetValue(RawValue, ChannelPermission.DeafenMembers); + /// If , a user may move other users between voice channels. + public bool MoveMembers => Permissions.GetValue(RawValue, ChannelPermission.MoveMembers); + /// If , a user may use voice-activity-detection rather than push-to-talk. + public bool UseVAD => Permissions.GetValue(RawValue, ChannelPermission.UseVAD); + /// If , a user may use priority speaker in a voice channel. + public bool PrioritySpeaker => Permissions.GetValue(RawValue, ChannelPermission.PrioritySpeaker); + /// If , a user may stream video in a voice channel. + public bool Stream => Permissions.GetValue(RawValue, ChannelPermission.Stream); + + /// If , a user may adjust role permissions. This also implicitly grants all other permissions. + public bool ManageRoles => Permissions.GetValue(RawValue, ChannelPermission.ManageRoles); + /// If , a user may edit the webhooks for this channel. + public bool ManageWebhooks => Permissions.GetValue(RawValue, ChannelPermission.ManageWebhooks); + /// If , a user may use application commands in this guild. + public bool UseApplicationCommands => Permissions.GetValue(RawValue, ChannelPermission.UseApplicationCommands); + /// If , a user may request to speak in stage channels. + public bool RequestToSpeak => Permissions.GetValue(RawValue, ChannelPermission.RequestToSpeak); + /// If , a user may manage threads in this guild. + public bool ManageThreads => Permissions.GetValue(RawValue, ChannelPermission.ManageThreads); + /// If , a user may create public threads in this guild. + public bool CreatePublicThreads => Permissions.GetValue(RawValue, ChannelPermission.CreatePublicThreads); + /// If , a user may create private threads in this guild. + public bool CreatePrivateThreads => Permissions.GetValue(RawValue, ChannelPermission.CreatePrivateThreads); + /// If , a user may use external stickers in this guild. + public bool UseExternalStickers => Permissions.GetValue(RawValue, ChannelPermission.UseExternalStickers); + /// If , a user may send messages in threads in this guild. + public bool SendMessagesInThreads => Permissions.GetValue(RawValue, ChannelPermission.SendMessagesInThreads); + /// If , a user launch application activities in voice channels in this guild. + public bool StartEmbeddedActivities => Permissions.GetValue(RawValue, ChannelPermission.StartEmbeddedActivities); + /// If , a user can use soundboard in a voice channel. + public bool UseSoundboard => Permissions.GetValue(RawValue, ChannelPermission.UseSoundboard); + /// If , a user can edit and cancel events in this channel. + public bool CreateEvents => Permissions.GetValue(RawValue, ChannelPermission.CreateEvents); + /// If , a user can send voice messages in this channel. + public bool SendVoiceMessages => Permissions.GetValue(RawValue, ChannelPermission.SendVoiceMessages); + /// If , a user can use the Clyde AI bot in this channel. + public bool UseClydeAI => Permissions.GetValue(RawValue, ChannelPermission.UseClydeAI); + /// If , a user can set the status of a voice channel. + public bool SetVoiceChannelStatus => Permissions.GetValue(RawValue, GuildPermission.SetVoiceChannelStatus); + + /// Creates a new with the provided packed value. + public ChannelPermissions(ulong rawValue) { RawValue = rawValue; } + + private ChannelPermissions(ulong initialValue, + bool? createInstantInvite = null, + bool? manageChannel = null, + bool? addReactions = null, + bool? viewChannel = null, + bool? sendMessages = null, + bool? sendTTSMessages = null, + bool? manageMessages = null, + bool? embedLinks = null, + bool? attachFiles = null, + bool? readMessageHistory = null, + bool? mentionEveryone = null, + bool? useExternalEmojis = null, + bool? connect = null, + bool? speak = null, + bool? muteMembers = null, + bool? deafenMembers = null, + bool? moveMembers = null, + bool? useVoiceActivation = null, + bool? prioritySpeaker = null, + bool? stream = null, + bool? manageRoles = null, + bool? manageWebhooks = null, + bool? useApplicationCommands = null, + bool? requestToSpeak = null, + bool? manageThreads = null, + bool? createPublicThreads = null, + bool? createPrivateThreads = null, + bool? useExternalStickers = null, + bool? sendMessagesInThreads = null, + bool? startEmbeddedActivities = null, + bool? useSoundboard = null, + bool? createEvents = null, + bool? sendVoiceMessages = null, + bool? useClydeAI = null, + bool? setVoiceChannelStatus = null) + { + ulong value = initialValue; + + Permissions.SetValue(ref value, createInstantInvite, ChannelPermission.CreateInstantInvite); + Permissions.SetValue(ref value, manageChannel, ChannelPermission.ManageChannels); + Permissions.SetValue(ref value, addReactions, ChannelPermission.AddReactions); + Permissions.SetValue(ref value, viewChannel, ChannelPermission.ViewChannel); + Permissions.SetValue(ref value, sendMessages, ChannelPermission.SendMessages); + Permissions.SetValue(ref value, sendTTSMessages, ChannelPermission.SendTTSMessages); + Permissions.SetValue(ref value, manageMessages, ChannelPermission.ManageMessages); + Permissions.SetValue(ref value, embedLinks, ChannelPermission.EmbedLinks); + Permissions.SetValue(ref value, attachFiles, ChannelPermission.AttachFiles); + Permissions.SetValue(ref value, readMessageHistory, ChannelPermission.ReadMessageHistory); + Permissions.SetValue(ref value, mentionEveryone, ChannelPermission.MentionEveryone); + Permissions.SetValue(ref value, useExternalEmojis, ChannelPermission.UseExternalEmojis); + Permissions.SetValue(ref value, connect, ChannelPermission.Connect); + Permissions.SetValue(ref value, speak, ChannelPermission.Speak); + Permissions.SetValue(ref value, muteMembers, ChannelPermission.MuteMembers); + Permissions.SetValue(ref value, deafenMembers, ChannelPermission.DeafenMembers); + Permissions.SetValue(ref value, moveMembers, ChannelPermission.MoveMembers); + Permissions.SetValue(ref value, useVoiceActivation, ChannelPermission.UseVAD); + Permissions.SetValue(ref value, prioritySpeaker, ChannelPermission.PrioritySpeaker); + Permissions.SetValue(ref value, stream, ChannelPermission.Stream); + Permissions.SetValue(ref value, manageRoles, ChannelPermission.ManageRoles); + Permissions.SetValue(ref value, manageWebhooks, ChannelPermission.ManageWebhooks); + Permissions.SetValue(ref value, useApplicationCommands, ChannelPermission.UseApplicationCommands); + Permissions.SetValue(ref value, requestToSpeak, ChannelPermission.RequestToSpeak); + Permissions.SetValue(ref value, manageThreads, ChannelPermission.ManageThreads); + Permissions.SetValue(ref value, createPublicThreads, ChannelPermission.CreatePublicThreads); + Permissions.SetValue(ref value, createPrivateThreads, ChannelPermission.CreatePrivateThreads); + Permissions.SetValue(ref value, useExternalStickers, ChannelPermission.UseExternalStickers); + Permissions.SetValue(ref value, sendMessagesInThreads, ChannelPermission.SendMessagesInThreads); + Permissions.SetValue(ref value, startEmbeddedActivities, ChannelPermission.StartEmbeddedActivities); + Permissions.SetValue(ref value, useSoundboard, ChannelPermission.UseSoundboard); + Permissions.SetValue(ref value, createEvents, ChannelPermission.CreateEvents); + Permissions.SetValue(ref value, sendVoiceMessages, ChannelPermission.SendVoiceMessages); + Permissions.SetValue(ref value, useClydeAI, ChannelPermission.UseClydeAI); + Permissions.SetValue(ref value, setVoiceChannelStatus, ChannelPermission.SetVoiceChannelStatus); + + RawValue = value; + } + + /// Creates a new with the provided permissions. + public ChannelPermissions( + bool createInstantInvite = false, + bool manageChannel = false, + bool addReactions = false, + bool viewChannel = false, + bool sendMessages = false, + bool sendTTSMessages = false, + bool manageMessages = false, + bool embedLinks = false, + bool attachFiles = false, + bool readMessageHistory = false, + bool mentionEveryone = false, + bool useExternalEmojis = false, + bool connect = false, + bool speak = false, + bool muteMembers = false, + bool deafenMembers = false, + bool moveMembers = false, + bool useVoiceActivation = false, + bool prioritySpeaker = false, + bool stream = false, + bool manageRoles = false, + bool manageWebhooks = false, + bool useApplicationCommands = false, + bool requestToSpeak = false, + bool manageThreads = false, + bool createPublicThreads = false, + bool createPrivateThreads = false, + bool useExternalStickers = false, + bool sendMessagesInThreads = false, + bool startEmbeddedActivities = false, + bool useSoundboard = false, + bool createEvents = false, + bool sendVoiceMessages = false, + bool useClydeAI = false, + bool setVoiceChannelStatus = false) + : this(0, createInstantInvite, manageChannel, addReactions, viewChannel, sendMessages, sendTTSMessages, manageMessages, + embedLinks, attachFiles, readMessageHistory, mentionEveryone, useExternalEmojis, connect, + speak, muteMembers, deafenMembers, moveMembers, useVoiceActivation, prioritySpeaker, stream, manageRoles, manageWebhooks, + useApplicationCommands, requestToSpeak, manageThreads, createPublicThreads, createPrivateThreads, useExternalStickers, sendMessagesInThreads, + startEmbeddedActivities, useSoundboard, createEvents, sendVoiceMessages, useClydeAI, setVoiceChannelStatus) + { } + + /// Creates a new from this one, changing the provided non-null permissions. + public ChannelPermissions Modify( + bool? createInstantInvite = null, + bool? manageChannel = null, + bool? addReactions = null, + bool? viewChannel = null, + bool? sendMessages = null, + bool? sendTTSMessages = null, + bool? manageMessages = null, + bool? embedLinks = null, + bool? attachFiles = null, + bool? readMessageHistory = null, + bool? mentionEveryone = null, + bool? useExternalEmojis = null, + bool? connect = null, + bool? speak = null, + bool? muteMembers = null, + bool? deafenMembers = null, + bool? moveMembers = null, + bool? useVoiceActivation = null, + bool? prioritySpeaker = null, + bool? stream = null, + bool? manageRoles = null, + bool? manageWebhooks = null, + bool? useApplicationCommands = null, + bool? requestToSpeak = null, + bool? manageThreads = null, + bool? createPublicThreads = null, + bool? createPrivateThreads = null, + bool? useExternalStickers = null, + bool? sendMessagesInThreads = null, + bool? startEmbeddedActivities = null, + bool? useSoundboard = null, + bool? createEvents = null, + bool? sendVoiceMessages = null, + bool? useClydeAI = null, + bool? setVoiceChannelStatus = null) + => new ChannelPermissions(RawValue, + createInstantInvite, + manageChannel, + addReactions, + viewChannel, + sendMessages, + sendTTSMessages, + manageMessages, + embedLinks, + attachFiles, + readMessageHistory, + mentionEveryone, + useExternalEmojis, + connect, + speak, + muteMembers, + deafenMembers, + moveMembers, + useVoiceActivation, + prioritySpeaker, + stream, + manageRoles, + manageWebhooks, + useApplicationCommands, + requestToSpeak, + manageThreads, + createPublicThreads, + createPrivateThreads, + useExternalStickers, + sendMessagesInThreads, + startEmbeddedActivities, + useSoundboard, + createEvents, + sendVoiceMessages, + useClydeAI, + setVoiceChannelStatus); + + public bool Has(ChannelPermission permission) => Permissions.GetValue(RawValue, permission); + + public List ToList() + { + var perms = new List(); + for (byte i = 0; i < Permissions.MaxBits; i++) + { + ulong flag = ((ulong)1 << i); + if ((RawValue & flag) != 0) + perms.Add((ChannelPermission)flag); + } + return perms; + } + + public override string ToString() => RawValue.ToString(); + private string DebuggerDisplay => $"{string.Join(", ", ToList())}"; + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/GuildApplicationCommandPermissions.cs b/src/Discord.Net.Core/Entities/Permissions/GuildApplicationCommandPermissions.cs new file mode 100644 index 0000000..e738fec --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/GuildApplicationCommandPermissions.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Returned when fetching the permissions for a command in a guild. + /// + public class GuildApplicationCommandPermission + + { + /// + /// The id of the command. + /// + public ulong CommandId { get; } + + /// + /// The id of the application the command belongs to. + /// + public ulong ApplicationId { get; } + + /// + /// The id of the guild. + /// + public ulong GuildId { get; } + + /// + /// The permissions for the command in the guild. + /// + public IReadOnlyCollection Permissions { get; } + + internal GuildApplicationCommandPermission(ulong commandId, ulong appId, ulong guildId, ApplicationCommandPermission[] permissions) + { + CommandId = commandId; + ApplicationId = appId; + GuildId = guildId; + Permissions = permissions; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs b/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs new file mode 100644 index 0000000..978161d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs @@ -0,0 +1,286 @@ +using System; + +namespace Discord +{ + /// Defines the available permissions for a channel. + [Flags] + public enum GuildPermission : ulong + { + // General + /// + /// Allows creation of instant invites. + /// + CreateInstantInvite = 1L << 0, + + /// + /// Allows kicking members. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + KickMembers = 1L << 1, + + /// + /// Allows banning members. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + BanMembers = 1L << 2, + + /// + /// Allows all permissions and bypasses channel permission overwrites. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + Administrator = 1L << 3, + + /// + /// Allows management and editing of channels. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + ManageChannels = 1L << 4, + + /// + /// Allows management and editing of the guild. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + ManageGuild = 1L << 5, + + /// + /// Allows for viewing of guild insights + /// + ViewGuildInsights = 1L << 19, + + // Text + /// + /// Allows for the addition of reactions to messages. + /// + AddReactions = 1L << 6, + + /// + /// Allows for viewing of audit logs. + /// + ViewAuditLog = 1L << 7, + + /// + /// Allows guild members to view a channel, which includes reading messages in text channels. + /// + ViewChannel = 1L << 10, + + /// + /// Allows for sending messages in a channel + /// + SendMessages = 1L << 11, + + /// + /// Allows for sending of text-to-speech messages. + /// + SendTTSMessages = 1L << 12, + + /// + /// Allows for deletion of other users messages. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + ManageMessages = 1L << 13, + /// + /// Allows links sent by users with this permission will be auto-embedded. + /// + EmbedLinks = 1L << 14, + + /// + /// Allows for uploading images and files. + /// + AttachFiles = 1L << 15, + + /// + /// Allows for reading of message history. + /// + ReadMessageHistory = 1 << 16, + + /// + /// Allows for using the @everyone tag to notify all users in a channel, and the @here tag to notify all + /// online users in a channel. + /// + MentionEveryone = 1L << 17, + + /// + /// Allows the usage of custom emojis from other servers. + /// + UseExternalEmojis = 1L << 18, + + /// + /// Allows for joining of a voice channel. + /// + Connect = 1L << 20, + + /// + /// Allows for speaking in a voice channel. + /// + Speak = 1L << 21, + + /// + /// Allows for muting members in a voice channel. + /// + MuteMembers = 1L << 22, + + /// + /// Allows for deafening of members in a voice channel. + /// + DeafenMembers = 1L << 23, + + /// + /// Allows for moving of members between voice channels. + /// + MoveMembers = 1L << 24, + + /// + /// Allows for using voice-activity-detection in a voice channel. + /// + UseVAD = 1L << 25, + + /// + /// Allows for using priority speaker in a voice channel. + /// + PrioritySpeaker = 1L << 8, + + /// + /// Allows video streaming in a voice channel. + /// + Stream = 1L << 9, + + // General 2 + /// + /// Allows for modification of own nickname. + /// + ChangeNickname = 1L << 26, + + /// + /// Allows for modification of other users nicknames. + /// + ManageNicknames = 1L << 27, + /// + /// Allows management and editing of roles. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + ManageRoles = 1L << 28, + + /// + /// Allows management and editing of webhooks. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + ManageWebhooks = 1L << 29, + + /// + /// Allows management and editing of emojis and stickers. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + ManageEmojisAndStickers = 1L << 30, + + /// + /// Allows members to use application commands like slash commands and context menus in text channels. + /// + UseApplicationCommands = 1L << 31, + + /// + /// Allows for requesting to speak in stage channels. + /// + RequestToSpeak = 1L << 32, + + /// + /// Allows for creating, editing, and deleting guild scheduled events. + /// + ManageEvents = 1L << 33, + + /// + /// Allows for deleting and archiving threads, and viewing all private threads. + /// + /// + /// This permission requires the owner account to use two-factor + /// authentication when used on a guild that has server-wide 2FA enabled. + /// + ManageThreads = 1L << 34, + + /// + /// Allows for creating public threads. + /// + CreatePublicThreads = 1L << 35, + + /// + /// Allows for creating private threads. + /// + CreatePrivateThreads = 1L << 36, + + /// + /// Allows the usage of custom stickers from other servers. + /// + UseExternalStickers = 1L << 37, + + /// + /// Allows for sending messages in threads. + /// + SendMessagesInThreads = 1L << 38, + + /// + /// Allows for launching activities (applications with the EMBEDDED flag) in a voice channel. + /// + StartEmbeddedActivities = 1L << 39, + + /// + /// Allows for timing out users. + /// + ModerateMembers = 1L << 40, + + /// + /// Allows for viewing role subscription insights. + /// + ViewMonetizationAnalytics = 1L << 41, + + /// + /// Allows for using the soundboard. + /// + UseSoundboard = 1L << 42, + + /// + /// Allows for creating emojis, stickers, and soundboard sounds, and editing and deleting those created by the current user. + /// + CreateGuildExpressions = 1L << 43, + + /// + /// Allows sending voice messages. + /// + SendVoiceMessages = 1L << 46, + + /// + /// Allows members to interact with the Clyde AI bot. + /// + UseClydeAI = 1L << 47, + + /// + /// Allows setting voice channel status. + /// + SetVoiceChannelStatus = 1L << 48, + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/GuildPermissions.cs b/src/Discord.Net.Core/Entities/Permissions/GuildPermissions.cs new file mode 100644 index 0000000..90e41dd --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/GuildPermissions.cs @@ -0,0 +1,426 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Discord +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct GuildPermissions + { + /// Gets a blank that grants no permissions. + public static readonly GuildPermissions None = new GuildPermissions(); + /// Gets a that grants all guild permissions for webhook users. + public static readonly GuildPermissions Webhook = new GuildPermissions(0b0_00000_0000000_0000000_0001101100000_000000); + /// Gets a that grants all guild permissions. + public static readonly GuildPermissions All = new GuildPermissions(ulong.MaxValue); + + /// Gets a packed value representing all the permissions in this . + public ulong RawValue { get; } + + /// If , a user may create invites. + public bool CreateInstantInvite => Permissions.GetValue(RawValue, GuildPermission.CreateInstantInvite); + /// If , a user may ban users from the guild. + public bool BanMembers => Permissions.GetValue(RawValue, GuildPermission.BanMembers); + /// If , a user may kick users from the guild. + public bool KickMembers => Permissions.GetValue(RawValue, GuildPermission.KickMembers); + /// If , a user is granted all permissions, and cannot have them revoked via channel permissions. + public bool Administrator => Permissions.GetValue(RawValue, GuildPermission.Administrator); + /// If , a user may create, delete and modify channels. + public bool ManageChannels => Permissions.GetValue(RawValue, GuildPermission.ManageChannels); + /// If , a user may adjust guild properties. + public bool ManageGuild => Permissions.GetValue(RawValue, GuildPermission.ManageGuild); + + /// If , a user may add reactions. + public bool AddReactions => Permissions.GetValue(RawValue, GuildPermission.AddReactions); + /// If , a user may view the audit log. + public bool ViewAuditLog => Permissions.GetValue(RawValue, GuildPermission.ViewAuditLog); + /// If , a user may view the guild insights. + public bool ViewGuildInsights => Permissions.GetValue(RawValue, GuildPermission.ViewGuildInsights); + + /// If True, a user may view channels. + public bool ViewChannel => Permissions.GetValue(RawValue, GuildPermission.ViewChannel); + /// If True, a user may send messages. + public bool SendMessages => Permissions.GetValue(RawValue, GuildPermission.SendMessages); + /// If , a user may send text-to-speech messages. + public bool SendTTSMessages => Permissions.GetValue(RawValue, GuildPermission.SendTTSMessages); + /// If , a user may delete messages. + public bool ManageMessages => Permissions.GetValue(RawValue, GuildPermission.ManageMessages); + /// If , Discord will auto-embed links sent by this user. + public bool EmbedLinks => Permissions.GetValue(RawValue, GuildPermission.EmbedLinks); + /// If , a user may send files. + public bool AttachFiles => Permissions.GetValue(RawValue, GuildPermission.AttachFiles); + /// If , a user may read previous messages. + public bool ReadMessageHistory => Permissions.GetValue(RawValue, GuildPermission.ReadMessageHistory); + /// If , a user may mention @everyone. + public bool MentionEveryone => Permissions.GetValue(RawValue, GuildPermission.MentionEveryone); + /// If , a user may use custom emoji from other guilds. + public bool UseExternalEmojis => Permissions.GetValue(RawValue, GuildPermission.UseExternalEmojis); + + /// If , a user may connect to a voice channel. + public bool Connect => Permissions.GetValue(RawValue, GuildPermission.Connect); + /// If , a user may speak in a voice channel. + public bool Speak => Permissions.GetValue(RawValue, GuildPermission.Speak); + /// If , a user may mute users. + public bool MuteMembers => Permissions.GetValue(RawValue, GuildPermission.MuteMembers); + /// If , a user may deafen users. + public bool DeafenMembers => Permissions.GetValue(RawValue, GuildPermission.DeafenMembers); + /// If , a user may move other users between voice channels. + public bool MoveMembers => Permissions.GetValue(RawValue, GuildPermission.MoveMembers); + /// If , a user may use voice-activity-detection rather than push-to-talk. + public bool UseVAD => Permissions.GetValue(RawValue, GuildPermission.UseVAD); + /// If True, a user may use priority speaker in a voice channel. + public bool PrioritySpeaker => Permissions.GetValue(RawValue, GuildPermission.PrioritySpeaker); + /// If True, a user may stream video in a voice channel. + public bool Stream => Permissions.GetValue(RawValue, GuildPermission.Stream); + + /// If , a user may change their own nickname. + public bool ChangeNickname => Permissions.GetValue(RawValue, GuildPermission.ChangeNickname); + /// If , a user may change the nickname of other users. + public bool ManageNicknames => Permissions.GetValue(RawValue, GuildPermission.ManageNicknames); + /// If , a user may adjust roles. + public bool ManageRoles => Permissions.GetValue(RawValue, GuildPermission.ManageRoles); + /// If , a user may edit the webhooks for this guild. + public bool ManageWebhooks => Permissions.GetValue(RawValue, GuildPermission.ManageWebhooks); + /// If , a user may edit the emojis and stickers for this guild. + public bool ManageEmojisAndStickers => Permissions.GetValue(RawValue, GuildPermission.ManageEmojisAndStickers); + /// If , a user may use slash commands in this guild. + public bool UseApplicationCommands => Permissions.GetValue(RawValue, GuildPermission.UseApplicationCommands); + /// If , a user may request to speak in stage channels. + public bool RequestToSpeak => Permissions.GetValue(RawValue, GuildPermission.RequestToSpeak); + /// If , a user may create, edit, and delete events. + public bool ManageEvents => Permissions.GetValue(RawValue, GuildPermission.ManageEvents); + /// If , a user may manage threads in this guild. + public bool ManageThreads => Permissions.GetValue(RawValue, GuildPermission.ManageThreads); + /// If , a user may create public threads in this guild. + public bool CreatePublicThreads => Permissions.GetValue(RawValue, GuildPermission.CreatePublicThreads); + /// If , a user may create private threads in this guild. + public bool CreatePrivateThreads => Permissions.GetValue(RawValue, GuildPermission.CreatePrivateThreads); + /// If , a user may use external stickers in this guild. + public bool UseExternalStickers => Permissions.GetValue(RawValue, GuildPermission.UseExternalStickers); + /// If , a user may send messages in threads in this guild. + public bool SendMessagesInThreads => Permissions.GetValue(RawValue, GuildPermission.SendMessagesInThreads); + /// If , a user launch application activities in voice channels in this guild. + public bool StartEmbeddedActivities => Permissions.GetValue(RawValue, GuildPermission.StartEmbeddedActivities); + /// If , a user can timeout other users in this guild. + public bool ModerateMembers => Permissions.GetValue(RawValue, GuildPermission.ModerateMembers); + /// If , a user can use soundboard in this guild. + public bool UseSoundboard => Permissions.GetValue(RawValue, GuildPermission.UseSoundboard); + /// If , a user can view monetization analytics in this guild. + public bool ViewMonetizationAnalytics => Permissions.GetValue(RawValue, GuildPermission.ViewMonetizationAnalytics); + /// If , a user can send voice messages in this guild. + public bool SendVoiceMessages => Permissions.GetValue(RawValue, GuildPermission.SendVoiceMessages); + /// If , a user can use the Clyde AI bot in this guild. + public bool UseClydeAI => Permissions.GetValue(RawValue, GuildPermission.UseClydeAI); + /// If , a user can create guild expressions in this guild. + public bool CreateGuildExpressions => Permissions.GetValue(RawValue, GuildPermission.CreateGuildExpressions); + /// If , a user can set the status of a voice channel. + public bool SetVoiceChannelStatus => Permissions.GetValue(RawValue, GuildPermission.SetVoiceChannelStatus); + + /// Creates a new with the provided packed value. + public GuildPermissions(ulong rawValue) { RawValue = rawValue; } + + /// Creates a new with the provided packed value after converting to ulong. + public GuildPermissions(string rawValue) { RawValue = ulong.Parse(rawValue); } + + private GuildPermissions(ulong initialValue, + bool? createInstantInvite = null, + bool? kickMembers = null, + bool? banMembers = null, + bool? administrator = null, + bool? manageChannels = null, + bool? manageGuild = null, + bool? addReactions = null, + bool? viewAuditLog = null, + bool? viewGuildInsights = null, + bool? viewChannel = null, + bool? sendMessages = null, + bool? sendTTSMessages = null, + bool? manageMessages = null, + bool? embedLinks = null, + bool? attachFiles = null, + bool? readMessageHistory = null, + bool? mentionEveryone = null, + bool? useExternalEmojis = null, + bool? connect = null, + bool? speak = null, + bool? muteMembers = null, + bool? deafenMembers = null, + bool? moveMembers = null, + bool? useVoiceActivation = null, + bool? prioritySpeaker = null, + bool? stream = null, + bool? changeNickname = null, + bool? manageNicknames = null, + bool? manageRoles = null, + bool? manageWebhooks = null, + bool? manageEmojisAndStickers = null, + bool? useApplicationCommands = null, + bool? requestToSpeak = null, + bool? manageEvents = null, + bool? manageThreads = null, + bool? createPublicThreads = null, + bool? createPrivateThreads = null, + bool? useExternalStickers = null, + bool? sendMessagesInThreads = null, + bool? startEmbeddedActivities = null, + bool? moderateMembers = null, + bool? useSoundboard = null, + bool? viewMonetizationAnalytics = null, + bool? sendVoiceMessages = null, + bool? useClydeAI = null, + bool? createGuildExpressions = null, + bool? setVoiceChannelStatus = null) + { + ulong value = initialValue; + + Permissions.SetValue(ref value, createInstantInvite, GuildPermission.CreateInstantInvite); + Permissions.SetValue(ref value, banMembers, GuildPermission.BanMembers); + Permissions.SetValue(ref value, kickMembers, GuildPermission.KickMembers); + Permissions.SetValue(ref value, administrator, GuildPermission.Administrator); + Permissions.SetValue(ref value, manageChannels, GuildPermission.ManageChannels); + Permissions.SetValue(ref value, manageGuild, GuildPermission.ManageGuild); + Permissions.SetValue(ref value, addReactions, GuildPermission.AddReactions); + Permissions.SetValue(ref value, viewAuditLog, GuildPermission.ViewAuditLog); + Permissions.SetValue(ref value, viewGuildInsights, GuildPermission.ViewGuildInsights); + Permissions.SetValue(ref value, viewChannel, GuildPermission.ViewChannel); + Permissions.SetValue(ref value, sendMessages, GuildPermission.SendMessages); + Permissions.SetValue(ref value, sendTTSMessages, GuildPermission.SendTTSMessages); + Permissions.SetValue(ref value, manageMessages, GuildPermission.ManageMessages); + Permissions.SetValue(ref value, embedLinks, GuildPermission.EmbedLinks); + Permissions.SetValue(ref value, attachFiles, GuildPermission.AttachFiles); + Permissions.SetValue(ref value, readMessageHistory, GuildPermission.ReadMessageHistory); + Permissions.SetValue(ref value, mentionEveryone, GuildPermission.MentionEveryone); + Permissions.SetValue(ref value, useExternalEmojis, GuildPermission.UseExternalEmojis); + Permissions.SetValue(ref value, connect, GuildPermission.Connect); + Permissions.SetValue(ref value, speak, GuildPermission.Speak); + Permissions.SetValue(ref value, muteMembers, GuildPermission.MuteMembers); + Permissions.SetValue(ref value, deafenMembers, GuildPermission.DeafenMembers); + Permissions.SetValue(ref value, moveMembers, GuildPermission.MoveMembers); + Permissions.SetValue(ref value, useVoiceActivation, GuildPermission.UseVAD); + Permissions.SetValue(ref value, prioritySpeaker, GuildPermission.PrioritySpeaker); + Permissions.SetValue(ref value, stream, GuildPermission.Stream); + Permissions.SetValue(ref value, changeNickname, GuildPermission.ChangeNickname); + Permissions.SetValue(ref value, manageNicknames, GuildPermission.ManageNicknames); + Permissions.SetValue(ref value, manageRoles, GuildPermission.ManageRoles); + Permissions.SetValue(ref value, manageWebhooks, GuildPermission.ManageWebhooks); + Permissions.SetValue(ref value, manageEmojisAndStickers, GuildPermission.ManageEmojisAndStickers); + Permissions.SetValue(ref value, useApplicationCommands, GuildPermission.UseApplicationCommands); + Permissions.SetValue(ref value, requestToSpeak, GuildPermission.RequestToSpeak); + Permissions.SetValue(ref value, manageEvents, GuildPermission.ManageEvents); + Permissions.SetValue(ref value, manageThreads, GuildPermission.ManageThreads); + Permissions.SetValue(ref value, createPublicThreads, GuildPermission.CreatePublicThreads); + Permissions.SetValue(ref value, createPrivateThreads, GuildPermission.CreatePrivateThreads); + Permissions.SetValue(ref value, useExternalStickers, GuildPermission.UseExternalStickers); + Permissions.SetValue(ref value, sendMessagesInThreads, GuildPermission.SendMessagesInThreads); + Permissions.SetValue(ref value, startEmbeddedActivities, GuildPermission.StartEmbeddedActivities); + Permissions.SetValue(ref value, moderateMembers, GuildPermission.ModerateMembers); + Permissions.SetValue(ref value, useSoundboard, GuildPermission.UseSoundboard); + Permissions.SetValue(ref value, viewMonetizationAnalytics, GuildPermission.ViewMonetizationAnalytics); + Permissions.SetValue(ref value, sendVoiceMessages, GuildPermission.SendVoiceMessages); + Permissions.SetValue(ref value, useClydeAI, GuildPermission.UseClydeAI); + Permissions.SetValue(ref value, createGuildExpressions, GuildPermission.CreateGuildExpressions); + Permissions.SetValue(ref value, setVoiceChannelStatus, GuildPermission.SetVoiceChannelStatus); + + RawValue = value; + } + + /// Creates a new structure with the provided permissions. + public GuildPermissions( + bool createInstantInvite = false, + bool kickMembers = false, + bool banMembers = false, + bool administrator = false, + bool manageChannels = false, + bool manageGuild = false, + bool addReactions = false, + bool viewAuditLog = false, + bool viewGuildInsights = false, + bool viewChannel = false, + bool sendMessages = false, + bool sendTTSMessages = false, + bool manageMessages = false, + bool embedLinks = false, + bool attachFiles = false, + bool readMessageHistory = false, + bool mentionEveryone = false, + bool useExternalEmojis = false, + bool connect = false, + bool speak = false, + bool muteMembers = false, + bool deafenMembers = false, + bool moveMembers = false, + bool useVoiceActivation = false, + bool prioritySpeaker = false, + bool stream = false, + bool changeNickname = false, + bool manageNicknames = false, + bool manageRoles = false, + bool manageWebhooks = false, + bool manageEmojisAndStickers = false, + bool useApplicationCommands = false, + bool requestToSpeak = false, + bool manageEvents = false, + bool manageThreads = false, + bool createPublicThreads = false, + bool createPrivateThreads = false, + bool useExternalStickers = false, + bool sendMessagesInThreads = false, + bool startEmbeddedActivities = false, + bool moderateMembers = false, + bool useSoundboard = false, + bool viewMonetizationAnalytics = false, + bool sendVoiceMessages = false, + bool useClydeAI = false, + bool createGuildExpressions = false, + bool setVoiceChannelStatus = false) + : this(0, + createInstantInvite: createInstantInvite, + manageRoles: manageRoles, + kickMembers: kickMembers, + banMembers: banMembers, + administrator: administrator, + manageChannels: manageChannels, + manageGuild: manageGuild, + addReactions: addReactions, + viewAuditLog: viewAuditLog, + viewGuildInsights: viewGuildInsights, + viewChannel: viewChannel, + sendMessages: sendMessages, + sendTTSMessages: sendTTSMessages, + manageMessages: manageMessages, + embedLinks: embedLinks, + attachFiles: attachFiles, + readMessageHistory: readMessageHistory, + mentionEveryone: mentionEveryone, + useExternalEmojis: useExternalEmojis, + connect: connect, + speak: speak, + muteMembers: muteMembers, + deafenMembers: deafenMembers, + moveMembers: moveMembers, + useVoiceActivation: useVoiceActivation, + prioritySpeaker: prioritySpeaker, + stream: stream, + changeNickname: changeNickname, + manageNicknames: manageNicknames, + manageWebhooks: manageWebhooks, + manageEmojisAndStickers: manageEmojisAndStickers, + useApplicationCommands: useApplicationCommands, + requestToSpeak: requestToSpeak, + manageEvents: manageEvents, + manageThreads: manageThreads, + createPublicThreads: createPublicThreads, + createPrivateThreads: createPrivateThreads, + useExternalStickers: useExternalStickers, + sendMessagesInThreads: sendMessagesInThreads, + startEmbeddedActivities: startEmbeddedActivities, + moderateMembers: moderateMembers, + useSoundboard: useSoundboard, + viewMonetizationAnalytics: viewMonetizationAnalytics, + sendVoiceMessages: sendVoiceMessages, + useClydeAI: useClydeAI, + createGuildExpressions: createGuildExpressions, + setVoiceChannelStatus: setVoiceChannelStatus) + { } + + /// Creates a new from this one, changing the provided non-null permissions. + public GuildPermissions Modify( + bool? createInstantInvite = null, + bool? kickMembers = null, + bool? banMembers = null, + bool? administrator = null, + bool? manageChannels = null, + bool? manageGuild = null, + bool? addReactions = null, + bool? viewAuditLog = null, + bool? viewGuildInsights = null, + bool? viewChannel = null, + bool? sendMessages = null, + bool? sendTTSMessages = null, + bool? manageMessages = null, + bool? embedLinks = null, + bool? attachFiles = null, + bool? readMessageHistory = null, + bool? mentionEveryone = null, + bool? useExternalEmojis = null, + bool? connect = null, + bool? speak = null, + bool? muteMembers = null, + bool? deafenMembers = null, + bool? moveMembers = null, + bool? useVoiceActivation = null, + bool? prioritySpeaker = null, + bool? stream = null, + bool? changeNickname = null, + bool? manageNicknames = null, + bool? manageRoles = null, + bool? manageWebhooks = null, + bool? manageEmojisAndStickers = null, + bool? useApplicationCommands = null, + bool? requestToSpeak = null, + bool? manageEvents = null, + bool? manageThreads = null, + bool? createPublicThreads = null, + bool? createPrivateThreads = null, + bool? useExternalStickers = null, + bool? sendMessagesInThreads = null, + bool? startEmbeddedActivities = null, + bool? moderateMembers = null, + bool? useSoundboard = null, + bool? viewMonetizationAnalytics = null, + bool? sendVoiceMessages = null, + bool? useClydeAI = null, + bool? createGuildExpressions = null, + bool? setVoiceChannelStatus = null) + => new GuildPermissions(RawValue, createInstantInvite, kickMembers, banMembers, administrator, manageChannels, manageGuild, addReactions, + viewAuditLog, viewGuildInsights, viewChannel, sendMessages, sendTTSMessages, manageMessages, embedLinks, attachFiles, + readMessageHistory, mentionEveryone, useExternalEmojis, connect, speak, muteMembers, deafenMembers, moveMembers, + useVoiceActivation, prioritySpeaker, stream, changeNickname, manageNicknames, manageRoles, manageWebhooks, manageEmojisAndStickers, + useApplicationCommands, requestToSpeak, manageEvents, manageThreads, createPublicThreads, createPrivateThreads, useExternalStickers, sendMessagesInThreads, + startEmbeddedActivities, moderateMembers, useSoundboard, viewMonetizationAnalytics, sendVoiceMessages, useClydeAI, createGuildExpressions, setVoiceChannelStatus); + + /// + /// Returns a value that indicates if a specific is enabled + /// in these permissions. + /// + /// The permission value to check for. + /// if the permission is enabled, otherwise. + public bool Has(GuildPermission permission) => Permissions.GetValue(RawValue, permission); + + /// + /// Returns a containing all of the + /// flags that are enabled. + /// + /// A containing flags. Empty if none are enabled. + public List ToList() + { + var perms = new List(); + + // bitwise operations on raw value + // each of the GuildPermissions increments by 2^i from 0 to MaxBits + for (byte i = 0; i < Permissions.MaxBits; i++) + { + ulong flag = ((ulong)1 << i); + if ((RawValue & flag) != 0) + perms.Add((GuildPermission)flag); + } + return perms; + } + + internal void Ensure(GuildPermission permissions) + { + if (!Has(permissions)) + { + var vals = Enum.GetValues(typeof(GuildPermission)).Cast(); + var currentValues = RawValue; + var missingValues = vals.Where(x => permissions.HasFlag(x) && !Permissions.GetValue(currentValues, x)); + + throw new InvalidOperationException($"Missing required guild permission{(missingValues.Count() > 1 ? "s" : "")} {string.Join(", ", missingValues.Select(x => x.ToString()))} in order to execute this operation."); + } + } + + public override string ToString() => RawValue.ToString(); + private string DebuggerDisplay => $"{string.Join(", ", ToList())}"; + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/Overwrite.cs b/src/Discord.Net.Core/Entities/Permissions/Overwrite.cs new file mode 100644 index 0000000..f8f3fff --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/Overwrite.cs @@ -0,0 +1,31 @@ +namespace Discord +{ + /// + /// Represent a permission object. + /// + public struct Overwrite + { + /// + /// Gets the unique identifier for the object this overwrite is targeting. + /// + public ulong TargetId { get; } + /// + /// Gets the type of object this overwrite is targeting. + /// + public PermissionTarget TargetType { get; } + /// + /// Gets the permissions associated with this overwrite entry. + /// + public OverwritePermissions Permissions { get; } + + /// + /// Initializes a new with provided target information and modified permissions. + /// + public Overwrite(ulong targetId, PermissionTarget targetType, OverwritePermissions permissions) + { + TargetId = targetId; + TargetType = targetType; + Permissions = permissions; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/OverwritePermissions.cs b/src/Discord.Net.Core/Entities/Permissions/OverwritePermissions.cs new file mode 100644 index 0000000..fcc7573 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/OverwritePermissions.cs @@ -0,0 +1,313 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Diagnostics; + +namespace Discord +{ + /// + /// Represents a container for a series of overwrite permissions. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct OverwritePermissions + { + /// + /// Gets a blank that inherits all permissions. + /// + public static OverwritePermissions InheritAll { get; } = new OverwritePermissions(); + /// + /// Gets a that grants all permissions for the given channel. + /// + /// Unknown channel type. + public static OverwritePermissions AllowAll(IChannel channel) + => new OverwritePermissions(ChannelPermissions.All(channel).RawValue, 0); + /// + /// Gets a that denies all permissions for the given channel. + /// + /// Unknown channel type. + public static OverwritePermissions DenyAll(IChannel channel) + => new OverwritePermissions(0, ChannelPermissions.All(channel).RawValue); + + /// + /// Gets a packed value representing all the allowed permissions in this . + /// + public ulong AllowValue { get; } + /// + /// Gets a packed value representing all the denied permissions in this . + /// + public ulong DenyValue { get; } + + /// If Allowed, a user may create invites. + public PermValue CreateInstantInvite => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.CreateInstantInvite); + /// If Allowed, a user may create, delete and modify this channel. + public PermValue ManageChannel => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.ManageChannels); + /// If Allowed, a user may add reactions. + public PermValue AddReactions => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.AddReactions); + /// If Allowed, a user may join channels. + public PermValue ViewChannel => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.ViewChannel); + /// If Allowed, a user may send messages. + public PermValue SendMessages => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.SendMessages); + /// If Allowed, a user may send text-to-speech messages. + public PermValue SendTTSMessages => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.SendTTSMessages); + /// If Allowed, a user may delete messages. + public PermValue ManageMessages => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.ManageMessages); + /// If Allowed, Discord will auto-embed links sent by this user. + public PermValue EmbedLinks => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.EmbedLinks); + /// If Allowed, a user may send files. + public PermValue AttachFiles => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.AttachFiles); + /// If Allowed, a user may read previous messages. + public PermValue ReadMessageHistory => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.ReadMessageHistory); + /// If Allowed, a user may mention @everyone. + public PermValue MentionEveryone => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.MentionEveryone); + /// If Allowed, a user may use custom emoji from other guilds. + public PermValue UseExternalEmojis => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.UseExternalEmojis); + + /// If Allowed, a user may connect to a voice channel. + public PermValue Connect => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.Connect); + /// If Allowed, a user may speak in a voice channel. + public PermValue Speak => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.Speak); + /// If Allowed, a user may mute users. + public PermValue MuteMembers => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.MuteMembers); + /// If Allowed, a user may deafen users. + public PermValue DeafenMembers => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.DeafenMembers); + /// If Allowed, a user may move other users between voice channels. + public PermValue MoveMembers => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.MoveMembers); + /// If Allowed, a user may use voice-activity-detection rather than push-to-talk. + public PermValue UseVAD => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.UseVAD); + /// If Allowed, a user may use priority speaker in a voice channel. + public PermValue PrioritySpeaker => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.PrioritySpeaker); + /// If Allowed, a user may go live in a voice channel. + public PermValue Stream => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.Stream); + + /// If Allowed, a user may adjust role permissions. This also implicitly grants all other permissions. + public PermValue ManageRoles => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.ManageRoles); + /// If True, a user may edit the webhooks for this channel. + public PermValue ManageWebhooks => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.ManageWebhooks); + /// If , a user may use slash commands in this guild. + public PermValue UseApplicationCommands => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.UseApplicationCommands); + /// If , a user may request to speak in stage channels. + public PermValue RequestToSpeak => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.RequestToSpeak); + /// If , a user may manage threads in this guild. + public PermValue ManageThreads => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.ManageThreads); + /// If , a user may create public threads in this guild. + public PermValue CreatePublicThreads => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.CreatePublicThreads); + /// If , a user may create private threads in this guild. + public PermValue CreatePrivateThreads => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.CreatePrivateThreads); + /// If , a user may use external stickers in this guild. + public PermValue UseExternalStickers => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.UseExternalStickers); + /// If , a user may send messages in threads in this guild. + public PermValue SendMessagesInThreads => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.SendMessagesInThreads); + /// If , a user launch application activities in voice channels in this guild. + public PermValue StartEmbeddedActivities => Permissions.GetValue(AllowValue, DenyValue, ChannelPermission.StartEmbeddedActivities); + + /// Creates a new OverwritePermissions with the provided allow and deny packed values. + public OverwritePermissions(ulong allowValue, ulong denyValue) + { + AllowValue = allowValue; + DenyValue = denyValue; + } + + /// Creates a new OverwritePermissions with the provided allow and deny packed values after converting to ulong. + public OverwritePermissions(string allowValue, string denyValue) + { + AllowValue = ulong.Parse(allowValue); + DenyValue = ulong.Parse(denyValue); + } + + private OverwritePermissions(ulong allowValue, ulong denyValue, + PermValue? createInstantInvite = null, + PermValue? manageChannel = null, + PermValue? addReactions = null, + PermValue? viewChannel = null, + PermValue? sendMessages = null, + PermValue? sendTTSMessages = null, + PermValue? manageMessages = null, + PermValue? embedLinks = null, + PermValue? attachFiles = null, + PermValue? readMessageHistory = null, + PermValue? mentionEveryone = null, + PermValue? useExternalEmojis = null, + PermValue? connect = null, + PermValue? speak = null, + PermValue? muteMembers = null, + PermValue? deafenMembers = null, + PermValue? moveMembers = null, + PermValue? useVoiceActivation = null, + PermValue? manageRoles = null, + PermValue? manageWebhooks = null, + PermValue? prioritySpeaker = null, + PermValue? stream = null, + PermValue? useSlashCommands = null, + PermValue? useApplicationCommands = null, + PermValue? requestToSpeak = null, + PermValue? manageThreads = null, + PermValue? createPublicThreads = null, + PermValue? createPrivateThreads = null, + PermValue? usePublicThreads = null, + PermValue? usePrivateThreads = null, + PermValue? useExternalStickers = null, + PermValue? sendMessagesInThreads = null, + PermValue? startEmbeddedActivities = null) + { + Permissions.SetValue(ref allowValue, ref denyValue, createInstantInvite, ChannelPermission.CreateInstantInvite); + Permissions.SetValue(ref allowValue, ref denyValue, manageChannel, ChannelPermission.ManageChannels); + Permissions.SetValue(ref allowValue, ref denyValue, addReactions, ChannelPermission.AddReactions); + Permissions.SetValue(ref allowValue, ref denyValue, viewChannel, ChannelPermission.ViewChannel); + Permissions.SetValue(ref allowValue, ref denyValue, sendMessages, ChannelPermission.SendMessages); + Permissions.SetValue(ref allowValue, ref denyValue, sendTTSMessages, ChannelPermission.SendTTSMessages); + Permissions.SetValue(ref allowValue, ref denyValue, manageMessages, ChannelPermission.ManageMessages); + Permissions.SetValue(ref allowValue, ref denyValue, embedLinks, ChannelPermission.EmbedLinks); + Permissions.SetValue(ref allowValue, ref denyValue, attachFiles, ChannelPermission.AttachFiles); + Permissions.SetValue(ref allowValue, ref denyValue, readMessageHistory, ChannelPermission.ReadMessageHistory); + Permissions.SetValue(ref allowValue, ref denyValue, mentionEveryone, ChannelPermission.MentionEveryone); + Permissions.SetValue(ref allowValue, ref denyValue, useExternalEmojis, ChannelPermission.UseExternalEmojis); + Permissions.SetValue(ref allowValue, ref denyValue, connect, ChannelPermission.Connect); + Permissions.SetValue(ref allowValue, ref denyValue, speak, ChannelPermission.Speak); + Permissions.SetValue(ref allowValue, ref denyValue, muteMembers, ChannelPermission.MuteMembers); + Permissions.SetValue(ref allowValue, ref denyValue, deafenMembers, ChannelPermission.DeafenMembers); + Permissions.SetValue(ref allowValue, ref denyValue, moveMembers, ChannelPermission.MoveMembers); + Permissions.SetValue(ref allowValue, ref denyValue, useVoiceActivation, ChannelPermission.UseVAD); + Permissions.SetValue(ref allowValue, ref denyValue, prioritySpeaker, ChannelPermission.PrioritySpeaker); + Permissions.SetValue(ref allowValue, ref denyValue, stream, ChannelPermission.Stream); + Permissions.SetValue(ref allowValue, ref denyValue, manageRoles, ChannelPermission.ManageRoles); + Permissions.SetValue(ref allowValue, ref denyValue, manageWebhooks, ChannelPermission.ManageWebhooks); + Permissions.SetValue(ref allowValue, ref denyValue, useApplicationCommands, ChannelPermission.UseApplicationCommands); + Permissions.SetValue(ref allowValue, ref denyValue, requestToSpeak, ChannelPermission.RequestToSpeak); + Permissions.SetValue(ref allowValue, ref denyValue, manageThreads, ChannelPermission.ManageThreads); + Permissions.SetValue(ref allowValue, ref denyValue, createPublicThreads, ChannelPermission.CreatePublicThreads); + Permissions.SetValue(ref allowValue, ref denyValue, createPrivateThreads, ChannelPermission.CreatePrivateThreads); + Permissions.SetValue(ref allowValue, ref denyValue, useExternalStickers, ChannelPermission.UseExternalStickers); + Permissions.SetValue(ref allowValue, ref denyValue, sendMessagesInThreads, ChannelPermission.SendMessagesInThreads); + Permissions.SetValue(ref allowValue, ref denyValue, startEmbeddedActivities, ChannelPermission.StartEmbeddedActivities); + + AllowValue = allowValue; + DenyValue = denyValue; + } + + /// + /// Initializes a new struct with the provided permissions. + /// + public OverwritePermissions( + PermValue createInstantInvite = PermValue.Inherit, + PermValue manageChannel = PermValue.Inherit, + PermValue addReactions = PermValue.Inherit, + PermValue viewChannel = PermValue.Inherit, + PermValue sendMessages = PermValue.Inherit, + PermValue sendTTSMessages = PermValue.Inherit, + PermValue manageMessages = PermValue.Inherit, + PermValue embedLinks = PermValue.Inherit, + PermValue attachFiles = PermValue.Inherit, + PermValue readMessageHistory = PermValue.Inherit, + PermValue mentionEveryone = PermValue.Inherit, + PermValue useExternalEmojis = PermValue.Inherit, + PermValue connect = PermValue.Inherit, + PermValue speak = PermValue.Inherit, + PermValue muteMembers = PermValue.Inherit, + PermValue deafenMembers = PermValue.Inherit, + PermValue moveMembers = PermValue.Inherit, + PermValue useVoiceActivation = PermValue.Inherit, + PermValue manageRoles = PermValue.Inherit, + PermValue manageWebhooks = PermValue.Inherit, + PermValue prioritySpeaker = PermValue.Inherit, + PermValue stream = PermValue.Inherit, + PermValue useSlashCommands = PermValue.Inherit, + PermValue useApplicationCommands = PermValue.Inherit, + PermValue requestToSpeak = PermValue.Inherit, + PermValue manageThreads = PermValue.Inherit, + PermValue createPublicThreads = PermValue.Inherit, + PermValue createPrivateThreads = PermValue.Inherit, + PermValue usePublicThreads = PermValue.Inherit, + PermValue usePrivateThreads = PermValue.Inherit, + PermValue useExternalStickers = PermValue.Inherit, + PermValue sendMessagesInThreads = PermValue.Inherit, + PermValue startEmbeddedActivities = PermValue.Inherit) + : this(0, 0, createInstantInvite, manageChannel, addReactions, viewChannel, sendMessages, sendTTSMessages, manageMessages, + embedLinks, attachFiles, readMessageHistory, mentionEveryone, useExternalEmojis, connect, speak, muteMembers, deafenMembers, + moveMembers, useVoiceActivation, manageRoles, manageWebhooks, prioritySpeaker, stream, useSlashCommands, useApplicationCommands, + requestToSpeak, manageThreads, createPublicThreads, createPrivateThreads, usePublicThreads, usePrivateThreads, useExternalStickers, + sendMessagesInThreads, startEmbeddedActivities) + { } + + /// + /// Initializes a new from the current one, changing the provided + /// non-null permissions. + /// + public OverwritePermissions Modify( + PermValue? createInstantInvite = null, + PermValue? manageChannel = null, + PermValue? addReactions = null, + PermValue? viewChannel = null, + PermValue? sendMessages = null, + PermValue? sendTTSMessages = null, + PermValue? manageMessages = null, + PermValue? embedLinks = null, + PermValue? attachFiles = null, + PermValue? readMessageHistory = null, + PermValue? mentionEveryone = null, + PermValue? useExternalEmojis = null, + PermValue? connect = null, + PermValue? speak = null, + PermValue? muteMembers = null, + PermValue? deafenMembers = null, + PermValue? moveMembers = null, + PermValue? useVoiceActivation = null, + PermValue? manageRoles = null, + PermValue? manageWebhooks = null, + PermValue? prioritySpeaker = null, + PermValue? stream = null, + PermValue? useSlashCommands = null, + PermValue? useApplicationCommands = null, + PermValue? requestToSpeak = null, + PermValue? manageThreads = null, + PermValue? createPublicThreads = null, + PermValue? createPrivateThreads = null, + PermValue? usePublicThreads = null, + PermValue? usePrivateThreads = null, + PermValue? useExternalStickers = null, + PermValue? sendMessagesInThreads = null, + PermValue? startEmbeddedActivities = null) + => new OverwritePermissions(AllowValue, DenyValue, createInstantInvite, manageChannel, addReactions, viewChannel, sendMessages, sendTTSMessages, manageMessages, + embedLinks, attachFiles, readMessageHistory, mentionEveryone, useExternalEmojis, connect, speak, muteMembers, deafenMembers, + moveMembers, useVoiceActivation, manageRoles, manageWebhooks, prioritySpeaker, stream, useSlashCommands, useApplicationCommands, + requestToSpeak, manageThreads, createPublicThreads, createPrivateThreads, usePublicThreads, usePrivateThreads, useExternalStickers, + sendMessagesInThreads, startEmbeddedActivities); + + /// + /// Creates a of all the values that are allowed. + /// + /// A of all allowed flags. If none, the list will be empty. + public List ToAllowList() + { + var perms = new List(); + for (byte i = 0; i < Permissions.MaxBits; i++) + { + // first operand must be long or ulong to shift >31 bits + ulong flag = ((ulong)1 << i); + if ((AllowValue & flag) != 0) + perms.Add((ChannelPermission)flag); + } + return perms; + } + + /// + /// Creates a of all the values that are denied. + /// + /// A of all denied flags. If none, the list will be empty. + public List ToDenyList() + { + var perms = new List(); + for (byte i = 0; i < Permissions.MaxBits; i++) + { + ulong flag = ((ulong)1 << i); + if ((DenyValue & flag) != 0) + perms.Add((ChannelPermission)flag); + } + return perms; + } + + public override string ToString() => $"Allow {AllowValue}, Deny {DenyValue}"; + private string DebuggerDisplay => + $"Allow {string.Join(", ", ToAllowList())}, " + + $"Deny {string.Join(", ", ToDenyList())}"; + } +} diff --git a/src/Discord.Net.Core/Entities/Permissions/PermValue.cs b/src/Discord.Net.Core/Entities/Permissions/PermValue.cs new file mode 100644 index 0000000..6cea827 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Permissions/PermValue.cs @@ -0,0 +1,13 @@ +namespace Discord +{ + /// Specifies the permission value. + public enum PermValue + { + /// Allows this permission. + Allow, + /// Denies this permission. + Deny, + /// Inherits the permission settings. + Inherit + } +} diff --git a/src/Discord.Net.Core/Entities/Roles/Color.cs b/src/Discord.Net.Core/Entities/Roles/Color.cs new file mode 100644 index 0000000..ee50710 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Roles/Color.cs @@ -0,0 +1,225 @@ +using System; +using System.Diagnostics; +using StandardColor = System.Drawing.Color; + +namespace Discord +{ + /// + /// Represents a color used in Discord. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct Color + { + /// Gets the max decimal value of color. + public const uint MaxDecimalValue = 0xFFFFFF; + /// Gets the default user color value. + public static readonly Color Default = new(0); + /// Gets the teal color value. + /// A color struct with the hex value of 1ABC9C. + public static readonly Color Teal = new(0x1ABC9C); + /// Gets the dark teal color value. + /// A color struct with the hex value of 11806A. + public static readonly Color DarkTeal = new(0x11806A); + /// Gets the green color value. + /// A color struct with the hex value of 2ECC71. + public static readonly Color Green = new(0x2ECC71); + /// Gets the dark green color value. + /// A color struct with the hex value of 1F8B4C. + public static readonly Color DarkGreen = new(0x1F8B4C); + /// Gets the blue color value. + /// A color struct with the hex value of 3498DB. + public static readonly Color Blue = new(0x3498DB); + /// Gets the dark blue color value. + /// A color struct with the hex value of 206694. + public static readonly Color DarkBlue = new(0x206694); + /// Gets the purple color value. + /// A color struct with the hex value of 9B59B6. + public static readonly Color Purple = new(0x9B59B6); + /// Gets the dark purple color value. + /// A color struct with the hex value of 71368A. + public static readonly Color DarkPurple = new(0x71368A); + /// Gets the magenta color value. + /// A color struct with the hex value of E91E63. + public static readonly Color Magenta = new(0xE91E63); + /// Gets the dark magenta color value. + /// A color struct with the hex value of AD1457. + public static readonly Color DarkMagenta = new(0xAD1457); + /// Gets the gold color value. + /// A color struct with the hex value of F1C40F. + public static readonly Color Gold = new(0xF1C40F); + /// Gets the light orange color value. + /// A color struct with the hex value of C27C0E. + public static readonly Color LightOrange = new(0xC27C0E); + /// Gets the orange color value. + /// A color struct with the hex value of E67E22. + public static readonly Color Orange = new(0xE67E22); + /// Gets the dark orange color value. + /// A color struct with the hex value of A84300. + public static readonly Color DarkOrange = new(0xA84300); + /// Gets the red color value. + /// A color struct with the hex value of E74C3C. + public static readonly Color Red = new(0xE74C3C); + /// Gets the dark red color value. + /// A color struct with the hex value of 992D22. + public static readonly Color DarkRed = new(0x992D22); + /// Gets the light grey color value. + /// A color struct with the hex value of 979C9F. + public static readonly Color LightGrey = new(0x979C9F); + /// Gets the lighter grey color value. + /// A color struct with the hex value of 95A5A6. + public static readonly Color LighterGrey = new(0x95A5A6); + /// Gets the dark grey color value. + /// A color struct with the hex value of 607D8B. + public static readonly Color DarkGrey = new(0x607D8B); + /// Gets the darker grey color value. + /// A color struct with the hex value of 546E7A. + public static readonly Color DarkerGrey = new(0x546E7A); + + /// Gets the encoded value for this color. + /// + /// This value is encoded as an unsigned integer value. The most-significant 8 bits contain the red value, + /// the middle 8 bits contain the green value, and the least-significant 8 bits contain the blue value. + /// + public uint RawValue { get; } + + /// Gets the red component for this color. + public byte R => (byte)(RawValue >> 16); + /// Gets the green component for this color. + public byte G => (byte)(RawValue >> 8); + /// Gets the blue component for this color. + public byte B => (byte)(RawValue); + + /// + /// Initializes a struct with the given raw value. + /// + /// + /// The following will create a color that has a hex value of + /// #607D8B. + /// + /// Color darkGrey = new Color(0x607D8B); + /// + /// + /// The raw value of the color (e.g. 0x607D8B). + /// Value exceeds . + public Color(uint rawValue) + { + if (rawValue > MaxDecimalValue) + throw new ArgumentException($"{nameof(RawValue)} of color cannot be greater than {MaxDecimalValue}!", nameof(rawValue)); + + RawValue = rawValue; + } + + /// + /// Initializes a struct with the given RGB bytes. + /// + /// + /// The following will create a color that has a value of + /// #607D8B. + /// + /// Color darkGrey = new Color((byte)0b_01100000, (byte)0b_01111101, (byte)0b_10001011); + /// + /// + /// The byte that represents the red color. + /// The byte that represents the green color. + /// The byte that represents the blue color. + /// Value exceeds . + public Color(byte r, byte g, byte b) + { + uint value = ((uint)r << 16) + | ((uint)g << 8) + | (uint)b; + + if (value > MaxDecimalValue) + throw new ArgumentException($"{nameof(RawValue)} of color cannot be greater than {MaxDecimalValue}!"); + + RawValue = value; + } + + /// + /// Initializes a struct with the given RGB value. + /// + /// + /// The following will create a color that has a value of + /// #607D8B. + /// + /// Color darkGrey = new Color(96, 125, 139); + /// + /// + /// The value that represents the red color. Must be within 0~255. + /// The value that represents the green color. Must be within 0~255. + /// The value that represents the blue color. Must be within 0~255. + /// The argument value is not between 0 to 255. + public Color(int r, int g, int b) + { + if (r < 0 || r > 255) + throw new ArgumentOutOfRangeException(nameof(r), "Value must be within [0,255]."); + if (g < 0 || g > 255) + throw new ArgumentOutOfRangeException(nameof(g), "Value must be within [0,255]."); + if (b < 0 || b > 255) + throw new ArgumentOutOfRangeException(nameof(b), "Value must be within [0,255]."); + RawValue = ((uint)r << 16) + | ((uint)g << 8) + | (uint)b; + } + /// + /// Initializes a struct with the given RGB float value. + /// + /// + /// The following will create a color that has a value of + /// #607c8c. + /// + /// Color darkGrey = new Color(0.38f, 0.49f, 0.55f); + /// + /// + /// The value that represents the red color. Must be within 0~1. + /// The value that represents the green color. Must be within 0~1. + /// The value that represents the blue color. Must be within 0~1. + /// The argument value is not between 0 to 1. + public Color(float r, float g, float b) + { + if (r < 0.0f || r > 1.0f) + throw new ArgumentOutOfRangeException(nameof(r), "Value must be within [0,1]."); + if (g < 0.0f || g > 1.0f) + throw new ArgumentOutOfRangeException(nameof(g), "Value must be within [0,1]."); + if (b < 0.0f || b > 1.0f) + throw new ArgumentOutOfRangeException(nameof(b), "Value must be within [0,1]."); + RawValue = ((uint)(r * 255.0f) << 16) + | ((uint)(g * 255.0f) << 8) + | (uint)(b * 255.0f); + } + + public static bool operator ==(Color lhs, Color rhs) + => lhs.RawValue == rhs.RawValue; + + public static bool operator !=(Color lhs, Color rhs) + => lhs.RawValue != rhs.RawValue; + + public static implicit operator Color(uint rawValue) + => new(rawValue); + + public static implicit operator uint(Color color) + => color.RawValue; + + public override bool Equals(object obj) + => obj is Color c && RawValue == c.RawValue; + + public override int GetHashCode() => RawValue.GetHashCode(); + + public static implicit operator StandardColor(Color color) + => StandardColor.FromArgb((int)color.RawValue); + + public static explicit operator Color(StandardColor color) + => new((uint)color.ToArgb() << 8 >> 8); + + /// + /// Gets the hexadecimal representation of the color (e.g. #000ccc). + /// + /// + /// A hexadecimal string of the color. + /// + public override string ToString() => + string.Format("#{0:X6}", RawValue); + private string DebuggerDisplay => + string.Format("#{0:X6} ({0})", RawValue); + } +} diff --git a/src/Discord.Net.Core/Entities/Roles/IRole.cs b/src/Discord.Net.Core/Entities/Roles/IRole.cs new file mode 100644 index 0000000..ac4719e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Roles/IRole.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic role object to be given to a guild user. + /// + public interface IRole : ISnowflakeEntity, IDeletable, IMentionable, IComparable + { + /// + /// Gets the guild that owns this role. + /// + /// + /// A guild representing the parent guild of this role. + /// + IGuild Guild { get; } + + /// + /// Gets the color given to users of this role. + /// + /// + /// A struct representing the color of this role. + /// + Color Color { get; } + /// + /// Gets a value that indicates whether the role can be separated in the user list. + /// + /// + /// if users of this role are separated in the user list; otherwise . + /// + bool IsHoisted { get; } + /// + /// Gets a value that indicates whether the role is managed by Discord. + /// + /// + /// if this role is automatically managed by Discord; otherwise . + /// + bool IsManaged { get; } + /// + /// Gets a value that indicates whether the role is mentionable. + /// + /// + /// if this role may be mentioned in messages; otherwise . + /// + bool IsMentionable { get; } + /// + /// Gets the name of this role. + /// + /// + /// A string containing the name of this role. + /// + string Name { get; } + /// + /// Gets the icon of this role. + /// + /// + /// A string containing the hash of this role's icon. + /// + string Icon { get; } + /// + /// Gets the unicode emoji of this role. + /// + /// + /// This field is mutually exclusive with , either icon is set or emoji is set. + /// + Emoji Emoji { get; } + /// + /// Gets the permissions granted to members of this role. + /// + /// + /// A struct that this role possesses. + /// + GuildPermissions Permissions { get; } + /// + /// Gets this role's position relative to other roles in the same guild. + /// + /// + /// An representing the position of the role in the role list of the guild. + /// + int Position { get; } + /// + /// Gets the tags related to this role. + /// + /// + /// A object containing all tags related to this role. + /// + RoleTags Tags { get; } + + /// + /// Gets flags related to this role. + /// + RoleFlags Flags { get; } + + /// + /// Modifies this role. + /// + /// + /// This method modifies this role with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// A delegate containing the properties to modify the role with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Gets the image url of the icon role. + /// + /// + /// An image url of the icon role. + /// + string GetIconUrl(); + } +} diff --git a/src/Discord.Net.Core/Entities/Roles/ReorderRoleProperties.cs b/src/Discord.Net.Core/Entities/Roles/ReorderRoleProperties.cs new file mode 100644 index 0000000..0074c0a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Roles/ReorderRoleProperties.cs @@ -0,0 +1,34 @@ +namespace Discord +{ + /// + /// Properties that are used to reorder an . + /// + public class ReorderRoleProperties + { + /// + /// Gets the identifier of the role to be edited. + /// + /// + /// A representing the snowflake identifier of the role to be modified. + /// + public ulong Id { get; } + /// + /// Gets the new zero-based position of the role. + /// + /// + /// An representing the new zero-based position of the role. + /// + public int Position { get; } + + /// + /// Initializes a with the given role ID and position. + /// + /// The ID of the role to be edited. + /// The new zero-based position of the role. + public ReorderRoleProperties(ulong id, int pos) + { + Id = id; + Position = pos; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Roles/RoleFlags.cs b/src/Discord.Net.Core/Entities/Roles/RoleFlags.cs new file mode 100644 index 0000000..0ed8f12 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Roles/RoleFlags.cs @@ -0,0 +1,17 @@ +using System; + +namespace Discord; + +[Flags] +public enum RoleFlags +{ + /// + /// The role has no flags. + /// + None = 0, + + /// + /// Indicates that the role can be selected by members in an onboarding. + /// + InPrompt = 1 << 0, +} diff --git a/src/Discord.Net.Core/Entities/Roles/RoleProperties.cs b/src/Discord.Net.Core/Entities/Roles/RoleProperties.cs new file mode 100644 index 0000000..b6399c0 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Roles/RoleProperties.cs @@ -0,0 +1,81 @@ +using System; + +namespace Discord +{ + /// + /// Properties that are used to modify an with the specified changes. + /// + /// + /// The following example modifies the role to a mentionable one, renames the role into Sonic, and + /// changes the color to a light-blue. + /// + /// await role.ModifyAsync(x => + /// { + /// x.Name = "Sonic"; + /// x.Color = new Color(0x1A50BC); + /// x.Mentionable = true; + /// }); + /// + /// + /// + public class RoleProperties + { + /// + /// Gets or sets the name of the role. + /// + /// + /// This value may not be set if the role is an @everyone role. + /// + public Optional Name { get; set; } + /// + /// Gets or sets the role's . + /// + public Optional Permissions { get; set; } + /// + /// Gets or sets the position of the role. This is 0-based! + /// + /// + /// This value may not be set if the role is an @everyone role. + /// + public Optional Position { get; set; } + /// + /// Gets or sets the color of the role. + /// + /// + /// This value may not be set if the role is an @everyone role. + /// + public Optional Color { get; set; } + /// + /// Gets or sets whether or not this role should be displayed independently in the user list. + /// + /// + /// This value may not be set if the role is an @everyone role. + /// + public Optional Hoist { get; set; } + /// + /// Gets or sets the icon of the role. + /// + /// + /// This value cannot be set at the same time as Emoji, as they are both exclusive. + /// + /// Setting an Icon will override a currently existing Emoji if present. + /// + public Optional Icon { get; set; } + /// + /// Gets or sets the unicode emoji of the role. + /// + /// + /// This value cannot be set at the same time as Icon, as they are both exclusive. + /// + /// Setting an Emoji will override a currently existing Icon if present. + /// + public Optional Emoji { get; set; } + /// + /// Gets or sets whether or not this role can be mentioned. + /// + /// + /// This value may not be set if the role is an @everyone role. + /// + public Optional Mentionable { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Roles/RoleTags.cs b/src/Discord.Net.Core/Entities/Roles/RoleTags.cs new file mode 100644 index 0000000..d0cbd35 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Roles/RoleTags.cs @@ -0,0 +1,40 @@ +namespace Discord +{ + /// + /// Provides tags related to a discord role. + /// + public class RoleTags + { + /// + /// Gets the identifier of the bot that this role belongs to, if it does. + /// + /// + /// A if this role belongs to a bot; otherwise + /// . + /// + public ulong? BotId { get; } + /// + /// Gets the identifier of the integration that this role belongs to, if it does. + /// + /// + /// A if this role belongs to an integration; otherwise + /// . + /// + public ulong? IntegrationId { get; } + /// + /// Gets if this role is the guild's premium subscriber (booster) role. + /// + /// + /// if this role is the guild's premium subscriber role; + /// otherwise . + /// + public bool IsPremiumSubscriberRole { get; } + + internal RoleTags(ulong? botId, ulong? integrationId, bool isPremiumSubscriber) + { + BotId = botId; + IntegrationId = integrationId; + IsPremiumSubscriberRole = isPremiumSubscriber; + } + } +} diff --git a/src/Discord.Net.Core/Entities/Stickers/ICustomSticker.cs b/src/Discord.Net.Core/Entities/Stickers/ICustomSticker.cs new file mode 100644 index 0000000..9cba38c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Stickers/ICustomSticker.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a custom sticker within a guild. + /// + public interface ICustomSticker : ISticker + { + /// + /// Gets the users id who uploaded the sticker. + /// + /// + /// In order to get the author id, the bot needs the MANAGE_EMOJIS_AND_STICKERS permission. + /// + ulong? AuthorId { get; } + + /// + /// Gets the guild that this custom sticker is in. + /// + IGuild Guild { get; } + + /// + /// Modifies this sticker. + /// + /// + /// This method modifies this sticker with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + ///
+ ///
+ /// The bot needs the MANAGE_EMOJIS_AND_STICKERS permission within the guild in order to modify stickers. + ///
+ /// + /// The following example replaces the name of the sticker with kekw. + /// + /// await sticker.ModifyAsync(x => x.Name = "kekw"); + /// + /// + /// A delegate containing the properties to modify the sticker with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + + /// + /// Deletes the current sticker. + /// + /// + /// The bot needs the MANAGE_EMOJIS_AND_STICKERS permission inside the guild in order to delete stickers. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous deletion operation. + /// + Task DeleteAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Stickers/ISticker.cs b/src/Discord.Net.Core/Entities/Stickers/ISticker.cs new file mode 100644 index 0000000..9deea75 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Stickers/ISticker.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a discord sticker. + /// + public interface ISticker : IStickerItem + { + /// + /// Gets the ID of this sticker. + /// + /// + /// A snowflake ID associated with this sticker. + /// + new ulong Id { get; } + /// + /// Gets the ID of the pack of this sticker. + /// + /// + /// A snowflake ID associated with the pack of this sticker. + /// + ulong PackId { get; } + /// + /// Gets the name of this sticker. + /// + /// + /// A with the name of this sticker. + /// + new string Name { get; } + /// + /// Gets the description of this sticker. + /// + /// + /// A with the description of this sticker. + /// + string Description { get; } + /// + /// Gets the list of tags of this sticker. + /// + /// + /// A read-only list with the tags of this sticker. + /// + IReadOnlyCollection Tags { get; } + /// + /// Gets the type of this sticker. + /// + StickerType Type { get; } + /// + /// Gets the format type of this sticker. + /// + /// + /// A with the format type of this sticker. + /// + new StickerFormatType Format { get; } + + /// + /// Gets whether this guild sticker can be used, may be false due to loss of Server Boosts. + /// + bool? IsAvailable { get; } + + /// + /// Gets the standard sticker's sort order within its pack. + /// + int? SortOrder { get; } + /// + /// Gets the image url for this sticker. + /// + string GetStickerUrl(); + } +} diff --git a/src/Discord.Net.Core/Entities/Stickers/IStickerItem.cs b/src/Discord.Net.Core/Entities/Stickers/IStickerItem.cs new file mode 100644 index 0000000..07ea63d --- /dev/null +++ b/src/Discord.Net.Core/Entities/Stickers/IStickerItem.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + /// + /// Represents a partial sticker item received with a message. + /// + public interface IStickerItem + { + /// + /// The id of the sticker. + /// + ulong Id { get; } + + /// + /// The name of the sticker. + /// + string Name { get; } + + /// + /// The format of the sticker. + /// + StickerFormatType Format { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Stickers/StickerPack.cs b/src/Discord.Net.Core/Entities/Stickers/StickerPack.cs new file mode 100644 index 0000000..c0c90aa --- /dev/null +++ b/src/Discord.Net.Core/Entities/Stickers/StickerPack.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord +{ + /// + /// Represents a discord sticker pack. + /// + /// The type of the stickers within the collection. + public class StickerPack where TSticker : ISticker + { + /// + /// Gets the id of the sticker pack. + /// + public ulong Id { get; } + + /// + /// Gets a collection of the stickers in the pack. + /// + public IReadOnlyCollection Stickers { get; } + + /// + /// Gets the name of the sticker pack. + /// + public string Name { get; } + + /// + /// Gets the id of the pack's SKU. + /// + public ulong SkuId { get; } + + /// + /// Gets the id of a sticker in the pack which is shown as the pack's icon. + /// + public ulong? CoverStickerId { get; } + + /// + /// Gets the description of the sticker pack. + /// + public string Description { get; } + + /// + /// Gets the id of the sticker pack's banner image + /// + public ulong BannerAssetId { get; } + + internal StickerPack(string name, ulong id, ulong skuid, ulong? coverStickerId, string description, ulong bannerAssetId, IEnumerable stickers) + { + Name = name; + Id = id; + SkuId = skuid; + CoverStickerId = coverStickerId; + Description = description; + BannerAssetId = bannerAssetId; + + Stickers = stickers.ToImmutableArray(); + } + } +} diff --git a/src/Discord.Net.Core/Entities/Stickers/StickerProperties.cs b/src/Discord.Net.Core/Entities/Stickers/StickerProperties.cs new file mode 100644 index 0000000..5f51e5f --- /dev/null +++ b/src/Discord.Net.Core/Entities/Stickers/StickerProperties.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a class used to modify stickers. + /// + public class StickerProperties + { + /// + /// Gets or sets the name of the sticker. + /// + public Optional Name { get; set; } + + /// + /// Gets or sets the description of the sticker. + /// + public Optional Description { get; set; } + + /// + /// Gets or sets the tags of the sticker. + /// + public Optional> Tags { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Stickers/StickerType.cs b/src/Discord.Net.Core/Entities/Stickers/StickerType.cs new file mode 100644 index 0000000..0db5507 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Stickers/StickerType.cs @@ -0,0 +1,18 @@ +namespace Discord +{ + /// + /// Represents a type of sticker.. + /// + public enum StickerType + { + /// + /// Represents a discord standard sticker, this type of sticker cannot be modified by an application. + /// + Standard = 1, + + /// + /// Represents a sticker that was created within a guild. + /// + Guild = 2 + } +} diff --git a/src/Discord.Net.Core/Entities/Teams/ITeam.cs b/src/Discord.Net.Core/Entities/Teams/ITeam.cs new file mode 100644 index 0000000..b6e3d98 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Teams/ITeam.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a Discord Team. + /// + public interface ITeam + { + /// + /// Gets the team icon url. + /// + string IconUrl { get; } + /// + /// Gets the team unique identifier. + /// + ulong Id { get; } + /// + /// Gets the members of this team. + /// + IReadOnlyList TeamMembers { get; } + /// + /// Gets the name of this team. + /// + string Name { get; } + /// + /// Gets the user identifier that owns this team. + /// + ulong OwnerUserId { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Teams/ITeamMember.cs b/src/Discord.Net.Core/Entities/Teams/ITeamMember.cs new file mode 100644 index 0000000..fe0e499 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Teams/ITeamMember.cs @@ -0,0 +1,25 @@ +namespace Discord +{ + /// + /// Represents a Discord Team member. + /// + public interface ITeamMember + { + /// + /// Gets the membership state of this team member. + /// + MembershipState MembershipState { get; } + /// + /// Gets the permissions of this team member. + /// + string[] Permissions { get; } + /// + /// Gets the team unique identifier for this team member. + /// + ulong TeamId { get; } + /// + /// Gets the Discord user of this team member. + /// + IUser User { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Teams/MembershipState.cs b/src/Discord.Net.Core/Entities/Teams/MembershipState.cs new file mode 100644 index 0000000..45b1693 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Teams/MembershipState.cs @@ -0,0 +1,11 @@ +namespace Discord +{ + /// + /// Represents the membership state of a team member. + /// + public enum MembershipState + { + Invited, + Accepted, + } +} diff --git a/src/Discord.Net.Core/Entities/Users/AddGuildUserProperties.cs b/src/Discord.Net.Core/Entities/Users/AddGuildUserProperties.cs new file mode 100644 index 0000000..03177de --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/AddGuildUserProperties.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Properties that are used to add a new to the guild with the following parameters. + /// + /// + public class AddGuildUserProperties + { + /// + /// Gets or sets the user's nickname. + /// + /// + /// To clear the user's nickname, this value can be set to or + /// . + /// + public Optional Nickname { get; set; } + /// + /// Gets or sets whether the user should be muted in a voice channel. + /// + /// + /// If this value is set to , no user will be able to hear this user speak in the guild. + /// + public Optional Mute { get; set; } + /// + /// Gets or sets whether the user should be deafened in a voice channel. + /// + /// + /// If this value is set to , this user will not be able to hear anyone speak in the guild. + /// + public Optional Deaf { get; set; } + /// + /// Gets or sets the roles the user should have. + /// + /// + /// + /// To add a role to a user: + /// + /// + /// + /// To remove a role from a user: + /// + /// + /// + public Optional> Roles { get; set; } + /// + /// Gets or sets the roles the user should have. + /// + /// + /// + /// To add a role to a user: + /// + /// + /// + /// To remove a role from a user: + /// + /// + /// + public Optional> RoleIds { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/ClientType.cs b/src/Discord.Net.Core/Entities/Users/ClientType.cs new file mode 100644 index 0000000..d4afe39 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/ClientType.cs @@ -0,0 +1,21 @@ +namespace Discord +{ + /// + /// Defines the types of clients a user can be active on. + /// + public enum ClientType + { + /// + /// The user is active using the mobile application. + /// + Mobile, + /// + /// The user is active using the desktop application. + /// + Desktop, + /// + /// The user is active using the web application. + /// + Web + } +} diff --git a/src/Discord.Net.Core/Entities/Users/ConnectionVisibility.cs b/src/Discord.Net.Core/Entities/Users/ConnectionVisibility.cs new file mode 100644 index 0000000..ed041c9 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/ConnectionVisibility.cs @@ -0,0 +1,17 @@ +namespace Discord +{ + /// + /// The visibility of the connected account. + /// + public enum ConnectionVisibility + { + /// + /// Invisible to everyone except the user themselves. + /// + None = 0, + /// + /// Visible to everyone. + /// + Everyone = 1 + } +} diff --git a/src/Discord.Net.Core/Entities/Users/GuildUserFlags.cs b/src/Discord.Net.Core/Entities/Users/GuildUserFlags.cs new file mode 100644 index 0000000..60250f2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/GuildUserFlags.cs @@ -0,0 +1,42 @@ +namespace Discord; + + +/// +/// Represents public flags for a guild member. +/// +public enum GuildUserFlags +{ + /// + /// Member has no flags set. + /// + None = 0, + + /// + /// Member has left and rejoined the guild. + /// + /// + /// Cannot be modified. + /// + DidRejoin = 1 << 0, + + /// + /// Member has completed onboarding. + /// + /// + /// Cannot be modified. + /// + CompletedOnboarding = 1 << 1, + + /// + /// Member bypasses guild verification requirements. + /// + BypassesVerification = 1 << 2, + + /// + /// Member has started onboarding. + /// + /// + /// Cannot be modified. + /// + StartedOnboarding = 1 << 3, +} diff --git a/src/Discord.Net.Core/Entities/Users/GuildUserProperties.cs b/src/Discord.Net.Core/Entities/Users/GuildUserProperties.cs new file mode 100644 index 0000000..8903168 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/GuildUserProperties.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Properties that are used to modify an with the following parameters. + /// + /// + public class GuildUserProperties + { + /// + /// Gets or sets whether the user should be muted in a voice channel. + /// + /// + /// If this value is set to , no user will be able to hear this user speak in the guild. + /// + public Optional Mute { get; set; } + /// + /// Gets or sets whether the user should be deafened in a voice channel. + /// + /// + /// If this value is set to , this user will not be able to hear anyone speak in the guild. + /// + public Optional Deaf { get; set; } + /// + /// Gets or sets the user's nickname. + /// + /// + /// To clear the user's nickname, this value can be set to or + /// . + /// + public Optional Nickname { get; set; } + /// + /// Gets or sets the roles the user should have. + /// + /// + /// + /// To add a role to a user: + /// + /// + /// + /// To remove a role from a user: + /// + /// + /// + public Optional> Roles { get; set; } + /// + /// Gets or sets the roles the user should have. + /// + /// + /// + /// To add a role to a user: + /// + /// + /// + /// To remove a role from a user: + /// + /// + /// + public Optional> RoleIds { get; set; } + /// + /// Moves a user to a voice channel. If , this user will be disconnected from their current voice channel. + /// + /// + /// This user MUST already be in a for this to work. + /// When set, this property takes precedence over . + /// + public Optional Channel { get; set; } + /// + /// Moves a user to a voice channel. Set to to disconnect this user from their current voice channel. + /// + /// + /// This user MUST already be in a for this to work. + /// + public Optional ChannelId { get; set; } + + /// + /// Sets a timestamp how long a user should be timed out for. + /// + /// + /// or a time in the past to clear a currently existing timeout. + /// + public Optional TimedOutUntil { get; set; } + + /// + /// Gets or sets the flags of the guild member. + /// + /// + /// Not all flags can be modified, these are reserved for Discord. + /// + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IConnection.cs b/src/Discord.Net.Core/Entities/Users/IConnection.cs new file mode 100644 index 0000000..124f32e --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IConnection.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; + +namespace Discord +{ + public interface IConnection + { + /// + /// Gets the ID of the connection account. + /// + /// + /// A representing the unique identifier value of this connection. + /// + string Id { get; } + /// + /// Gets the username of the connection account. + /// + /// + /// A string containing the name of this connection. + /// + string Name { get; } + /// + /// Gets the service of the connection (twitch, youtube). + /// + /// + /// A string containing the name of this type of connection. + /// + string Type { get; } + /// + /// Gets whether the connection is revoked. + /// + /// + /// A value which if true indicates that this connection has been revoked, otherwise false. + /// + bool? IsRevoked { get; } + /// + /// Gets a of integration partials. + /// + IReadOnlyCollection Integrations { get; } + /// + /// Gets whether the connection is verified. + /// + bool Verified { get; } + /// + /// Gets whether friend sync is enabled for this connection. + /// + bool FriendSync { get; } + /// + /// Gets whether activities related to this connection will be shown in presence updates. + /// + bool ShowActivity { get; } + /// + /// Visibility of this connection. + /// + ConnectionVisibility Visibility { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IGroupUser.cs b/src/Discord.Net.Core/Entities/Users/IGroupUser.cs new file mode 100644 index 0000000..ecf01f7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IGroupUser.cs @@ -0,0 +1,11 @@ +namespace Discord +{ + /// + /// Represents a Discord user that is in a group. + /// + public interface IGroupUser : IUser, IVoiceState + { + ///// Kicks this user from this group. + //Task KickAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IGuildUser.cs b/src/Discord.Net.Core/Entities/Users/IGuildUser.cs new file mode 100644 index 0000000..7e17c7c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IGuildUser.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic guild user. + /// + public interface IGuildUser : IUser, IVoiceState + { + /// + /// Gets when this user joined the guild. + /// + /// + /// A representing the time of which the user has joined the guild; + /// when it cannot be obtained. + /// + DateTimeOffset? JoinedAt { get; } + /// + /// Gets the displayed name for this user. + /// + /// + /// A string representing the display name of the user; If the nickname is null, this will be the username. + /// + string DisplayName { get; } + /// + /// Gets the nickname for this user. + /// + /// + /// A string representing the nickname of the user; if none is set. + /// + string Nickname { get; } + /// + /// Gets the displayed avatar for this user. + /// + /// + /// The users displayed avatar hash. If the user does not have a guild avatar, this will be the regular avatar. + /// If the user also does not have a regular avatar, this will be . + /// + string DisplayAvatarId { get; } + /// + /// Gets the guild specific avatar for this user. + /// + /// + /// The users guild avatar hash if they have one; otherwise . + /// + string GuildAvatarId { get; } + /// + /// Gets the guild-level permissions for this user. + /// + /// + /// A structure for this user, representing what + /// permissions this user has in the guild. + /// + GuildPermissions GuildPermissions { get; } + + /// + /// Gets the guild for this user. + /// + /// + /// A guild object that this user belongs to. + /// + IGuild Guild { get; } + /// + /// Gets the ID of the guild for this user. + /// + /// + /// An representing the snowflake identifier of the guild that this user belongs to. + /// + ulong GuildId { get; } + /// + /// Gets the date and time for when this user's guild boost began. + /// + /// + /// A for when the user began boosting this guild; if they are not boosting the guild. + /// + DateTimeOffset? PremiumSince { get; } + /// + /// Gets a collection of IDs for the roles that this user currently possesses in the guild. + /// + /// + /// This property returns a read-only collection of the identifiers of the roles that this user possesses. + /// For WebSocket users, a Roles property can be found in place of this property. Due to the REST + /// implementation, only a collection of identifiers can be retrieved instead of the full role objects. + /// + /// + /// A read-only collection of , each representing a snowflake identifier for a role that + /// this user possesses. + /// + IReadOnlyCollection RoleIds { get; } + + /// + /// Whether the user has passed the guild's Membership Screening requirements. + /// + bool? IsPending { get; } + + /// + /// Gets the users position within the role hierarchy. + /// + int Hierarchy { get; } + + /// + /// Gets the date and time that indicates if and for how long a user has been timed out. + /// + /// + /// or a timestamp in the past if the user is not timed out. + /// + /// + /// A indicating how long the user will be timed out for. + /// + DateTimeOffset? TimedOutUntil { get; } + + /// + /// Gets the public flags for this guild member. + /// + GuildUserFlags Flags { get; } + + /// + /// Gets the level permissions granted to this user to a given channel. + /// + /// + /// The following example checks if the current user has the ability to send a message with attachment in + /// this channel; if so, uploads a file via . + /// + /// if (currentUser?.GetPermissions(targetChannel)?.AttachFiles) + /// await targetChannel.SendFileAsync("fortnite.png"); + /// + /// + /// The channel to get the permission from. + /// + /// A structure representing the permissions that a user has in the + /// specified channel. + /// + ChannelPermissions GetPermissions(IGuildChannel channel); + /// + /// Gets the guild-specific avatar URL for this user, if it is set. + /// + /// + /// + /// If you wish to retrieve the display avatar for this user, consider using . + /// + /// + /// The format of the image. + /// The size of the image that matches any power of two, ranging from 16 to 2048. + /// + /// A string representing the user's guild-specific avatar URL; if the user has no guild avatar set. + /// + string GetGuildAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128); + /// + /// Kicks this user from this guild. + /// + /// The reason for the kick which will be recorded in the audit log. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous kick operation. + /// + Task KickAsync(string reason = null, RequestOptions options = null); + /// + /// Modifies this user's properties in this guild. + /// + /// + /// This method modifies the current guild user with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// The delegate containing the properties to modify the user with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + /// + /// Adds the specified role to this user in the guild. + /// + /// The role to be added to the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role addition operation. + /// + Task AddRoleAsync(ulong roleId, RequestOptions options = null); + /// + /// Adds the specified role to this user in the guild. + /// + /// The role to be added to the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role addition operation. + /// + Task AddRoleAsync(IRole role, RequestOptions options = null); + /// + /// Adds the specified to this user in the guild. + /// + /// The roles to be added to the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role addition operation. + /// + Task AddRolesAsync(IEnumerable roleIds, RequestOptions options = null); + /// + /// Adds the specified to this user in the guild. + /// + /// The roles to be added to the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role addition operation. + /// + Task AddRolesAsync(IEnumerable roles, RequestOptions options = null); + /// + /// Removes the specified from this user in the guild. + /// + /// The role to be removed from the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role removal operation. + /// + Task RemoveRoleAsync(ulong roleId, RequestOptions options = null); + /// + /// Removes the specified from this user in the guild. + /// + /// The role to be removed from the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role removal operation. + /// + Task RemoveRoleAsync(IRole role, RequestOptions options = null); + /// + /// Removes the specified from this user in the guild. + /// + /// The roles to be removed from the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role removal operation. + /// + Task RemoveRolesAsync(IEnumerable roleIds, RequestOptions options = null); + /// + /// Removes the specified from this user in the guild. + /// + /// The roles to be removed from the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous role removal operation. + /// + Task RemoveRolesAsync(IEnumerable roles, RequestOptions options = null); + /// + /// Sets a timeout based on provided to this user in the guild. + /// + /// The indicating how long a user should be timed out for. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous timeout creation operation. + /// + Task SetTimeOutAsync(TimeSpan span, RequestOptions options = null); + /// + /// Removes the current timeout from the user in this guild if one exists. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous timeout removal operation. + /// + Task RemoveTimeOutAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IPresence.cs b/src/Discord.Net.Core/Entities/Users/IPresence.cs new file mode 100644 index 0000000..45babf4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IPresence.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents the user's presence status. This may include their online status and their activity. + /// + public interface IPresence + { + /// + /// Gets the current status of this user. + /// + UserStatus Status { get; } + /// + /// Gets the set of clients where this user is currently active. + /// + IReadOnlyCollection ActiveClients { get; } + /// + /// Gets the list of activities that this user currently has available. + /// + IReadOnlyCollection Activities { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/ISelfUser.cs b/src/Discord.Net.Core/Entities/Users/ISelfUser.cs new file mode 100644 index 0000000..4f5da84 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/ISelfUser.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents the logged-in Discord user. + /// + public interface ISelfUser : IUser + { + /// + /// Gets the email associated with this user. + /// + string Email { get; } + /// + /// Indicates whether or not this user has their email verified. + /// + /// + /// if this user's email has been verified; if not. + /// + bool IsVerified { get; } + /// + /// Indicates whether or not this user has MFA enabled on their account. + /// + /// + /// if this user has enabled multi-factor authentication on their account; if not. + /// + bool IsMfaEnabled { get; } + /// + /// Gets the flags that are applied to a user's account. + /// + /// + /// This value is determined by bitwise OR-ing values together. + /// + /// + /// The value of flags for this user. + /// + UserProperties Flags { get; } + /// + /// Gets the type of Nitro subscription that is active on this user's account. + /// + /// + /// This information may only be available with the identify OAuth scope. + /// + /// + /// The type of Nitro subscription the user subscribes to, if any. + /// + PremiumType PremiumType { get; } + /// + /// Gets the user's chosen language option. + /// + /// + /// The IETF language tag of the user's chosen region, if provided. + /// For example, a locale of "English, US" is "en-US", "Chinese (Taiwan)" is "zh-TW", etc. + /// + string Locale { get; } + + /// + /// Modifies the user's properties. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IThreadUser.cs b/src/Discord.Net.Core/Entities/Users/IThreadUser.cs new file mode 100644 index 0000000..81cbf89 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IThreadUser.cs @@ -0,0 +1,30 @@ +using System; + +namespace Discord +{ + /// + /// Represents a Discord thread user. + /// + public interface IThreadUser : IMentionable + { + /// + /// Gets the this user is in. + /// + IThreadChannel Thread { get; } + + /// + /// Gets the timestamp for when this user joined this thread. + /// + DateTimeOffset ThreadJoinedAt { get; } + + /// + /// Gets the guild this thread was created in. + /// + IGuild Guild { get; } + + /// + /// Gets the on the server this thread was created in. + /// + IGuildUser GuildUser { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IUser.cs b/src/Discord.Net.Core/Entities/Users/IUser.cs new file mode 100644 index 0000000..15e7f7a --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IUser.cs @@ -0,0 +1,160 @@ +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic user. + /// + public interface IUser : ISnowflakeEntity, IMentionable, IPresence + { + /// + /// Gets the identifier of this user's avatar. + /// + string AvatarId { get; } + /// + /// Gets the avatar URL for this user, if it is set. + /// + /// + /// + /// If you wish to retrieve the display avatar for this user, consider using . + /// + /// + /// + /// The following example attempts to retrieve the user's current avatar and send it to a channel; if one is + /// not set, a default avatar for this user will be returned instead. + /// + /// + /// The format of the image. + /// The size of the image that matches any power of two, ranging from 16 to 2048. + /// + /// A string representing the user's avatar URL; if the user has no avatar set. + /// + string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128); + /// + /// Gets the default avatar URL for this user. + /// + /// + /// This avatar is auto-generated by Discord and consists of their logo combined with a random background color. + /// + /// The calculation is always done by taking the remainder of this user's divided by 5. + /// + /// + /// + /// A string representing the user's default avatar URL. + /// + string GetDefaultAvatarUrl(); + /// + /// Gets the display avatar URL for this user. + /// + /// + /// This method will return if the user has no avatar set. + /// + /// The format of the image. + /// The size of the image that matches any power of two, ranging from 16 to 2048. + /// + /// A string representing the user's display avatar URL. + /// + string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128); + /// + /// Gets the per-username unique ID for this user. This will return "0000" for users who have migrated to new username system. + /// + string Discriminator { get; } + /// + /// Gets the per-username unique ID for this user. This will return 0 for users who have migrated to new username system. + /// + ushort DiscriminatorValue { get; } + /// + /// Gets a value that indicates whether this user is identified as a bot. + /// + /// + /// This property retrieves a value that indicates whether this user is a registered bot application + /// (indicated by the blue BOT tag within the official chat client). + /// + /// + /// if the user is a bot application; otherwise . + /// + bool IsBot { get; } + /// + /// Gets a value that indicates whether this user is a webhook user. + /// + /// + /// if the user is a webhook; otherwise . + /// + bool IsWebhook { get; } + /// + /// Gets the username for this user. + /// + string Username { get; } + /// + /// Gets the public flags that are applied to this user's account. + /// + /// + /// This value is determined by bitwise OR-ing values together. + /// + /// + /// The value of public flags for this user. + /// + UserProperties? PublicFlags { get; } + + /// + /// Gets the user's display name, if it is set. For bots, this will get the application name. + /// + /// + /// This property will be if user has no display name set. + /// + string GlobalName { get; } + + /// + /// Gets the hash of the avatar decoration. + /// + /// + /// if the user has no avatar decoration set. + /// + string AvatarDecorationHash { get; } + + /// + /// Gets the id of the avatar decoration's SKU. + /// + /// + /// if the user has no avatar decoration set. + /// + ulong? AvatarDecorationSkuId { get; } + + /// + /// Creates the direct message channel of this user. + /// + /// + /// This method is used to obtain or create a channel used to send a direct message. + /// + /// In event that the current user cannot send a message to the target user, a channel can and will + /// still be created by Discord. However, attempting to send a message will yield a + /// with a 403 as its + /// . There are currently no official workarounds by + /// Discord. + /// + /// + /// + /// The following example attempts to send a direct message to the target user and logs the incident should + /// it fail. + /// + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for getting or creating a DM channel. The task result + /// contains the DM channel associated with this user. + /// + Task CreateDMChannelAsync(RequestOptions options = null); + + + /// + /// Gets the URL for user's avatar decoration. + /// + /// + /// if the user has no avatar decoration set. + /// + string GetAvatarDecorationUrl(); + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IVoiceState.cs b/src/Discord.Net.Core/Entities/Users/IVoiceState.cs new file mode 100644 index 0000000..3a1e320 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IVoiceState.cs @@ -0,0 +1,79 @@ +using System; + +namespace Discord +{ + /// + /// Represents a user's voice connection status. + /// + public interface IVoiceState + { + /// + /// Gets a value that indicates whether this user is deafened by the guild. + /// + /// + /// if the user is deafened (i.e. not permitted to listen to or speak to others) by the guild; + /// otherwise . + /// + bool IsDeafened { get; } + /// + /// Gets a value that indicates whether this user is muted (i.e. not permitted to speak via voice) by the + /// guild. + /// + /// + /// if this user is muted by the guild; otherwise . + /// + bool IsMuted { get; } + /// + /// Gets a value that indicates whether this user has marked themselves as deafened. + /// + /// + /// if this user has deafened themselves (i.e. not permitted to listen to or speak to others); otherwise . + /// + bool IsSelfDeafened { get; } + /// + /// Gets a value that indicates whether this user has marked themselves as muted (i.e. not permitted to + /// speak via voice). + /// + /// + /// if this user has muted themselves; otherwise . + /// + bool IsSelfMuted { get; } + /// + /// Gets a value that indicates whether the user is muted by the current user. + /// + /// + /// if the guild is temporarily blocking audio to/from this user; otherwise . + /// + bool IsSuppressed { get; } + /// + /// Gets the voice channel this user is currently in. + /// + /// + /// A generic voice channel object representing the voice channel that the user is currently in; + /// if none. + /// + IVoiceChannel VoiceChannel { get; } + /// + /// Gets the unique identifier for this user's voice session. + /// + string VoiceSessionId { get; } + /// + /// Gets a value that indicates if this user is streaming in a voice channel. + /// + /// + /// if the user is streaming; otherwise . + /// + bool IsStreaming { get; } + /// + /// Gets a value that indicates if the user is videoing in a voice channel. + /// + /// + /// if the user has their camera turned on; otherwise . + /// + bool IsVideoing { get; } + /// + /// Gets the time on which the user requested to speak. + /// + DateTimeOffset? RequestToSpeakTimestamp { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/IWebhookUser.cs b/src/Discord.Net.Core/Entities/Users/IWebhookUser.cs new file mode 100644 index 0000000..7a10c6b --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/IWebhookUser.cs @@ -0,0 +1,9 @@ +namespace Discord +{ + /// Represents a Webhook Discord user. + public interface IWebhookUser : IGuildUser + { + /// Gets the ID of a webhook. + ulong WebhookId { get; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/PremiumType.cs b/src/Discord.Net.Core/Entities/Users/PremiumType.cs new file mode 100644 index 0000000..24165d4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/PremiumType.cs @@ -0,0 +1,26 @@ +namespace Discord +{ + /// + /// Specifies the type of subscription a user is subscribed to. + /// + public enum PremiumType + { + /// + /// No subscription. + /// + None = 0, + /// + /// Nitro Classic subscription. Includes app perks like animated emojis and avatars, but not games. + /// + NitroClassic = 1, + /// + /// Nitro subscription. Includes app perks as well as the games subscription service. + /// + Nitro = 2, + + /// + /// Nitro Basic subscription. Includes app perks like video backgrounds, sending bigger files. + /// + NitroBasic = 3 + } +} diff --git a/src/Discord.Net.Core/Entities/Users/SelfUserProperties.cs b/src/Discord.Net.Core/Entities/Users/SelfUserProperties.cs new file mode 100644 index 0000000..a684af7 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/SelfUserProperties.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + /// + /// Properties that are used to modify the with the specified changes. + /// + /// + public class SelfUserProperties + { + /// + /// Gets or sets the username. + /// + public Optional Username { get; set; } + /// + /// Gets or sets the avatar. + /// + public Optional Avatar { get; set; } + + /// + /// Gets or sets the banner. + /// + public Optional Banner { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Users/UserProperties.cs b/src/Discord.Net.Core/Entities/Users/UserProperties.cs new file mode 100644 index 0000000..aeba6a2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/UserProperties.cs @@ -0,0 +1,78 @@ +using System; + +namespace Discord +{ + [Flags] + public enum UserProperties + { + /// + /// Default value for flags, when none are given to an account. + /// + None = 0, + /// + /// Flag given to users who are a Discord employee. + /// + Staff = 1 << 0, + /// + /// Flag given to users who are owners of a partnered Discord server. + /// + Partner = 1 << 1, + /// + /// Flag given to users in HypeSquad events. + /// + HypeSquadEvents = 1 << 2, + /// + /// Flag given to users who have participated in the bug report program and are level 1. + /// + BugHunterLevel1 = 1 << 3, + /// + /// Flag given to users who are in the HypeSquad House of Bravery. + /// + HypeSquadBravery = 1 << 6, + /// + /// Flag given to users who are in the HypeSquad House of Brilliance. + /// + HypeSquadBrilliance = 1 << 7, + /// + /// Flag given to users who are in the HypeSquad House of Balance. + /// + HypeSquadBalance = 1 << 8, + /// + /// Flag given to users who subscribed to Nitro before games were added. + /// + EarlySupporter = 1 << 9, + /// + /// Flag given to users who are part of a team. + /// + TeamUser = 1 << 10, + /// + /// Flag given to users who represent Discord (System). + /// + System = 1 << 12, + /// + /// Flag given to users who have participated in the bug report program and are level 2. + /// + BugHunterLevel2 = 1 << 14, + /// + /// Flag given to users who are verified bots. + /// + VerifiedBot = 1 << 16, + /// + /// Flag given to users that developed bots and early verified their accounts. + /// + EarlyVerifiedBotDeveloper = 1 << 17, + /// + /// Flag given to users that are discord certified moderators who has give discord's exam. + /// + DiscordCertifiedModerator = 1 << 18, + /// + /// Flag given to bots that use only outgoing webhooks, exclusively. + /// + BotHTTPInteractions = 1 << 19, + + /// + /// Flag given to users that are active developers. + /// + ActiveDeveloper = 1 << 22 + } +} diff --git a/src/Discord.Net.Core/Entities/Users/UserStatus.cs b/src/Discord.Net.Core/Entities/Users/UserStatus.cs new file mode 100644 index 0000000..0903326 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Users/UserStatus.cs @@ -0,0 +1,33 @@ +namespace Discord +{ + /// + /// Defines the available Discord user status. + /// + public enum UserStatus + { + /// + /// The user is offline. + /// + Offline, + /// + /// The user is online. + /// + Online, + /// + /// The user is idle. + /// + Idle, + /// + /// The user is AFK. + /// + AFK, + /// + /// The user is busy. + /// + DoNotDisturb, + /// + /// The user is invisible. + /// + Invisible, + } +} diff --git a/src/Discord.Net.Core/Entities/Webhooks/IWebhook.cs b/src/Discord.Net.Core/Entities/Webhooks/IWebhook.cs new file mode 100644 index 0000000..ba767df --- /dev/null +++ b/src/Discord.Net.Core/Entities/Webhooks/IWebhook.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a webhook object on Discord. + /// + public interface IWebhook : IDeletable, ISnowflakeEntity + { + /// + /// Gets the token of this webhook; if the is . + /// + string Token { get; } + + /// + /// Gets the default name of this webhook. + /// + string Name { get; } + + /// + /// Gets the ID of this webhook's default avatar. + /// + string AvatarId { get; } + + /// + /// Gets the URL to this webhook's default avatar. + /// + string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128); + + /// + /// Gets the channel for this webhook. + /// + IIntegrationChannel Channel { get; } + + /// + /// Gets the ID of the channel for this webhook; for webhooks. + /// + ulong? ChannelId { get; } + + /// + /// Gets the guild owning this webhook. + /// + IGuild Guild { get; } + /// + /// Gets the ID of the guild owning this webhook. + /// + ulong? GuildId { get; } + + /// + /// Gets the user that created this webhook. + /// + IUser Creator { get; } + + /// + /// Gets the ID of the application owning this webhook. + /// + ulong? ApplicationId { get; } + + /// + /// Gets the type of this webhook. + /// + WebhookType Type { get; } + + /// + /// Modifies this webhook. + /// + Task ModifyAsync(Action func, RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Entities/Webhooks/WebhookProperties.cs b/src/Discord.Net.Core/Entities/Webhooks/WebhookProperties.cs new file mode 100644 index 0000000..e5ee4d6 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Webhooks/WebhookProperties.cs @@ -0,0 +1,32 @@ +namespace Discord +{ + /// + /// Properties used to modify an with the specified changes. + /// + /// + public class WebhookProperties + { + /// + /// Gets or sets the default name of the webhook. + /// + public Optional Name { get; set; } + /// + /// Gets or sets the default avatar of the webhook. + /// + public Optional Image { get; set; } + /// + /// Gets or sets the channel for this webhook. + /// + /// + /// This field is not used when authenticated with . + /// + public Optional Channel { get; set; } + /// + /// Gets or sets the channel ID for this webhook. + /// + /// + /// This field is not used when authenticated with . + /// + public Optional ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Core/Entities/Webhooks/WebhookType.cs b/src/Discord.Net.Core/Entities/Webhooks/WebhookType.cs new file mode 100644 index 0000000..af7a6a4 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Webhooks/WebhookType.cs @@ -0,0 +1,25 @@ +namespace Discord; + +/// +/// Represents the type of a webhook. +/// +/// +/// This type is currently unused, and is only returned in audit log responses. +/// +public enum WebhookType +{ + /// + /// An incoming webhook. + /// + Incoming = 1, + + /// + /// A channel follower webhook. + /// + ChannelFollower = 2, + + /// + /// An application (interaction) webhook. + /// + Application = 3, +} diff --git a/src/Discord.Net.Core/Extensions/AsyncEnumerableExtensions.cs b/src/Discord.Net.Core/Extensions/AsyncEnumerableExtensions.cs new file mode 100644 index 0000000..d960762 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/AsyncEnumerableExtensions.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord +{ + /// An extension class for squashing . + /// + /// This set of extension methods will squash an into a + /// single . This is often associated with requests that has a + /// set limit when requesting. + /// + public static class AsyncEnumerableExtensions + { + /// Flattens the specified pages into one asynchronously. + public static async Task> FlattenAsync(this IAsyncEnumerable> source) + { + return await source.Flatten().ToArrayAsync().ConfigureAwait(false); + } + /// Flattens the specified pages into one . + public static IAsyncEnumerable Flatten(this IAsyncEnumerable> source) + { + return source.SelectMany(enumerable => enumerable.ToAsyncEnumerable()); + } + } +} diff --git a/src/Discord.Net.Core/Extensions/AttachmentExtensions.cs b/src/Discord.Net.Core/Extensions/AttachmentExtensions.cs new file mode 100644 index 0000000..6054107 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/AttachmentExtensions.cs @@ -0,0 +1,15 @@ +namespace Discord +{ + public static class AttachmentExtensions + { + /// + /// The prefix applied to files to indicate that it is a spoiler. + /// + public const string SpoilerPrefix = "SPOILER_"; + /// + /// Gets whether the message's attachments are spoilers or not. + /// + public static bool IsSpoiler(this IAttachment attachment) + => attachment.Filename.StartsWith(SpoilerPrefix); + } +} diff --git a/src/Discord.Net.Core/Extensions/ChannelExtensions.cs b/src/Discord.Net.Core/Extensions/ChannelExtensions.cs new file mode 100644 index 0000000..ff9fc50 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/ChannelExtensions.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public static class ChannelExtensions + { + /// + /// Attempts to get the based off of the channel's interfaces. + /// + /// The channel to get the type of. + /// The of the channel if found, otherwise . + public static ChannelType? GetChannelType(this IChannel channel) + { + switch (channel) + { + case IStageChannel: + return ChannelType.Stage; + + case IThreadChannel thread: + return thread.Type switch + { + ThreadType.NewsThread => ChannelType.NewsThread, + ThreadType.PrivateThread => ChannelType.PrivateThread, + ThreadType.PublicThread => ChannelType.PublicThread, + _ => null, + }; + + case ICategoryChannel: + return ChannelType.Category; + + case IDMChannel: + return ChannelType.DM; + + case IGroupChannel: + return ChannelType.Group; + + case INewsChannel: + return ChannelType.News; + + case IVoiceChannel: + return ChannelType.Voice; + + case ITextChannel: + return ChannelType.Text; + + case IMediaChannel: + return ChannelType.Media; + + case IForumChannel: + return ChannelType.Forum; + } + + return null; + } + } +} diff --git a/src/Discord.Net.Core/Extensions/CollectionExtensions.cs b/src/Discord.Net.Core/Extensions/CollectionExtensions.cs new file mode 100644 index 0000000..f6ba762 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/CollectionExtensions.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace Discord +{ + internal static class CollectionExtensions + { + //public static IReadOnlyCollection ToReadOnlyCollection(this IReadOnlyCollection source) + // => new CollectionWrapper(source, () => source.Count); + public static IReadOnlyCollection ToReadOnlyCollection(this ICollection source) + => new CollectionWrapper(source, () => source.Count); + //public static IReadOnlyCollection ToReadOnlyCollection(this IReadOnlyDictionary source) + // => new CollectionWrapper(source.Select(x => x.Value), () => source.Count); + public static IReadOnlyCollection ToReadOnlyCollection(this IDictionary source) + => new CollectionWrapper(source.Values, () => source.Count); + public static IReadOnlyCollection ToReadOnlyCollection(this IEnumerable query, IReadOnlyCollection source) + => new CollectionWrapper(query, () => source.Count); + public static IReadOnlyCollection ToReadOnlyCollection(this IEnumerable query, Func countFunc) + => new CollectionWrapper(query, countFunc); + } + + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal struct CollectionWrapper : IReadOnlyCollection + { + private readonly IEnumerable _query; + private readonly Func _countFunc; + + //It's okay that this count is affected by race conditions - we're wrapping a concurrent collection and that's to be expected + public int Count => _countFunc(); + + public CollectionWrapper(IEnumerable query, Func countFunc) + { + _query = query; + _countFunc = countFunc; + } + + private string DebuggerDisplay => $"Count = {Count}"; + + public IEnumerator GetEnumerator() => _query.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _query.GetEnumerator(); + } +} diff --git a/src/Discord.Net.Core/Extensions/DiscordClientExtensions.cs b/src/Discord.Net.Core/Extensions/DiscordClientExtensions.cs new file mode 100644 index 0000000..6ebdbac --- /dev/null +++ b/src/Discord.Net.Core/Extensions/DiscordClientExtensions.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord +{ + /// An extension class for the Discord client. + public static class DiscordClientExtensions + { + /// Gets the private channel with the provided ID. + public static async Task GetPrivateChannelAsync(this IDiscordClient client, ulong id) + => await client.GetChannelAsync(id).ConfigureAwait(false) as IPrivateChannel; + + /// Gets the DM channel with the provided ID. + public static async Task GetDMChannelAsync(this IDiscordClient client, ulong id) + => await client.GetPrivateChannelAsync(id).ConfigureAwait(false) as IDMChannel; + /// Gets all available DM channels for the client. + public static async Task> GetDMChannelsAsync(this IDiscordClient client) + => (await client.GetPrivateChannelsAsync().ConfigureAwait(false)).OfType(); + + /// Gets the group channel with the provided ID. + public static async Task GetGroupChannelAsync(this IDiscordClient client, ulong id) + => await client.GetPrivateChannelAsync(id).ConfigureAwait(false) as IGroupChannel; + /// Gets all available group channels for the client. + public static async Task> GetGroupChannelsAsync(this IDiscordClient client) + => (await client.GetPrivateChannelsAsync().ConfigureAwait(false)).OfType(); + + /// Gets the most optimal voice region for the client. + public static async Task GetOptimalVoiceRegionAsync(this IDiscordClient discord) + { + var regions = await discord.GetVoiceRegionsAsync().ConfigureAwait(false); + return regions.FirstOrDefault(x => x.IsOptimal); + } + } +} diff --git a/src/Discord.Net.Core/Extensions/EmbedBuilderExtensions.cs b/src/Discord.Net.Core/Extensions/EmbedBuilderExtensions.cs new file mode 100644 index 0000000..8cc3f1e --- /dev/null +++ b/src/Discord.Net.Core/Extensions/EmbedBuilderExtensions.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord +{ + /// An extension class for building an embed. + public static class EmbedBuilderExtensions + { + /// Adds embed color based on the provided raw value. + public static EmbedBuilder WithColor(this EmbedBuilder builder, uint rawValue) => + builder.WithColor(new Color(rawValue)); + + /// Adds embed color based on the provided RGB value. + public static EmbedBuilder WithColor(this EmbedBuilder builder, byte r, byte g, byte b) => + builder.WithColor(new Color(r, g, b)); + + /// Adds embed color based on the provided RGB value. + /// The argument value is not between 0 to 255. + public static EmbedBuilder WithColor(this EmbedBuilder builder, int r, int g, int b) => + builder.WithColor(new Color(r, g, b)); + + /// Adds embed color based on the provided RGB value. + /// The argument value is not between 0 to 1. + public static EmbedBuilder WithColor(this EmbedBuilder builder, float r, float g, float b) => + builder.WithColor(new Color(r, g, b)); + + /// Fills the embed author field with the provided user's full username and avatar URL. + public static EmbedBuilder WithAuthor(this EmbedBuilder builder, IUser user) => + builder.WithAuthor(user.DiscriminatorValue != 0 ? $"{user.Username}#{user.Discriminator}" : user.Username, user.GetAvatarUrl() ?? user.GetDefaultAvatarUrl()); + + /// Converts a object to a . + /// The embed type is not . + public static EmbedBuilder ToEmbedBuilder(this IEmbed embed) + { + if (embed.Type != EmbedType.Rich) + throw new InvalidOperationException($"Only {nameof(EmbedType.Rich)} embeds may be built."); + + var builder = new EmbedBuilder + { + Author = new EmbedAuthorBuilder + { + Name = embed.Author?.Name, + IconUrl = embed.Author?.IconUrl, + Url = embed.Author?.Url + }, + Color = embed.Color, + Description = embed.Description, + Footer = new EmbedFooterBuilder + { + Text = embed.Footer?.Text, + IconUrl = embed.Footer?.IconUrl + }, + ImageUrl = embed.Image?.Url, + ThumbnailUrl = embed.Thumbnail?.Url, + Timestamp = embed.Timestamp, + Title = embed.Title, + Url = embed.Url + }; + + foreach (var field in embed.Fields) + builder.AddField(field.Name, field.Value, field.Inline); + + return builder; + } + + /// + /// Adds the specified fields into this . + /// + /// Field count exceeds . + public static EmbedBuilder WithFields(this EmbedBuilder builder, IEnumerable fields) + { + foreach (var field in fields) + builder.AddField(field); + + return builder; + } + /// + /// Adds the specified fields into this . + /// + public static EmbedBuilder WithFields(this EmbedBuilder builder, params EmbedFieldBuilder[] fields) + => WithFields(builder, fields.AsEnumerable()); + } +} diff --git a/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs b/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs new file mode 100644 index 0000000..75d81d2 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/GenericCollectionExtensions.cs @@ -0,0 +1,15 @@ +using System.Linq; + +namespace System.Collections.Generic; + +internal static class GenericCollectionExtensions +{ + public static void Deconstruct(this KeyValuePair kvp, out T1 value1, out T2 value2) + { + value1 = kvp.Key; + value2 = kvp.Value; + } + + public static Dictionary ToDictionary(this IEnumerable> kvp) => + kvp.ToDictionary(x => x.Key, x => x.Value); +} diff --git a/src/Discord.Net.Core/Extensions/GuildExtensions.cs b/src/Discord.Net.Core/Extensions/GuildExtensions.cs new file mode 100644 index 0000000..9dd8de8 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/GuildExtensions.cs @@ -0,0 +1,40 @@ +namespace Discord +{ + /// + /// An extension class for . + /// + public static class GuildExtensions + { + /// + /// Gets if welcome system messages are enabled. + /// + /// The guild to check. + /// A bool indicating if the welcome messages are enabled in the system channel. + public static bool GetWelcomeMessagesEnabled(this IGuild guild) + => !guild.SystemChannelFlags.HasFlag(SystemChannelMessageDeny.WelcomeMessage); + + /// + /// Gets if guild boost system messages are enabled. + /// + /// The guild to check. + /// A bool indicating if the guild boost messages are enabled in the system channel. + public static bool GetGuildBoostMessagesEnabled(this IGuild guild) + => !guild.SystemChannelFlags.HasFlag(SystemChannelMessageDeny.GuildBoost); + + /// + /// Gets if guild setup system messages are enabled. + /// + /// The guild to check. + /// A bool indicating if the guild setup messages are enabled in the system channel. + public static bool GetGuildSetupTipMessagesEnabled(this IGuild guild) + => !guild.SystemChannelFlags.HasFlag(SystemChannelMessageDeny.GuildSetupTip); + + /// + /// Gets if guild welcome messages have a reply with sticker button. + /// + /// The guild to check. + /// A bool indicating if the guild welcome messages have a reply with sticker button. + public static bool GetGuildWelcomeMessageReplyEnabled(this IGuild guild) + => !guild.SystemChannelFlags.HasFlag(SystemChannelMessageDeny.WelcomeMessageReply); + } +} diff --git a/src/Discord.Net.Core/Extensions/GuildOnboardingExtensions.cs b/src/Discord.Net.Core/Extensions/GuildOnboardingExtensions.cs new file mode 100644 index 0000000..d42f7a1 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/GuildOnboardingExtensions.cs @@ -0,0 +1,39 @@ +using System.Linq; + +namespace Discord; + +public static class GuildOnboardingExtensions +{ + public static GuildOnboardingProperties ToProperties(this IGuildOnboarding onboarding) + => new () + { + ChannelIds = onboarding.DefaultChannelIds.ToArray(), + IsEnabled = onboarding.IsEnabled, + Mode = onboarding.Mode, + Prompts = onboarding.Prompts.Select(x => x.ToProperties()).ToArray(), + }; + + public static GuildOnboardingPromptProperties ToProperties(this IGuildOnboardingPrompt prompt) + => new() + { + Id = prompt.Id, + Type = prompt.Type, + IsInOnboarding = prompt.IsInOnboarding, + IsRequired = prompt.IsRequired, + IsSingleSelect = prompt.IsSingleSelect, + Title = prompt.Title, + Options = prompt.Options.Select(x => x.ToProperties()).ToArray() + }; + + public static GuildOnboardingPromptOptionProperties ToProperties(this IGuildOnboardingPromptOption option) + => new() + { + Title = option.Title, + ChannelIds = option.ChannelIds.ToArray(), + Description = option.Description, + Emoji = Optional.Create(option.Emoji), + Id = option.Id, + RoleIds = option.RoleIds.ToArray(), + }; + +} diff --git a/src/Discord.Net.Core/Extensions/MessageExtensions.cs b/src/Discord.Net.Core/Extensions/MessageExtensions.cs new file mode 100644 index 0000000..0f8dbea --- /dev/null +++ b/src/Discord.Net.Core/Extensions/MessageExtensions.cs @@ -0,0 +1,100 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Provides extension methods for . + /// + public static class MessageExtensions + { + /// + /// Gets a URL that jumps to the message. + /// + /// The message to jump to. + /// + /// A string that contains a URL for jumping to the message in chat. + /// + public static string GetJumpUrl(this IMessage msg) + { + var channel = msg.Channel; + return $"https://discord.com/channels/{(channel is IDMChannel ? "@me" : $"{(channel as ITextChannel).GuildId}")}/{channel.Id}/{msg.Id}"; + } + + /// + /// Add multiple reactions to a message. + /// + /// + /// This method does not bulk add reactions! It will send a request for each reaction included. + /// + /// + /// + /// IEmote A = new Emoji("🅰"); + /// IEmote B = new Emoji("🅱"); + /// await msg.AddReactionsAsync(new[] { A, B }); + /// + /// + /// The message to add reactions to. + /// An array of reactions to add to the message. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for adding a reaction to this message. + /// + /// + /// + public static async Task AddReactionsAsync(this IUserMessage msg, IEnumerable reactions, RequestOptions options = null) + { + foreach (var rxn in reactions) + await msg.AddReactionAsync(rxn, options).ConfigureAwait(false); + } + /// + /// Remove multiple reactions from a message. + /// + /// + /// This method does not bulk remove reactions! If you want to clear reactions from a message, + /// + /// + /// + /// + /// await msg.RemoveReactionsAsync(currentUser, new[] { A, B }); + /// + /// + /// The message to remove reactions from. + /// The user who removed the reaction. + /// An array of reactions to remove from the message. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation for removing a reaction to this message. + /// + /// + /// + public static async Task RemoveReactionsAsync(this IUserMessage msg, IUser user, IEnumerable reactions, RequestOptions options = null) + { + foreach (var rxn in reactions) + await msg.RemoveReactionAsync(rxn, user, options).ConfigureAwait(false); + } + + /// + /// Sends an inline reply that references a message. + /// + /// The message that is being replied on. + /// The message to be sent. + /// Determines whether the message should be read aloud by Discord or not. + /// The to be sent. + /// A array of s to send with this response. Max 10. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The options to be used when sending the request. + /// The message components to be included with this message. Used for interactions. + /// A collection of stickers to send with the message. + /// Message flags combined as a bitfield. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public static Task ReplyAsync(this IUserMessage msg, string text = null, bool isTTS = false, Embed embed = null, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => msg.Channel.SendMessageAsync(text, isTTS, embed, options, allowedMentions, new MessageReference(messageId: msg.Id), components, stickers, embeds, flags); + } +} diff --git a/src/Discord.Net.Core/Extensions/ObjectExtensions.cs b/src/Discord.Net.Core/Extensions/ObjectExtensions.cs new file mode 100644 index 0000000..240fb47 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/ObjectExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + internal static class ObjectExtensions + { + public static bool IsNumericType(this object o) + { + switch (Type.GetTypeCode(o.GetType())) + { + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.Decimal: + case TypeCode.Double: + case TypeCode.Single: + return true; + default: + return false; + } + } + } +} diff --git a/src/Discord.Net.Core/Extensions/TaskCompletionSourceExtensions.cs b/src/Discord.Net.Core/Extensions/TaskCompletionSourceExtensions.cs new file mode 100644 index 0000000..d0247c8 --- /dev/null +++ b/src/Discord.Net.Core/Extensions/TaskCompletionSourceExtensions.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + internal static class TaskCompletionSourceExtensions + { + public static Task SetResultAsync(this TaskCompletionSource source, T result) + => Task.Run(() => source.SetResult(result)); + public static Task TrySetResultAsync(this TaskCompletionSource source, T result) + => Task.Run(() => source.TrySetResult(result)); + + public static Task SetExceptionAsync(this TaskCompletionSource source, Exception ex) + => Task.Run(() => source.SetException(ex)); + public static Task TrySetExceptionAsync(this TaskCompletionSource source, Exception ex) + => Task.Run(() => source.TrySetException(ex)); + + public static Task SetCanceledAsync(this TaskCompletionSource source) + => Task.Run(() => source.SetCanceled()); + public static Task TrySetCanceledAsync(this TaskCompletionSource source) + => Task.Run(() => source.TrySetCanceled()); + } +} diff --git a/src/Discord.Net.Core/Extensions/UserExtensions.cs b/src/Discord.Net.Core/Extensions/UserExtensions.cs new file mode 100644 index 0000000..ca081ea --- /dev/null +++ b/src/Discord.Net.Core/Extensions/UserExtensions.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord +{ + /// An extension class for various Discord user objects. + public static class UserExtensions + { + /// + /// Sends a message via DM. + /// + /// + /// This method attempts to send a direct-message to the user. + /// + /// + /// Please note that this method will throw an + /// if the user cannot receive DMs due to privacy reasons or if the user has the sender blocked. + /// + /// + /// You may want to consider catching for + /// 50007 when using this method. + /// + /// + /// + /// The user to send the DM to. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// + /// Specifies if notifications are sent for mentioned users and roles in the message . + /// If , all mentioned roles and users will be notified. + /// + /// The message components to be included with this message. Used for interactions. + /// A array of s to send with this response. Max 10. + /// + /// A task that represents the asynchronous send operation. The task result contains the sent message. + /// + public static async Task SendMessageAsync(this IUser user, + string text = null, + bool isTTS = false, + Embed embed = null, + RequestOptions options = null, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed[] embeds = null) + { + return await (await user.CreateDMChannelAsync().ConfigureAwait(false)).SendMessageAsync(text, isTTS, embed, options, allowedMentions, components: components, embeds: embeds).ConfigureAwait(false); + } + + /// + /// Sends a file to this message channel with an optional caption. + /// + /// + /// The following example uploads a streamed image that will be called b1nzy.jpg embedded inside a + /// rich embed to the channel. + /// + /// await channel.SendFileAsync(b1nzyStream, "b1nzy.jpg", + /// embed: new EmbedBuilder {ImageUrl = "attachment://b1nzy.jpg"}.Build()); + /// + /// + /// + /// This method attempts to send an attachment as a direct-message to the user. + /// + /// + /// Please note that this method will throw an + /// if the user cannot receive DMs due to privacy reasons or if the user has the sender blocked. + /// + /// + /// You may want to consider catching for + /// 50007 when using this method. + /// + /// + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// The user to send the DM to. + /// The of the file to be sent. + /// The name of the attachment. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// The message component to be included with this message. Used for interactions. + /// A array of s to send with this response. Max 10. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public static async Task SendFileAsync(this IUser user, + Stream stream, + string filename, + string text = null, + bool isTTS = false, + Embed embed = null, + RequestOptions options = null, + MessageComponent components = null, + Embed[] embeds = null) + { + return await (await user.CreateDMChannelAsync().ConfigureAwait(false)).SendFileAsync(stream, filename, text, isTTS, embed, options, components: components, embeds: embeds).ConfigureAwait(false); + } + + /// + /// Sends a file via DM with an optional caption. + /// + /// + /// The following example uploads a local file called wumpus.txt along with the text + /// good discord boi to the channel. + /// + /// await channel.SendFileAsync("wumpus.txt", "good discord boi"); + /// + /// + /// The following example uploads a local image called b1nzy.jpg embedded inside a rich embed to the + /// channel. + /// + /// await channel.SendFileAsync("b1nzy.jpg", + /// embed: new EmbedBuilder {ImageUrl = "attachment://b1nzy.jpg"}.Build()); + /// + /// + /// + /// This method attempts to send an attachment as a direct-message to the user. + /// + /// + /// Please note that this method will throw an + /// if the user cannot receive DMs due to privacy reasons or if the user has the sender blocked. + /// + /// + /// You may want to consider catching for + /// 50007 when using this method. + /// + /// + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// The user to send the DM to. + /// The file path of the file. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// The message component to be included with this message. Used for interactions. + /// A array of s to send with this response. Max 10. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public static async Task SendFileAsync(this IUser user, + string filePath, + string text = null, + bool isTTS = false, + Embed embed = null, + RequestOptions options = null, + MessageComponent components = null, + Embed[] embeds = null) + { + return await (await user.CreateDMChannelAsync().ConfigureAwait(false)).SendFileAsync(filePath, text, isTTS, embed, options, components: components, embeds: embeds).ConfigureAwait(false); + } + + /// + /// Sends a file via DM with an optional caption. + /// + /// + /// This method attempts to send an attachment as a direct-message to the user. + /// + /// + /// Please note that this method will throw an + /// if the user cannot receive DMs due to privacy reasons or if the user has the sender blocked. + /// + /// + /// You may want to consider catching for + /// 50007 when using this method. + /// + /// + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// The user to send the DM to. + /// The attachment containing the file and description. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// The message component to be included with this message. Used for interactions. + /// A array of s to send with this response. Max 10. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public static async Task SendFileAsync(this IUser user, + FileAttachment attachment, + string text = null, + bool isTTS = false, + Embed embed = null, + RequestOptions options = null, + MessageComponent components = null, + Embed[] embeds = null) + { + return await (await user.CreateDMChannelAsync().ConfigureAwait(false)).SendFileAsync(attachment, text, isTTS, embed, options, components: components, embeds: embeds).ConfigureAwait(false); + } + + /// + /// Sends a collection of files via DM. + /// + /// + /// This method attempts to send an attachments as a direct-message to the user. + /// + /// + /// Please note that this method will throw an + /// if the user cannot receive DMs due to privacy reasons or if the user has the sender blocked. + /// + /// + /// You may want to consider catching for + /// 50007 when using this method. + /// + /// + /// + /// If you wish to upload an image and have it embedded in a embed, + /// you may upload the file and refer to the file with "attachment://filename.ext" in the + /// . See the example section for its usage. + /// + /// + /// The user to send the DM to. + /// A collection of attachments to upload. + /// The message to be sent. + /// Whether the message should be read aloud by Discord or not. + /// The to be sent. + /// The options to be used when sending the request. + /// The message component to be included with this message. Used for interactions. + /// A array of s to send with this response. Max 10. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public static async Task SendFilesAsync(this IUser user, + IEnumerable attachments, + string text = null, + bool isTTS = false, + Embed embed = null, + RequestOptions options = null, + MessageComponent components = null, + Embed[] embeds = null) + { + return await (await user.CreateDMChannelAsync().ConfigureAwait(false)).SendFilesAsync(attachments, text, isTTS, embed, options, components: components, embeds: embeds).ConfigureAwait(false); + } + + /// + /// Bans the user from the guild and optionally prunes their recent messages. + /// + /// The user to ban. + /// The number of days to remove messages from this for - must be between [0, 7] + /// The reason of the ban to be written in the audit log. + /// The options to be used when sending the request. + /// is not between 0 to 7. + /// + /// A task that represents the asynchronous operation for banning a user. + /// + public static Task BanAsync(this IGuildUser user, int pruneDays = 0, string reason = null, RequestOptions options = null) + => user.Guild.AddBanAsync(user, pruneDays, reason, options); + } +} diff --git a/src/Discord.Net.Core/Format.cs b/src/Discord.Net.Core/Format.cs new file mode 100644 index 0000000..04e811c --- /dev/null +++ b/src/Discord.Net.Core/Format.cs @@ -0,0 +1,125 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace Discord +{ + /// A helper class for formatting characters. + public static class Format + { + // Characters which need escaping + private static readonly string[] SensitiveCharacters = { + "\\", "*", "_", "~", "`", ".", ":", "/", ">", "|" }; + + /// Returns a markdown-formatted string with bold formatting. + public static string Bold(string text) => $"**{text}**"; + /// Returns a markdown-formatted string with italics formatting. + public static string Italics(string text) => $"*{text}*"; + /// Returns a markdown-formatted string with underline formatting. + public static string Underline(string text) => $"__{text}__"; + /// Returns a markdown-formatted string with strike-through formatting. + public static string Strikethrough(string text) => $"~~{text}~~"; + /// Returns a string with spoiler formatting. + public static string Spoiler(string text) => $"||{text}||"; + /// Returns a markdown-formatted URL. Only works in descriptions and fields. + public static string Url(string text, string url) => $"[{text}]({url})"; + /// Escapes a URL so that a preview is not generated. + public static string EscapeUrl(string url) => $"<{url}>"; + + /// Returns a markdown-formatted string with codeblock formatting. + public static string Code(string text, string language = null) + { + if (language != null || text.Contains("\n")) + return $"```{language ?? ""}\n{text}\n```"; + else + return $"`{text}`"; + } + + /// Sanitizes the string, safely escaping any Markdown sequences. + public static string Sanitize(string text) + { + if (text != null) + foreach (string unsafeChar in SensitiveCharacters) + text = text.Replace(unsafeChar, $"\\{unsafeChar}"); + return text; + } + + /// + /// Formats a string as a quote. + /// + /// The text to format. + /// Gets the formatted quote text. + public static string Quote(string text) + { + // do not modify null or whitespace text + // whitespace does not get quoted properly + if (string.IsNullOrWhiteSpace(text)) + return text; + + StringBuilder result = new StringBuilder(); + + int startIndex = 0; + int newLineIndex; + do + { + newLineIndex = text.IndexOf('\n', startIndex); + if (newLineIndex == -1) + { + // read the rest of the string + var str = text.Substring(startIndex); + result.Append($"> {str}"); + } + else + { + // read until the next newline + var str = text.Substring(startIndex, newLineIndex - startIndex); + result.Append($"> {str}\n"); + } + startIndex = newLineIndex + 1; + } + while (newLineIndex != -1 && startIndex != text.Length); + + return result.ToString(); + } + + /// + /// Formats a string as a block quote. + /// + /// The text to format. + /// Gets the formatted block quote text. + public static string BlockQuote(string text) + { + // do not modify null or whitespace + if (string.IsNullOrWhiteSpace(text)) + return text; + + return $">>> {text}"; + } + + /// + /// Remove discord supported markdown from text. + /// + /// The to remove markdown from. + /// Gets the unformatted text. + public static string StripMarkDown(string text) + { + //Remove discord supported markdown + var newText = Regex.Replace(text, @"(\*|_|`|~|>|\\)", ""); + return newText; + } + + /// + /// Formats a user's username and optional discriminator. + /// + /// To format the string in bidirectional unicode or not + /// The user whose username and discriminator to format + /// The username + optional discriminator. + public static string UsernameAndDiscriminator(IUser user, bool doBidirectional) + { + if (user.DiscriminatorValue != 0) + return doBidirectional + ? $"\u2066{user.Username}\u2069#{user.Discriminator}" + : $"{user.Username}#{user.Discriminator}"; + return user.Username; + } + } +} diff --git a/src/Discord.Net.Core/GatewayIntents.cs b/src/Discord.Net.Core/GatewayIntents.cs new file mode 100644 index 0000000..b7979af --- /dev/null +++ b/src/Discord.Net.Core/GatewayIntents.cs @@ -0,0 +1,75 @@ +using System; + +namespace Discord +{ + [Flags] + public enum GatewayIntents + { + /// This intent includes no events + None = 0, + /// This intent includes GUILD_CREATE, GUILD_UPDATE, GUILD_DELETE, GUILD_ROLE_CREATE, GUILD_ROLE_UPDATE, GUILD_ROLE_DELETE, CHANNEL_CREATE, CHANNEL_UPDATE, CHANNEL_DELETE, CHANNEL_PINS_UPDATE + Guilds = 1 << 0, + /// This intent includes GUILD_MEMBER_ADD, GUILD_MEMBER_UPDATE, GUILD_MEMBER_REMOVE + /// This is a privileged intent and must be enabled in the Developer Portal. + GuildMembers = 1 << 1, + /// This intent includes GUILD_BAN_ADD, GUILD_BAN_REMOVE + GuildBans = 1 << 2, + /// This intent includes GUILD_EMOJIS_UPDATE + GuildEmojis = 1 << 3, + /// This intent includes GUILD_INTEGRATIONS_UPDATE + GuildIntegrations = 1 << 4, + /// This intent includes WEBHOOKS_UPDATE + GuildWebhooks = 1 << 5, + /// This intent includes INVITE_CREATE, INVITE_DELETE + GuildInvites = 1 << 6, + /// This intent includes VOICE_STATE_UPDATE + GuildVoiceStates = 1 << 7, + /// This intent includes PRESENCE_UPDATE + /// This is a privileged intent and must be enabled in the Developer Portal. + GuildPresences = 1 << 8, + /// This intent includes MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, MESSAGE_DELETE_BULK + GuildMessages = 1 << 9, + /// This intent includes MESSAGE_REACTION_ADD, MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE_ALL, MESSAGE_REACTION_REMOVE_EMOJI + GuildMessageReactions = 1 << 10, + /// This intent includes TYPING_START + GuildMessageTyping = 1 << 11, + /// This intent includes CHANNEL_CREATE, MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, CHANNEL_PINS_UPDATE + DirectMessages = 1 << 12, + /// This intent includes MESSAGE_REACTION_ADD, MESSAGE_REACTION_REMOVE, MESSAGE_REACTION_REMOVE_ALL, MESSAGE_REACTION_REMOVE_EMOJI + DirectMessageReactions = 1 << 13, + /// This intent includes TYPING_START + DirectMessageTyping = 1 << 14, + /// + /// This intent defines if the content within messages received by MESSAGE_CREATE is available or not. + /// This is a privileged intent and needs to be enabled in the developer portal. + /// + MessageContent = 1 << 15, + /// + /// This intent includes GUILD_SCHEDULED_EVENT_CREATE, GUILD_SCHEDULED_EVENT_UPDATE, GUILD_SCHEDULED_EVENT_DELETE, GUILD_SCHEDULED_EVENT_USER_ADD, GUILD_SCHEDULED_EVENT_USER_REMOVE + /// + GuildScheduledEvents = 1 << 16, + + /// + /// This intent includes AUTO_MODERATION_RULE_CREATE, AUTO_MODERATION_RULE_UPDATE, AUTO_MODERATION_RULE_DELETE + /// + AutoModerationConfiguration = 1 << 20, + + /// + /// This intent includes AUTO_MODERATION_ACTION_EXECUTION + /// + AutoModerationActionExecution = 1 << 21, + + /// + /// This intent includes all but , and + /// which are privileged and must be enabled in the Developer Portal. + /// + AllUnprivileged = Guilds | GuildBans | GuildEmojis | GuildIntegrations | GuildWebhooks | GuildInvites | + GuildVoiceStates | GuildMessages | GuildMessageReactions | GuildMessageTyping | DirectMessages | + DirectMessageReactions | DirectMessageTyping | GuildScheduledEvents | AutoModerationConfiguration | + AutoModerationActionExecution, + /// + /// This intent includes all of them, including privileged ones. + /// + All = AllUnprivileged | GuildMembers | GuildPresences | MessageContent + } +} diff --git a/src/Discord.Net.Core/IDiscordClient.cs b/src/Discord.Net.Core/IDiscordClient.cs new file mode 100644 index 0000000..a87a25c --- /dev/null +++ b/src/Discord.Net.Core/IDiscordClient.cs @@ -0,0 +1,353 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic Discord client. + /// + public interface IDiscordClient : IDisposable, IAsyncDisposable + { + /// + /// Gets the current state of connection. + /// + ConnectionState ConnectionState { get; } + /// + /// Gets the currently logged-in user. + /// + ISelfUser CurrentUser { get; } + /// + /// Gets the token type of the logged-in user. + /// + TokenType TokenType { get; } + + /// + /// Starts the connection between Discord and the client.. + /// + /// + /// This method will initialize the connection between the client and Discord. + /// + /// This method will immediately return after it is called, as it will initialize the connection on + /// another thread. + /// + /// + /// + /// A task that represents the asynchronous start operation. + /// + Task StartAsync(); + /// + /// Stops the connection between Discord and the client. + /// + /// + /// A task that represents the asynchronous stop operation. + /// + Task StopAsync(); + + /// + /// Gets a Discord application information for the logged-in user. + /// + /// + /// This method reflects your application information you submitted when creating a Discord application via + /// the Developer Portal. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the application + /// information. + /// + Task GetApplicationInfoAsync(RequestOptions options = null); + + /// + /// Gets a generic channel. + /// + /// + /// + /// var channel = await _client.GetChannelAsync(381889909113225237); + /// if (channel != null && channel is IMessageChannel msgChannel) + /// { + /// await msgChannel.SendMessageAsync($"{msgChannel} is created at {msgChannel.CreatedAt}"); + /// } + /// + /// + /// The snowflake identifier of the channel (e.g. `381889909113225237`). + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the channel associated + /// with the snowflake identifier; when the channel cannot be found. + /// + Task GetChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of private channels opened in this session. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// This method will retrieve all private channels (including direct-message, group channel and such) that + /// are currently opened in this session. + /// + /// This method will not return previously opened private channels outside of the current session! If + /// you have just started the client, this may return an empty collection. + /// + /// + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of private channels that the user currently partakes in. + /// + Task> GetPrivateChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of direct message channels opened in this session. + /// + /// + /// This method returns a collection of currently opened direct message channels. + /// + /// This method will not return previously opened DM channels outside of the current session! If you + /// have just started the client, this may return an empty collection. + /// + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of direct-message channels that the user currently partakes in. + /// + Task> GetDMChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of group channels opened in this session. + /// + /// + /// This method returns a collection of currently opened group channels. + /// + /// This method will not return previously opened group channels outside of the current session! If you + /// have just started the client, this may return an empty collection. + /// + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of group channels that the user currently partakes in. + /// + Task> GetGroupChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + + /// + /// Gets the connections that the user has set up. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of connections. + /// + Task> GetConnectionsAsync(RequestOptions options = null); + + /// + /// Gets a global application command. + /// + /// The id of the command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the application command if found, otherwise + /// . + /// + Task GetGlobalApplicationCommandAsync(ulong id, RequestOptions options = null); + + /// + /// Gets a collection of all global commands. + /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of global + /// application commands. + /// + Task> GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null); + + /// + /// Creates a global application command. + /// + /// The properties to use when creating the command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created application command. + /// + Task CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options = null); + + /// + /// Bulk overwrites all global application commands. + /// + /// A collection of properties to use when creating the commands. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains a collection of application commands that were created. + /// + Task> BulkOverwriteGlobalApplicationCommand(ApplicationCommandProperties[] properties, RequestOptions options = null); + + /// + /// Gets a guild. + /// + /// The guild snowflake identifier. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the guild associated + /// with the snowflake identifier; when the guild cannot be found. + /// + Task GetGuildAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a collection of guilds that the user is currently in. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of guilds that the current user is in. + /// + Task> GetGuildsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Creates a guild for the logged-in user who is in less than 10 active guilds. + /// + /// + /// This method creates a new guild on behalf of the logged-in user. + /// + /// Due to Discord's limitation, this method will only work for users that are in less than 10 guilds. + /// + /// + /// The name of the new guild. + /// The voice region to create the guild with. + /// The icon of the guild. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created guild. + /// + Task CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon = null, RequestOptions options = null); + + /// + /// Gets an invite. + /// + /// The invitation identifier. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the invite information. + /// + Task GetInviteAsync(string inviteId, RequestOptions options = null); + + /// + /// Gets a user. + /// + /// + /// + /// var user = await _client.GetUserAsync(168693960628371456); + /// if (user != null) + /// Console.WriteLine($"{user} is created at {user.CreatedAt}."; + /// + /// + /// The snowflake identifier of the user (e.g. `168693960628371456`). + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the user associated with + /// the snowflake identifier; if the user is not found. + /// + Task GetUserAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + /// + /// Gets a user. + /// + /// + /// + /// var user = await _client.GetUserAsync("Still", "2876"); + /// if (user != null) + /// Console.WriteLine($"{user} is created at {user.CreatedAt}."; + /// + /// + /// The name of the user (e.g. `Still`). + /// The discriminator value of the user (e.g. `2876`). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the user associated with + /// the name and the discriminator; if the user is not found. + /// + Task GetUserAsync(string username, string discriminator, RequestOptions options = null); + + /// + /// Gets a collection of the available voice regions. + /// + /// + /// The following example gets the most optimal voice region from the collection. + /// + /// var regions = await client.GetVoiceRegionsAsync(); + /// var optimalRegion = regions.FirstOrDefault(x => x.IsOptimal); + /// + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// with all of the available voice regions in this session. + /// + Task> GetVoiceRegionsAsync(RequestOptions options = null); + /// + /// Gets a voice region. + /// + /// The identifier of the voice region (e.g. eu-central ). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the voice region + /// associated with the identifier; if the voice region is not found. + /// + Task GetVoiceRegionAsync(string id, RequestOptions options = null); + + /// + /// Gets a webhook available. + /// + /// The identifier of the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a webhook associated + /// with the identifier; if the webhook is not found. + /// + Task GetWebhookAsync(ulong id, RequestOptions options = null); + + /// + /// Gets the recommended shard count as suggested by Discord. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains an + /// that represents the number of shards that should be used with this account. + /// + Task GetRecommendedShardCountAsync(RequestOptions options = null); + + /// + /// Gets the gateway information related to the bot. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a + /// that represents the gateway information related to the bot. + /// + Task GetBotGatewayAsync(RequestOptions options = null); + + /// + /// Creates a test entitlement to a given SKU for a given guild or user. + /// + Task CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options = null); + + /// + /// Deletes a currently-active test entitlement. + /// + Task DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options = null); + + /// + /// Returns all entitlements for a given app, active and expired. + /// + IAsyncEnumerable> GetEntitlementsAsync(int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null); + + /// + /// Returns all SKUs for a given application. + /// + Task> GetSKUsAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Core/Interactions/IInteractionContext.cs b/src/Discord.Net.Core/Interactions/IInteractionContext.cs new file mode 100644 index 0000000..3b5ba52 --- /dev/null +++ b/src/Discord.Net.Core/Interactions/IInteractionContext.cs @@ -0,0 +1,36 @@ +namespace Discord +{ + /// + /// Represents the context of an Interaction. + /// + public interface IInteractionContext + { + /// + /// Gets the client that will be used to handle this interaction. + /// + IDiscordClient Client { get; } + + /// + /// Gets the guild the interaction originated from. + /// + /// + /// Will be if the interaction originated from a DM channel or the interaction was a Context Command interaction. + /// + IGuild Guild { get; } + + /// + /// Gets the channel the interaction originated from. + /// + IMessageChannel Channel { get; } + + /// + /// Gets the user who invoked the interaction event. + /// + IUser User { get; } + + /// + /// Gets the underlying interaction. + /// + IDiscordInteraction Interaction { get; } + } +} diff --git a/src/Discord.Net.Core/Interactions/IRestInteractionContext.cs b/src/Discord.Net.Core/Interactions/IRestInteractionContext.cs new file mode 100644 index 0000000..2aa5156 --- /dev/null +++ b/src/Discord.Net.Core/Interactions/IRestInteractionContext.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public interface IRestInteractionContext : IInteractionContext + { + /// + /// Gets or sets the callback to use when the service has outgoing json for the rest webhook. + /// + /// + /// If this property is the default callback will be used. + /// + Func InteractionResponseCallback { get; } + } +} diff --git a/src/Discord.Net.Core/Interactions/IRouteMatchContainer.cs b/src/Discord.Net.Core/Interactions/IRouteMatchContainer.cs new file mode 100644 index 0000000..f9a3a31 --- /dev/null +++ b/src/Discord.Net.Core/Interactions/IRouteMatchContainer.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a container for temporarily storing CustomId wild card matches of a component. + /// + public interface IRouteMatchContainer + { + /// + /// Gets the collection of captured route segments in this container. + /// + /// + /// A collection of captured route segments. + /// + IEnumerable SegmentMatches { get; } + + /// + /// Sets the property of this container. + /// + /// The collection of captured route segments. + void SetSegmentMatches(IEnumerable segmentMatches); + } +} diff --git a/src/Discord.Net.Core/Interactions/IRouteSegmentMatch.cs b/src/Discord.Net.Core/Interactions/IRouteSegmentMatch.cs new file mode 100644 index 0000000..675bd67 --- /dev/null +++ b/src/Discord.Net.Core/Interactions/IRouteSegmentMatch.cs @@ -0,0 +1,16 @@ +namespace Discord +{ + /// + /// Represents an object for storing a CustomId wild card match. + /// + public interface IRouteSegmentMatch + { + /// + /// Gets the captured value of this wild card match. + /// + /// + /// The value of this wild card. + /// + string Value { get; } + } +} diff --git a/src/Discord.Net.Core/Interactions/RouteSegmentMatch.cs b/src/Discord.Net.Core/Interactions/RouteSegmentMatch.cs new file mode 100644 index 0000000..f1d80cf --- /dev/null +++ b/src/Discord.Net.Core/Interactions/RouteSegmentMatch.cs @@ -0,0 +1,16 @@ +namespace Discord +{ + /// + /// Represents an object for storing a CustomId wild card match. + /// + internal record RouteSegmentMatch : IRouteSegmentMatch + { + /// + public string Value { get; } + + public RouteSegmentMatch(string value) + { + Value = value; + } + } +} diff --git a/src/Discord.Net.Core/Logging/LogManager.cs b/src/Discord.Net.Core/Logging/LogManager.cs new file mode 100644 index 0000000..ecc8ec5 --- /dev/null +++ b/src/Discord.Net.Core/Logging/LogManager.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Logging +{ + internal class LogManager + { + public LogSeverity Level { get; } + private Logger ClientLogger { get; } + + public event Func Message { add { _messageEvent.Add(value); } remove { _messageEvent.Remove(value); } } + private readonly AsyncEvent> _messageEvent = new AsyncEvent>(); + + public LogManager(LogSeverity minSeverity) + { + Level = minSeverity; + ClientLogger = new Logger(this, "Discord"); + } + + public async Task LogAsync(LogSeverity severity, string source, Exception ex) + { + try + { + if (severity <= Level) + await _messageEvent.InvokeAsync(new LogMessage(severity, source, null, ex)).ConfigureAwait(false); + } + catch + { + // ignored + } + } + public async Task LogAsync(LogSeverity severity, string source, string message, Exception ex = null) + { + try + { + if (severity <= Level) + await _messageEvent.InvokeAsync(new LogMessage(severity, source, message, ex)).ConfigureAwait(false); + } + catch + { + // ignored + } + } + + public async Task LogAsync(LogSeverity severity, string source, FormattableString message, Exception ex = null) + { + try + { + if (severity <= Level) + await _messageEvent.InvokeAsync(new LogMessage(severity, source, message.ToString(), ex)).ConfigureAwait(false); + } + catch { } + } + + + public Task ErrorAsync(string source, Exception ex) + => LogAsync(LogSeverity.Error, source, ex); + public Task ErrorAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Error, source, message, ex); + + public Task ErrorAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Error, source, message, ex); + + + public Task WarningAsync(string source, Exception ex) + => LogAsync(LogSeverity.Warning, source, ex); + public Task WarningAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Warning, source, message, ex); + + public Task WarningAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Warning, source, message, ex); + + + public Task InfoAsync(string source, Exception ex) + => LogAsync(LogSeverity.Info, source, ex); + public Task InfoAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Info, source, message, ex); + public Task InfoAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Info, source, message, ex); + + + public Task VerboseAsync(string source, Exception ex) + => LogAsync(LogSeverity.Verbose, source, ex); + public Task VerboseAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Verbose, source, message, ex); + public Task VerboseAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Verbose, source, message, ex); + + + public Task DebugAsync(string source, Exception ex) + => LogAsync(LogSeverity.Debug, source, ex); + public Task DebugAsync(string source, string message, Exception ex = null) + => LogAsync(LogSeverity.Debug, source, message, ex); + public Task DebugAsync(string source, FormattableString message, Exception ex = null) + => LogAsync(LogSeverity.Debug, source, message, ex); + + + public Logger CreateLogger(string name) => new Logger(this, name); + + public Task WriteInitialLog() + => ClientLogger.InfoAsync($"Discord.Net v{DiscordConfig.Version} (API v{DiscordConfig.APIVersion})"); + } +} diff --git a/src/Discord.Net.Core/Logging/LogMessage.cs b/src/Discord.Net.Core/Logging/LogMessage.cs new file mode 100644 index 0000000..08afe79 --- /dev/null +++ b/src/Discord.Net.Core/Logging/LogMessage.cs @@ -0,0 +1,127 @@ +using System; +using System.Text; + +namespace Discord +{ + /// + /// Provides a message object used for logging purposes. + /// + public struct LogMessage + { + /// + /// Gets the severity of the log entry. + /// + /// + /// A enum to indicate the severeness of the incident or event. + /// + public LogSeverity Severity { get; } + /// + /// Gets the source of the log entry. + /// + /// + /// A string representing the source of the log entry. + /// + public string Source { get; } + /// + /// Gets the message of this log entry. + /// + /// + /// A string containing the message of this log entry. + /// + public string Message { get; } + /// + /// Gets the exception of this log entry. + /// + /// + /// An object associated with an incident; otherwise . + /// + public Exception Exception { get; } + + /// + /// Initializes a new struct with the severity, source, message of the event, and + /// optionally, an exception. + /// + /// The severity of the event. + /// The source of the event. + /// The message of the event. + /// The exception of the event. + public LogMessage(LogSeverity severity, string source, string message, Exception exception = null) + { + Severity = severity; + Source = source; + Message = message; + Exception = exception; + } + + public override string ToString() => ToString(); + public string ToString(StringBuilder builder = null, bool fullException = true, bool prependTimestamp = true, DateTimeKind timestampKind = DateTimeKind.Local, int? padSource = 11) + { + string sourceName = Source; + string message = Message; + string exMessage = fullException ? Exception?.ToString() : Exception?.Message; + + int maxLength = 1 + + (prependTimestamp ? 8 : 0) + 1 + + (padSource.HasValue ? padSource.Value : sourceName?.Length ?? 0) + 1 + + (message?.Length ?? 0) + + (exMessage?.Length ?? 0) + 3; + + if (builder == null) + builder = new StringBuilder(maxLength); + else + { + builder.Clear(); + builder.EnsureCapacity(maxLength); + } + + if (prependTimestamp) + { + DateTime now; + if (timestampKind == DateTimeKind.Utc) + now = DateTime.UtcNow; + else + now = DateTime.Now; + string format = "HH:mm:ss"; + builder.Append(now.ToString(format)); + builder.Append(' '); + } + if (sourceName != null) + { + if (padSource.HasValue) + { + if (sourceName.Length < padSource.Value) + { + builder.Append(sourceName); + builder.Append(' ', padSource.Value - sourceName.Length); + } + else if (sourceName.Length > padSource.Value) + builder.Append(sourceName.Substring(0, padSource.Value)); + else + builder.Append(sourceName); + } + builder.Append(' '); + } + if (!string.IsNullOrEmpty(Message)) + { + for (int i = 0; i < message.Length; i++) + { + //Strip control chars + char c = message[i]; + if (!char.IsControl(c)) + builder.Append(c); + } + } + if (exMessage != null) + { + if (!string.IsNullOrEmpty(Message)) + { + builder.Append(':'); + builder.AppendLine(); + } + builder.Append(exMessage); + } + + return builder.ToString(); + } + } +} diff --git a/src/Discord.Net.Core/Logging/LogSeverity.cs b/src/Discord.Net.Core/Logging/LogSeverity.cs new file mode 100644 index 0000000..f9b518c --- /dev/null +++ b/src/Discord.Net.Core/Logging/LogSeverity.cs @@ -0,0 +1,34 @@ +namespace Discord +{ + /// + /// Specifies the severity of the log message. + /// + public enum LogSeverity + { + /// + /// Logs that contain the most severe level of error. This type of error indicate that immediate attention + /// may be required. + /// + Critical = 0, + /// + /// Logs that highlight when the flow of execution is stopped due to a failure. + /// + Error = 1, + /// + /// Logs that highlight an abnormal activity in the flow of execution. + /// + Warning = 2, + /// + /// Logs that track the general flow of the application. + /// + Info = 3, + /// + /// Logs that are used for interactive investigation during development. + /// + Verbose = 4, + /// + /// Logs that contain the most detailed messages. + /// + Debug = 5 + } +} diff --git a/src/Discord.Net.Core/Logging/Logger.cs b/src/Discord.Net.Core/Logging/Logger.cs new file mode 100644 index 0000000..e71c569 --- /dev/null +++ b/src/Discord.Net.Core/Logging/Logger.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Logging +{ + internal class Logger + { + private readonly LogManager _manager; + + public string Name { get; } + public LogSeverity Level => _manager.Level; + + public Logger(LogManager manager, string name) + { + _manager = manager; + Name = name; + } + + public Task LogAsync(LogSeverity severity, Exception exception = null) + => _manager.LogAsync(severity, Name, exception); + public Task LogAsync(LogSeverity severity, string message, Exception exception = null) + => _manager.LogAsync(severity, Name, message, exception); + public Task LogAsync(LogSeverity severity, FormattableString message, Exception exception = null) + => _manager.LogAsync(severity, Name, message, exception); + + + public Task ErrorAsync(Exception exception) + => _manager.ErrorAsync(Name, exception); + public Task ErrorAsync(string message, Exception exception = null) + => _manager.ErrorAsync(Name, message, exception); + + public Task ErrorAsync(FormattableString message, Exception exception = null) + => _manager.ErrorAsync(Name, message, exception); + + + public Task WarningAsync(Exception exception) + => _manager.WarningAsync(Name, exception); + public Task WarningAsync(string message, Exception exception = null) + => _manager.WarningAsync(Name, message, exception); + + public Task WarningAsync(FormattableString message, Exception exception = null) + => _manager.WarningAsync(Name, message, exception); + + + public Task InfoAsync(Exception exception) + => _manager.InfoAsync(Name, exception); + public Task InfoAsync(string message, Exception exception = null) + => _manager.InfoAsync(Name, message, exception); + + public Task InfoAsync(FormattableString message, Exception exception = null) + => _manager.InfoAsync(Name, message, exception); + + + public Task VerboseAsync(Exception exception) + => _manager.VerboseAsync(Name, exception); + public Task VerboseAsync(string message, Exception exception = null) + => _manager.VerboseAsync(Name, message, exception); + + public Task VerboseAsync(FormattableString message, Exception exception = null) + => _manager.VerboseAsync(Name, message, exception); + + + public Task DebugAsync(Exception exception) + => _manager.DebugAsync(Name, exception); + public Task DebugAsync(string message, Exception exception = null) + => _manager.DebugAsync(Name, message, exception); + + public Task DebugAsync(FormattableString message, Exception exception = null) + => _manager.DebugAsync(Name, message, exception); + + } +} diff --git a/src/Discord.Net.Core/LoginState.cs b/src/Discord.Net.Core/LoginState.cs new file mode 100644 index 0000000..49f86c9 --- /dev/null +++ b/src/Discord.Net.Core/LoginState.cs @@ -0,0 +1,15 @@ +namespace Discord +{ + /// Specifies the state of the client's login status. + public enum LoginState : byte + { + /// The client is currently logged out. + LoggedOut, + /// The client is currently logging in. + LoggingIn, + /// The client is currently logged in. + LoggedIn, + /// The client is currently logging out. + LoggingOut + } +} diff --git a/src/Discord.Net.Core/Net/ApplicationCommandException.cs b/src/Discord.Net.Core/Net/ApplicationCommandException.cs new file mode 100644 index 0000000..acc075f --- /dev/null +++ b/src/Discord.Net.Core/Net/ApplicationCommandException.cs @@ -0,0 +1,15 @@ +using System; +using System.Linq; + +namespace Discord.Net +{ + [Obsolete("Please use HttpException instead of this. Will be removed in next major version.", false)] + public class ApplicationCommandException : HttpException + { + public ApplicationCommandException(HttpException httpError) + : base(httpError.HttpCode, httpError.Request, httpError.DiscordCode, httpError.Reason, httpError.Errors.ToArray()) + { + + } + } +} diff --git a/src/Discord.Net.Core/Net/BucketId.cs b/src/Discord.Net.Core/Net/BucketId.cs new file mode 100644 index 0000000..16e2573 --- /dev/null +++ b/src/Discord.Net.Core/Net/BucketId.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Net +{ + /// + /// Represents a ratelimit bucket. + /// + public class BucketId : IEquatable + { + /// + /// Gets the http method used to make the request if available. + /// + public string HttpMethod { get; } + /// + /// Gets the endpoint that is going to be requested if available. + /// + public string Endpoint { get; } + /// + /// Gets the major parameters of the route. + /// + public IOrderedEnumerable> MajorParameters { get; } + /// + /// Gets the hash of this bucket. + /// + /// + /// The hash is provided by Discord to group ratelimits. + /// + public string BucketHash { get; } + /// + /// Gets if this bucket is a hash type. + /// + public bool IsHashBucket { get => BucketHash != null; } + + private BucketId(string httpMethod, string endpoint, IEnumerable> majorParameters, string bucketHash) + { + HttpMethod = httpMethod; + Endpoint = endpoint; + MajorParameters = majorParameters.OrderBy(x => x.Key); + BucketHash = bucketHash; + } + + /// + /// Creates a new based on the + /// and . + /// + /// Http method used to make the request. + /// Endpoint that is going to receive requests. + /// Major parameters of the route of this endpoint. + /// + /// A based on the + /// and the with the provided data. + /// + public static BucketId Create(string httpMethod, string endpoint, Dictionary majorParams) + { + Preconditions.NotNullOrWhitespace(endpoint, nameof(endpoint)); + majorParams ??= new Dictionary(); + return new BucketId(httpMethod, endpoint, majorParams, null); + } + + /// + /// Creates a new based on a + /// and a previous . + /// + /// Bucket hash provided by Discord. + /// that is going to be upgraded to a hash type. + /// + /// A based on the + /// and . + /// + public static BucketId Create(string hash, BucketId oldBucket) + { + Preconditions.NotNullOrWhitespace(hash, nameof(hash)); + Preconditions.NotNull(oldBucket, nameof(oldBucket)); + return new BucketId(null, null, oldBucket.MajorParameters, hash); + } + + /// + /// Gets the string that will define this bucket as a hash based one. + /// + /// + /// A that defines this bucket as a hash based one. + /// + public string GetBucketHash() + => IsHashBucket ? $"{BucketHash}:{string.Join("/", MajorParameters.Select(x => x.Value))}" : null; + + /// + /// Gets the string that will define this bucket as an endpoint based one. + /// + /// + /// A that defines this bucket as an endpoint based one. + /// + public string GetUniqueEndpoint() + => HttpMethod != null ? $"{HttpMethod} {Endpoint}" : Endpoint; + + public override bool Equals(object obj) + => Equals(obj as BucketId); + + public override int GetHashCode() + => IsHashBucket ? (BucketHash, string.Join("/", MajorParameters.Select(x => x.Value))).GetHashCode() : (HttpMethod, Endpoint).GetHashCode(); + + public override string ToString() + => GetBucketHash() ?? GetUniqueEndpoint(); + + public bool Equals(BucketId other) + { + if (other is null) + return false; + if (ReferenceEquals(this, other)) + return true; + if (GetType() != other.GetType()) + return false; + return ToString() == other.ToString(); + } + } +} diff --git a/src/Discord.Net.Core/Net/HttpException.cs b/src/Discord.Net.Core/Net/HttpException.cs new file mode 100644 index 0000000..fc7cec1 --- /dev/null +++ b/src/Discord.Net.Core/Net/HttpException.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Net; + +namespace Discord.Net +{ + /// + /// The exception that is thrown if an error occurs while processing an Discord HTTP request. + /// + public class HttpException : Exception + { + /// + /// Gets the HTTP status code returned by Discord. + /// + /// + /// An + /// HTTP status code + /// from Discord. + /// + public HttpStatusCode HttpCode { get; } + /// + /// Gets the JSON error code returned by Discord. + /// + /// + /// A + /// JSON error code + /// from Discord, or if none. + /// + public DiscordErrorCode? DiscordCode { get; } + /// + /// Gets the reason of the exception. + /// + public string Reason { get; } + /// + /// Gets the request object used to send the request. + /// + public IRequest Request { get; } + /// + /// Gets a collection of json errors describing what went wrong with the request. + /// + public IReadOnlyCollection Errors { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP status code returned. + /// The request that was sent prior to the exception. + /// The Discord status code returned. + /// The reason behind the exception. + public HttpException(HttpStatusCode httpCode, IRequest request, DiscordErrorCode? discordCode = null, string reason = null, DiscordJsonError[] errors = null) + : base(CreateMessage(httpCode, (int?)discordCode, reason, errors)) + { + HttpCode = httpCode; + Request = request; + DiscordCode = discordCode; + Reason = reason; + Errors = errors?.ToImmutableArray() ?? ImmutableArray.Empty; + } + + private static string CreateMessage(HttpStatusCode httpCode, int? discordCode = null, string reason = null, DiscordJsonError[] errors = null) + { + string msg; + if (discordCode != null && discordCode != 0) + { + if (reason != null) + msg = $"The server responded with error {(int)discordCode}: {reason}"; + else + msg = $"The server responded with error {(int)discordCode}: {httpCode}"; + } + else + { + if (reason != null) + msg = $"The server responded with error {(int)httpCode}: {reason}"; + else + msg = $"The server responded with error {(int)httpCode}: {httpCode}"; + } + + if (errors?.Length > 0) + { + msg += "\nInner Errors:"; + foreach (var error in errors) + if (error.Errors?.Count > 0) + foreach (var innerError in error.Errors) + msg += $"\n{innerError.Code}: {innerError.Message}"; + } + + return msg; + } + } +} diff --git a/src/Discord.Net.Core/Net/IRequest.cs b/src/Discord.Net.Core/Net/IRequest.cs new file mode 100644 index 0000000..1f23e65 --- /dev/null +++ b/src/Discord.Net.Core/Net/IRequest.cs @@ -0,0 +1,13 @@ +using System; + +namespace Discord.Net +{ + /// + /// Represents a generic request to be sent to Discord. + /// + public interface IRequest + { + DateTimeOffset? TimeoutAt { get; } + RequestOptions Options { get; } + } +} diff --git a/src/Discord.Net.Core/Net/RateLimitedException.cs b/src/Discord.Net.Core/Net/RateLimitedException.cs new file mode 100644 index 0000000..c19487f --- /dev/null +++ b/src/Discord.Net.Core/Net/RateLimitedException.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Net +{ + /// + /// The exception that is thrown when the user is being rate limited by Discord. + /// + public class RateLimitedException : TimeoutException + { + /// + /// Gets the request object used to send the request. + /// + public IRequest Request { get; } + + /// + /// Initializes a new instance of the class using the + /// sent. + /// + public RateLimitedException(IRequest request) + : base("You are being rate limited.") + { + Request = request; + } + } +} diff --git a/src/Discord.Net.Core/Net/Rest/IRateLimitInfo.cs b/src/Discord.Net.Core/Net/Rest/IRateLimitInfo.cs new file mode 100644 index 0000000..816f25a --- /dev/null +++ b/src/Discord.Net.Core/Net/Rest/IRateLimitInfo.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic ratelimit info. + /// + public interface IRateLimitInfo + { + /// + /// Gets whether or not this ratelimit info is global. + /// + bool IsGlobal { get; } + + /// + /// Gets the number of requests that can be made. + /// + int? Limit { get; } + + /// + /// Gets the number of remaining requests that can be made. + /// + int? Remaining { get; } + + /// + /// Gets the total time (in seconds) of when the current rate limit bucket will reset. Can have decimals to match previous millisecond ratelimit precision. + /// + int? RetryAfter { get; } + + /// + /// Gets the at which the rate limit resets. + /// + DateTimeOffset? Reset { get; } + + /// + /// Gets the absolute time when this ratelimit resets. + /// + TimeSpan? ResetAfter { get; } + + /// + /// Gets a unique string denoting the rate limit being encountered (non-inclusive of major parameters in the route path). + /// + string Bucket { get; } + + /// + /// Gets the amount of lag for the request. This is used to denote the precise time of when the ratelimit expires. + /// + TimeSpan? Lag { get; } + + /// + /// Gets the endpoint that this ratelimit info came from. + /// + string Endpoint { get; } + } +} diff --git a/src/Discord.Net.Core/Net/Rest/IRestClient.cs b/src/Discord.Net.Core/Net/Rest/IRestClient.cs new file mode 100644 index 0000000..d28fb70 --- /dev/null +++ b/src/Discord.Net.Core/Net/Rest/IRestClient.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Rest +{ + /// + /// Represents a generic REST-based client. + /// + public interface IRestClient : IDisposable + { + /// + /// Sets the HTTP header of this client for all requests. + /// + /// The field name of the header. + /// The value of the header. + void SetHeader(string key, string value); + /// + /// Sets the cancellation token for this client. + /// + /// The cancellation token. + void SetCancelToken(CancellationToken cancelToken); + + /// + /// Sends a REST request. + /// + /// The method used to send this request (i.e. HTTP verb such as GET, POST). + /// The endpoint to send this request to. + /// The cancellation token used to cancel the task. + /// Indicates whether to send the header only. + /// The audit log reason. + /// Additional headers to be sent with the request. + /// + Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly = false, string reason = null, + IEnumerable>> requestHeaders = null); + Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly = false, string reason = null, + IEnumerable>> requestHeaders = null); + Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, CancellationToken cancelToken, bool headerOnly = false, string reason = null, + IEnumerable>> requestHeaders = null); + } +} diff --git a/src/Discord.Net.Core/Net/Rest/RestClientProvider.cs b/src/Discord.Net.Core/Net/Rest/RestClientProvider.cs new file mode 100644 index 0000000..719f429 --- /dev/null +++ b/src/Discord.Net.Core/Net/Rest/RestClientProvider.cs @@ -0,0 +1,4 @@ +namespace Discord.Net.Rest +{ + public delegate IRestClient RestClientProvider(string baseUrl); +} diff --git a/src/Discord.Net.Core/Net/Rest/RestResponse.cs b/src/Discord.Net.Core/Net/Rest/RestResponse.cs new file mode 100644 index 0000000..f338fc7 --- /dev/null +++ b/src/Discord.Net.Core/Net/Rest/RestResponse.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.IO; +using System.Net; + +namespace Discord.Net.Rest +{ + public struct RestResponse + { + public HttpStatusCode StatusCode { get; } + public Dictionary Headers { get; } + public Stream Stream { get; } + + public RestResponse(HttpStatusCode statusCode, Dictionary headers, Stream stream) + { + StatusCode = statusCode; + Headers = headers; + Stream = stream; + } + } +} diff --git a/src/Discord.Net.Core/Net/Udp/IUdpSocket.cs b/src/Discord.Net.Core/Net/Udp/IUdpSocket.cs new file mode 100644 index 0000000..b827625 --- /dev/null +++ b/src/Discord.Net.Core/Net/Udp/IUdpSocket.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Udp +{ + public interface IUdpSocket : IDisposable + { + event Func ReceivedDatagram; + + ushort Port { get; } + + void SetCancelToken(CancellationToken cancelToken); + void SetDestination(string ip, int port); + + Task StartAsync(); + Task StopAsync(); + + Task SendAsync(byte[] data, int index, int count); + } +} diff --git a/src/Discord.Net.Core/Net/Udp/UdpSocketProvider.cs b/src/Discord.Net.Core/Net/Udp/UdpSocketProvider.cs new file mode 100644 index 0000000..c6c2bab --- /dev/null +++ b/src/Discord.Net.Core/Net/Udp/UdpSocketProvider.cs @@ -0,0 +1,4 @@ +namespace Discord.Net.Udp +{ + public delegate IUdpSocket UdpSocketProvider(); +} diff --git a/src/Discord.Net.Core/Net/WebSocketClosedException.cs b/src/Discord.Net.Core/Net/WebSocketClosedException.cs new file mode 100644 index 0000000..c743cd6 --- /dev/null +++ b/src/Discord.Net.Core/Net/WebSocketClosedException.cs @@ -0,0 +1,34 @@ +using System; +namespace Discord.Net +{ + /// + /// The exception that is thrown when the WebSocket session is closed by Discord. + /// + public class WebSocketClosedException : Exception + { + /// + /// Gets the close code sent by Discord. + /// + /// + /// A + /// close code + /// from Discord. + /// + public int CloseCode { get; } + /// + /// Gets the reason of the interruption. + /// + public string Reason { get; } + + /// + /// Initializes a new instance of the using a Discord close code + /// and an optional reason. + /// + public WebSocketClosedException(int closeCode, string reason = null) + : base($"The server sent close {closeCode}{(reason != null ? $": \"{reason}\"" : "")}") + { + CloseCode = closeCode; + Reason = reason; + } + } +} diff --git a/src/Discord.Net.Core/Net/WebSockets/IWebSocketClient.cs b/src/Discord.Net.Core/Net/WebSockets/IWebSocketClient.cs new file mode 100644 index 0000000..bc5e8dd --- /dev/null +++ b/src/Discord.Net.Core/Net/WebSockets/IWebSocketClient.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.WebSockets +{ + public interface IWebSocketClient : IDisposable + { + event Func BinaryMessage; + event Func TextMessage; + event Func Closed; + + void SetHeader(string key, string value); + void SetCancelToken(CancellationToken cancelToken); + + Task ConnectAsync(string host); + Task DisconnectAsync(int closeCode = 1000); + + Task SendAsync(byte[] data, int index, int count, bool isText); + } +} diff --git a/src/Discord.Net.Core/Net/WebSockets/WebSocketProvider.cs b/src/Discord.Net.Core/Net/WebSockets/WebSocketProvider.cs new file mode 100644 index 0000000..b0976dc --- /dev/null +++ b/src/Discord.Net.Core/Net/WebSockets/WebSocketProvider.cs @@ -0,0 +1,4 @@ +namespace Discord.Net.WebSockets +{ + public delegate IWebSocketClient WebSocketProvider(); +} diff --git a/src/Discord.Net.Core/RequestOptions.cs b/src/Discord.Net.Core/RequestOptions.cs new file mode 100644 index 0000000..489fe2e --- /dev/null +++ b/src/Discord.Net.Core/RequestOptions.cs @@ -0,0 +1,107 @@ +using Discord.Net; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents options that should be used when sending a request. + /// + public class RequestOptions + { + /// + /// Creates a new class with its default settings. + /// + public static RequestOptions Default => new RequestOptions(); + + /// + /// Gets or sets the maximum time to wait for this request to complete. + /// + /// + /// Gets or set the max time, in milliseconds, to wait for this request to complete. If + /// , a request will not time out. If a rate limit has been triggered for this request's bucket + /// and will not be unpaused in time, this request will fail immediately. + /// + /// + /// A in milliseconds for when the request times out. + /// + public int? Timeout { get; set; } + /// + /// Gets or sets the cancellation token for this request. + /// + /// + /// A for this request. + /// + public CancellationToken CancelToken { get; set; } = CancellationToken.None; + /// + /// Gets or sets the retry behavior when the request fails. + /// + public RetryMode? RetryMode { get; set; } + public bool HeaderOnly { get; internal set; } + /// + /// Gets or sets the reason for this action in the guild's audit log. + /// + /// + /// Gets or sets the reason that will be written to the guild's audit log if applicable. This may not apply + /// to all actions. + /// + public string AuditLogReason { get; set; } + /// + /// Gets or sets whether or not this request should use the system + /// clock for rate-limiting. Defaults to . + /// + /// + /// This property can also be set in . + /// On a per-request basis, the system clock should only be disabled + /// when millisecond precision is especially important, and the + /// hosting system is known to have a desynced clock. + /// + public bool? UseSystemClock { get; set; } + + /// + /// Gets or sets the callback to execute regarding ratelimits for this request. + /// + public Func RatelimitCallback { get; set; } + + internal bool IgnoreState { get; set; } + internal BucketId BucketId { get; set; } + internal bool IsClientBucket { get; set; } + internal bool IsReactionBucket { get; set; } + internal bool IsGatewayBucket { get; set; } + + internal IDictionary> RequestHeaders { get; } + + internal static RequestOptions CreateOrClone(RequestOptions options) + { + if (options == null) + return new RequestOptions(); + else + return options.Clone(); + } + + internal void ExecuteRatelimitCallback(IRateLimitInfo info) + { + if (RatelimitCallback != null) + { + _ = Task.Run(async () => + { + await RatelimitCallback(info); + }); + } + } + + /// + /// Initializes a new class with the default request timeout set in + /// . + /// + public RequestOptions() + { + Timeout = DiscordConfig.DefaultRequestTimeout; + RequestHeaders = new Dictionary>(); + } + + public RequestOptions Clone() => MemberwiseClone() as RequestOptions; + } +} diff --git a/src/Discord.Net.Core/RetryMode.cs b/src/Discord.Net.Core/RetryMode.cs new file mode 100644 index 0000000..1e09f4d --- /dev/null +++ b/src/Discord.Net.Core/RetryMode.cs @@ -0,0 +1,22 @@ +using System; + +namespace Discord +{ + /// Specifies how a request should act in the case of an error. + [Flags] + public enum RetryMode + { + /// If a request fails, an exception is thrown immediately. + AlwaysFail = 0x0, + /// Retry if a request timed out. + RetryTimeouts = 0x1, + // /// Retry if a request failed due to a network error. + //RetryErrors = 0x2, + /// Retry if a request failed due to a rate-limit. + RetryRatelimit = 0x4, + /// Retry if a request failed due to an HTTP error 502. + Retry502 = 0x8, + /// Continuously retry a request until it times out, its cancel token is triggered, or the server responds with a non-502 error. + AlwaysRetry = RetryTimeouts | /*RetryErrors |*/ RetryRatelimit | Retry502, + } +} diff --git a/src/Discord.Net.Core/TokenType.cs b/src/Discord.Net.Core/TokenType.cs new file mode 100644 index 0000000..03b8408 --- /dev/null +++ b/src/Discord.Net.Core/TokenType.cs @@ -0,0 +1,21 @@ +using System; + +namespace Discord +{ + /// Specifies the type of token to use with the client. + public enum TokenType + { + /// + /// An OAuth2 token type. + /// + Bearer, + /// + /// A bot token type. + /// + Bot, + /// + /// A webhook token type. + /// + Webhook + } +} diff --git a/src/Discord.Net.Core/Utils/AsyncEvent.cs b/src/Discord.Net.Core/Utils/AsyncEvent.cs new file mode 100644 index 0000000..3a67a9f --- /dev/null +++ b/src/Discord.Net.Core/Utils/AsyncEvent.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Discord +{ + internal class AsyncEvent + where T : class + { + private readonly object _subLock = new object(); + internal ImmutableArray _subscriptions; + + public bool HasSubscribers => _subscriptions.Length != 0; + public IReadOnlyList Subscriptions => _subscriptions; + + public AsyncEvent() + { + _subscriptions = ImmutableArray.Create(); + } + + public void Add(T subscriber) + { + Preconditions.NotNull(subscriber, nameof(subscriber)); + lock (_subLock) + _subscriptions = _subscriptions.Add(subscriber); + } + public void Remove(T subscriber) + { + Preconditions.NotNull(subscriber, nameof(subscriber)); + lock (_subLock) + _subscriptions = _subscriptions.Remove(subscriber); + } + } + + internal static class EventExtensions + { + public static async Task InvokeAsync(this AsyncEvent> eventHandler) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke().ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T arg) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2, T3 arg3) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2, arg3).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2, T3 arg3, T4 arg4) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2, arg3, arg4).ConfigureAwait(false); + } + public static async Task InvokeAsync(this AsyncEvent> eventHandler, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) + { + var subscribers = eventHandler.Subscriptions; + for (int i = 0; i < subscribers.Count; i++) + await subscribers[i].Invoke(arg1, arg2, arg3, arg4, arg5).ConfigureAwait(false); + } + } +} diff --git a/src/Discord.Net.Core/Utils/Cacheable.cs b/src/Discord.Net.Core/Utils/Cacheable.cs new file mode 100644 index 0000000..c0d3c30 --- /dev/null +++ b/src/Discord.Net.Core/Utils/Cacheable.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a cached entity. + /// + /// The type of entity that is cached. + /// The type of this entity's ID. + public struct Cacheable + where TEntity : IEntity + where TId : IEquatable + { + /// + /// Gets whether this entity is cached. + /// + public bool HasValue { get; } + /// + /// Gets the ID of this entity. + /// + public TId Id { get; } + /// + /// Gets the entity if it could be pulled from cache. + /// + /// + /// This value is not guaranteed to be set; in cases where the entity cannot be pulled from cache, it is + /// . + /// + public TEntity Value { get; } + private Func> DownloadFunc { get; } + + internal Cacheable(TEntity value, TId id, bool hasValue, Func> downloadFunc) + { + Value = value; + Id = id; + HasValue = hasValue; + DownloadFunc = downloadFunc; + } + + /// + /// Downloads this entity to cache. + /// + /// Thrown when used from a user account. + /// Thrown when the message is deleted. + /// + /// A task that represents the asynchronous download operation. The task result contains the downloaded + /// entity. + /// + public Task DownloadAsync() + => DownloadFunc(); + + /// + /// Returns the cached entity if it exists; otherwise downloads it. + /// + /// Thrown when used from a user account. + /// Thrown when the message is deleted and is not in cache. + /// + /// A task that represents the asynchronous operation that attempts to get the message via cache or to + /// download the message. The task result contains the downloaded entity. + /// + public async Task GetOrDownloadAsync() => HasValue ? Value : await DownloadAsync().ConfigureAwait(false); + } + public struct Cacheable + where TCachedEntity : IEntity, TRelationship + where TDownloadableEntity : IEntity, TRelationship + where TId : IEquatable + { + /// + /// Gets whether this entity is cached. + /// + public bool HasValue { get; } + /// + /// Gets the ID of this entity. + /// + public TId Id { get; } + /// + /// Gets the entity if it could be pulled from cache. + /// + /// + /// This value is not guaranteed to be set; in cases where the entity cannot be pulled from cache, it is + /// . + /// + public TCachedEntity Value { get; } + private Func> DownloadFunc { get; } + + internal Cacheable(TCachedEntity value, TId id, bool hasValue, Func> downloadFunc) + { + Value = value; + Id = id; + HasValue = hasValue; + DownloadFunc = downloadFunc; + } + + /// + /// Downloads this entity. + /// + /// Thrown when used from a user account. + /// Thrown when the message is deleted. + /// + /// A task that represents the asynchronous download operation. The task result contains the downloaded + /// entity. + /// + public Task DownloadAsync() + { + return DownloadFunc(); + } + + /// + /// Returns the cached entity if it exists; otherwise downloads it. + /// + /// Thrown when used from a user account. + /// Thrown when the message is deleted and is not in cache. + /// + /// A task that represents the asynchronous operation that attempts to get the message via cache or to + /// download the message. The task result contains the downloaded entity. + /// + public async Task GetOrDownloadAsync() => HasValue ? Value : await DownloadAsync().ConfigureAwait(false); + } +} diff --git a/src/Discord.Net.Core/Utils/ChannelTypeUtils.cs b/src/Discord.Net.Core/Utils/ChannelTypeUtils.cs new file mode 100644 index 0000000..331d5be --- /dev/null +++ b/src/Discord.Net.Core/Utils/ChannelTypeUtils.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Discord.Utils; + +public static class ChannelTypeUtils +{ + public static List AllChannelTypes() + => new List() + { + ChannelType.Forum, ChannelType.Category, ChannelType.DM, ChannelType.Group, ChannelType.GuildDirectory, + ChannelType.News, ChannelType.NewsThread, ChannelType.PrivateThread, ChannelType.PublicThread, + ChannelType.Stage, ChannelType.Store, ChannelType.Text, ChannelType.Voice, ChannelType.Media + }; +} diff --git a/src/Discord.Net.Core/Utils/Comparers.cs b/src/Discord.Net.Core/Utils/Comparers.cs new file mode 100644 index 0000000..2375396 --- /dev/null +++ b/src/Discord.Net.Core/Utils/Comparers.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Discord +{ + /// + /// Represents a collection of for various Discord objects. + /// + public static class DiscordComparers + { + /// + /// Gets an to be used to compare users. + /// + public static IEqualityComparer UserComparer { get; } = new EntityEqualityComparer(); + /// + /// Gets an to be used to compare guilds. + /// + public static IEqualityComparer GuildComparer { get; } = new EntityEqualityComparer(); + /// + /// Gets an to be used to compare channels. + /// + public static IEqualityComparer ChannelComparer { get; } = new EntityEqualityComparer(); + /// + /// Gets an to be used to compare roles. + /// + public static IEqualityComparer RoleComparer { get; } = new EntityEqualityComparer(); + /// + /// Gets an to be used to compare messages. + /// + public static IEqualityComparer MessageComparer { get; } = new EntityEqualityComparer(); + + private sealed class EntityEqualityComparer : EqualityComparer + where TEntity : IEntity + where TId : IEquatable + { + public override bool Equals(TEntity x, TEntity y) + { + return (x, y) switch + { + (null, null) => true, + (null, _) => false, + (_, null) => false, + _ => x.Id.Equals(y.Id) + }; + } + + public override int GetHashCode(TEntity obj) + { + return obj?.Id.GetHashCode() ?? 0; + } + } + } +} diff --git a/src/Discord.Net.Core/Utils/ComponentType.cs b/src/Discord.Net.Core/Utils/ComponentType.cs new file mode 100644 index 0000000..c7d42c5 --- /dev/null +++ b/src/Discord.Net.Core/Utils/ComponentType.cs @@ -0,0 +1,8 @@ +namespace Discord.Utils; + +public static class ComponentTypeUtils +{ + public static bool IsSelectType(this ComponentType type) => type is ComponentType.ChannelSelect + or ComponentType.SelectMenu or ComponentType.RoleSelect or ComponentType.UserSelect + or ComponentType.MentionableSelect; +} diff --git a/src/Discord.Net.Core/Utils/ConcurrentHashSet.cs b/src/Discord.Net.Core/Utils/ConcurrentHashSet.cs new file mode 100644 index 0000000..6041c28 --- /dev/null +++ b/src/Discord.Net.Core/Utils/ConcurrentHashSet.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Threading; + +namespace Discord +{ + //Based on https://github.com/dotnet/corefx/blob/d0dc5fc099946adc1035b34a8b1f6042eddb0c75/src/System.Threading.Tasks.Parallel/src/System/Threading/PlatformHelper.cs + //Copyright (c) .NET Foundation and Contributors + internal static class ConcurrentHashSet + { + private const int PROCESSOR_COUNT_REFRESH_INTERVAL_MS = 30000; + private static volatile int s_processorCount; + private static volatile int s_lastProcessorCountRefreshTicks; + + public static int DefaultConcurrencyLevel + { + get + { + int now = Environment.TickCount; + if (s_processorCount == 0 || (now - s_lastProcessorCountRefreshTicks) >= PROCESSOR_COUNT_REFRESH_INTERVAL_MS) + { + s_processorCount = Environment.ProcessorCount; + s_lastProcessorCountRefreshTicks = now; + } + + return s_processorCount; + } + } + } + + //Based on https://github.com/dotnet/corefx/blob/master/src/System.Collections.Concurrent/src/System/Collections/Concurrent/ConcurrentDictionary.cs + //Copyright (c) .NET Foundation and Contributors + [DebuggerDisplay("Count = {Count}")] + internal class ConcurrentHashSet : IReadOnlyCollection + { + private sealed class Tables + { + internal readonly Node[] _buckets; + internal readonly object[] _locks; + internal volatile int[] _countPerLock; + + internal Tables(Node[] buckets, object[] locks, int[] countPerLock) + { + _buckets = buckets; + _locks = locks; + _countPerLock = countPerLock; + } + } + private sealed class Node + { + internal readonly T _value; + internal volatile Node _next; + internal readonly int _hashcode; + + internal Node(T key, int hashcode, Node next) + { + _value = key; + _next = next; + _hashcode = hashcode; + } + } + + private const int DefaultCapacity = 31; + private const int MaxLockNumber = 1024; + + private static int GetBucket(int hashcode, int bucketCount) + { + int bucketNo = (hashcode & 0x7fffffff) % bucketCount; + return bucketNo; + } + private static void GetBucketAndLockNo(int hashcode, out int bucketNo, out int lockNo, int bucketCount, int lockCount) + { + bucketNo = (hashcode & 0x7fffffff) % bucketCount; + lockNo = bucketNo % lockCount; + } + private static int DefaultConcurrencyLevel => ConcurrentHashSet.DefaultConcurrencyLevel; + + private volatile Tables _tables; + private readonly IEqualityComparer _comparer; + private readonly bool _growLockArray; + private int _budget; + + public int Count + { + get + { + int count = 0; + + int acquiredLocks = 0; + try + { + AcquireAllLocks(ref acquiredLocks); + + for (int i = 0; i < _tables._countPerLock.Length; i++) + count += _tables._countPerLock[i]; + } + finally { ReleaseLocks(0, acquiredLocks); } + + return count; + } + } + public bool IsEmpty + { + get + { + int acquiredLocks = 0; + try + { + // Acquire all locks + AcquireAllLocks(ref acquiredLocks); + + for (int i = 0; i < _tables._countPerLock.Length; i++) + { + if (_tables._countPerLock[i] != 0) + return false; + } + } + finally { ReleaseLocks(0, acquiredLocks); } + + return true; + } + } + public ReadOnlyCollection Values + { + get + { + int locksAcquired = 0; + try + { + AcquireAllLocks(ref locksAcquired); + List values = new List(); + + for (int i = 0; i < _tables._buckets.Length; i++) + { + Node current = _tables._buckets[i]; + while (current != null) + { + values.Add(current._value); + current = current._next; + } + } + + return new ReadOnlyCollection(values); + } + finally { ReleaseLocks(0, locksAcquired); } + } + } + + public ConcurrentHashSet() + : this(DefaultConcurrencyLevel, DefaultCapacity, true, EqualityComparer.Default) { } + public ConcurrentHashSet(int concurrencyLevel, int capacity) + : this(concurrencyLevel, capacity, false, EqualityComparer.Default) { } + public ConcurrentHashSet(IEnumerable collection) + : this(collection, EqualityComparer.Default) { } + public ConcurrentHashSet(IEqualityComparer comparer) + : this(DefaultConcurrencyLevel, DefaultCapacity, true, comparer) { } + /// is + public ConcurrentHashSet(IEnumerable collection, IEqualityComparer comparer) + : this(comparer) + { + if (collection == null) + throw new ArgumentNullException(paramName: nameof(collection)); + InitializeFromCollection(collection); + } + /// + /// or is + /// + public ConcurrentHashSet(int concurrencyLevel, IEnumerable collection, IEqualityComparer comparer) + : this(concurrencyLevel, DefaultCapacity, false, comparer) + { + if (collection == null) + throw new ArgumentNullException(paramName: nameof(collection)); + if (comparer == null) + throw new ArgumentNullException(paramName: nameof(comparer)); + InitializeFromCollection(collection); + } + public ConcurrentHashSet(int concurrencyLevel, int capacity, IEqualityComparer comparer) + : this(concurrencyLevel, capacity, false, comparer) { } + internal ConcurrentHashSet(int concurrencyLevel, int capacity, bool growLockArray, IEqualityComparer comparer) + { + if (concurrencyLevel < 1) + throw new ArgumentOutOfRangeException(paramName: nameof(concurrencyLevel)); + if (capacity < 0) + throw new ArgumentOutOfRangeException(paramName: nameof(capacity)); + if (comparer == null) + throw new ArgumentNullException(paramName: nameof(comparer)); + + if (capacity < concurrencyLevel) + capacity = concurrencyLevel; + + object[] locks = new object[concurrencyLevel]; + for (int i = 0; i < locks.Length; i++) + locks[i] = new object(); + + int[] countPerLock = new int[locks.Length]; + Node[] buckets = new Node[capacity]; + _tables = new Tables(buckets, locks, countPerLock); + + _comparer = comparer; + _growLockArray = growLockArray; + _budget = buckets.Length / locks.Length; + } + private void InitializeFromCollection(IEnumerable collection) + { + foreach (var value in collection) + { + if (value == null) + throw new ArgumentNullException(paramName: "key"); + + if (!TryAddInternal(value, _comparer.GetHashCode(value), false)) + throw new ArgumentException(); + } + + if (_budget == 0) + _budget = _tables._buckets.Length / _tables._locks.Length; + } + /// is + public bool ContainsKey(T value) + { + if (value == null) + throw new ArgumentNullException(paramName: "key"); + return ContainsKeyInternal(value, _comparer.GetHashCode(value)); + } + private bool ContainsKeyInternal(T value, int hashcode) + { + Tables tables = _tables; + + int bucketNo = GetBucket(hashcode, tables._buckets.Length); + + Node n = Volatile.Read(ref tables._buckets[bucketNo]); + + while (n != null) + { + if (hashcode == n._hashcode && _comparer.Equals(n._value, value)) + return true; + n = n._next; + } + + return false; + } + + /// is + public bool TryAdd(T value) + { + if (value == null) + throw new ArgumentNullException(paramName: "key"); + return TryAddInternal(value, _comparer.GetHashCode(value), true); + } + private bool TryAddInternal(T value, int hashcode, bool acquireLock) + { + while (true) + { + Tables tables = _tables; + GetBucketAndLockNo(hashcode, out int bucketNo, out int lockNo, tables._buckets.Length, tables._locks.Length); + + bool resizeDesired = false; + bool lockTaken = false; + try + { + if (acquireLock) + Monitor.Enter(tables._locks[lockNo], ref lockTaken); + + if (tables != _tables) + continue; + + Node prev = null; + for (Node node = tables._buckets[bucketNo]; node != null; node = node._next) + { + if (hashcode == node._hashcode && _comparer.Equals(node._value, value)) + return false; + prev = node; + } + + Volatile.Write(ref tables._buckets[bucketNo], new Node(value, hashcode, tables._buckets[bucketNo])); + checked + { tables._countPerLock[lockNo]++; } + + if (tables._countPerLock[lockNo] > _budget) + resizeDesired = true; + } + finally + { + if (lockTaken) + Monitor.Exit(tables._locks[lockNo]); + } + + if (resizeDesired) + GrowTable(tables); + + return true; + } + } + + /// is + public bool TryRemove(T value) + { + if (value == null) + throw new ArgumentNullException(paramName: "key"); + return TryRemoveInternal(value); + } + private bool TryRemoveInternal(T value) + { + int hashcode = _comparer.GetHashCode(value); + while (true) + { + Tables tables = _tables; + GetBucketAndLockNo(hashcode, out int bucketNo, out int lockNo, tables._buckets.Length, tables._locks.Length); + + lock (tables._locks[lockNo]) + { + if (tables != _tables) + continue; + + Node prev = null; + for (Node curr = tables._buckets[bucketNo]; curr != null; curr = curr._next) + { + if (hashcode == curr._hashcode && _comparer.Equals(curr._value, value)) + { + if (prev == null) + Volatile.Write(ref tables._buckets[bucketNo], curr._next); + else + prev._next = curr._next; + + value = curr._value; + tables._countPerLock[lockNo]--; + return true; + } + prev = curr; + } + } + + value = default(T); + return false; + } + } + + public void Clear() + { + int locksAcquired = 0; + try + { + AcquireAllLocks(ref locksAcquired); + + Tables newTables = new Tables(new Node[DefaultCapacity], _tables._locks, new int[_tables._countPerLock.Length]); + _tables = newTables; + _budget = Math.Max(1, newTables._buckets.Length / newTables._locks.Length); + } + finally + { + ReleaseLocks(0, locksAcquired); + } + } + + public IEnumerator GetEnumerator() + { + Node[] buckets = _tables._buckets; + + for (int i = 0; i < buckets.Length; i++) + { + Node current = Volatile.Read(ref buckets[i]); + + while (current != null) + { + yield return current._value; + current = current._next; + } + } + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private void GrowTable(Tables tables) + { + const int MaxArrayLength = 0X7FEFFFFF; + int locksAcquired = 0; + try + { + AcquireLocks(0, 1, ref locksAcquired); + if (tables != _tables) + return; + + long approxCount = 0; + for (int i = 0; i < tables._countPerLock.Length; i++) + approxCount += tables._countPerLock[i]; + + if (approxCount < tables._buckets.Length / 4) + { + _budget = 2 * _budget; + if (_budget < 0) + _budget = int.MaxValue; + return; + } + + int newLength = 0; + bool maximizeTableSize = false; + try + { + checked + { + newLength = tables._buckets.Length * 2 + 1; + while (newLength % 3 == 0 || newLength % 5 == 0 || newLength % 7 == 0) + newLength += 2; + + if (newLength > MaxArrayLength) + maximizeTableSize = true; + } + } + catch (OverflowException) + { + maximizeTableSize = true; + } + + if (maximizeTableSize) + { + newLength = MaxArrayLength; + _budget = int.MaxValue; + } + + AcquireLocks(1, tables._locks.Length, ref locksAcquired); + + object[] newLocks = tables._locks; + + if (_growLockArray && tables._locks.Length < MaxLockNumber) + { + newLocks = new object[tables._locks.Length * 2]; + Array.Copy(tables._locks, 0, newLocks, 0, tables._locks.Length); + for (int i = tables._locks.Length; i < newLocks.Length; i++) + newLocks[i] = new object(); + } + + Node[] newBuckets = new Node[newLength]; + int[] newCountPerLock = new int[newLocks.Length]; + + for (int i = 0; i < tables._buckets.Length; i++) + { + Node current = tables._buckets[i]; + while (current != null) + { + Node next = current._next; + GetBucketAndLockNo(current._hashcode, out int newBucketNo, out int newLockNo, newBuckets.Length, newLocks.Length); + + newBuckets[newBucketNo] = new Node(current._value, current._hashcode, newBuckets[newBucketNo]); + + checked + { newCountPerLock[newLockNo]++; } + + current = next; + } + } + + _budget = Math.Max(1, newBuckets.Length / newLocks.Length); + _tables = new Tables(newBuckets, newLocks, newCountPerLock); + } + finally { ReleaseLocks(0, locksAcquired); } + } + + private void AcquireAllLocks(ref int locksAcquired) + { + AcquireLocks(0, 1, ref locksAcquired); + AcquireLocks(1, _tables._locks.Length, ref locksAcquired); + } + private void AcquireLocks(int fromInclusive, int toExclusive, ref int locksAcquired) + { + object[] locks = _tables._locks; + + for (int i = fromInclusive; i < toExclusive; i++) + { + bool lockTaken = false; + try + { + Monitor.Enter(locks[i], ref lockTaken); + } + finally + { + if (lockTaken) + locksAcquired++; + } + } + } + private void ReleaseLocks(int fromInclusive, int toExclusive) + { + for (int i = fromInclusive; i < toExclusive; i++) + Monitor.Exit(_tables._locks[i]); + } + } +} diff --git a/src/Discord.Net.Core/Utils/DateTimeUtils.cs b/src/Discord.Net.Core/Utils/DateTimeUtils.cs new file mode 100644 index 0000000..6084768 --- /dev/null +++ b/src/Discord.Net.Core/Utils/DateTimeUtils.cs @@ -0,0 +1,13 @@ +using System; + +namespace Discord +{ + /// + internal static class DateTimeUtils + { + public static DateTimeOffset FromTicks(long ticks) + => new DateTimeOffset(ticks, TimeSpan.Zero); + public static DateTimeOffset? FromTicks(long? ticks) + => ticks != null ? new DateTimeOffset(ticks.Value, TimeSpan.Zero) : (DateTimeOffset?)null; + } +} diff --git a/src/Discord.Net.Core/Utils/MentionUtils.cs b/src/Discord.Net.Core/Utils/MentionUtils.cs new file mode 100644 index 0000000..2210ddc --- /dev/null +++ b/src/Discord.Net.Core/Utils/MentionUtils.cs @@ -0,0 +1,325 @@ +using System; +using System.Globalization; +using System.Text; + +namespace Discord +{ + /// + /// Provides a series of helper methods for parsing mentions. + /// + public static class MentionUtils + { + private const char SanitizeChar = '\x200b'; + + //If the system can't be positive a user doesn't have a nickname, assume useNickname = true (source: Jake) + internal static string MentionUser(string id) => $"<@{id}>"; + /// + /// Returns a mention string based on the user ID. + /// + /// + /// A user mention string (e.g. <@80351110224678912>). + /// + public static string MentionUser(ulong id) => MentionUser(id.ToString()); + internal static string MentionChannel(string id) => $"<#{id}>"; + /// + /// Returns a mention string based on the channel ID. + /// + /// + /// A channel mention string (e.g. <#103735883630395392>). + /// + public static string MentionChannel(ulong id) => MentionChannel(id.ToString()); + internal static string MentionRole(string id) => $"<@&{id}>"; + /// + /// Returns a mention string based on the role ID. + /// + /// + /// A role mention string (e.g. <@&165511591545143296>). + /// + public static string MentionRole(ulong id) => MentionRole(id.ToString()); + + /// + /// Parses a provided user mention string. + /// + /// The user mention. + /// Invalid mention format. + public static ulong ParseUser(string text) + { + if (TryParseUser(text, out ulong id)) + return id; + throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text)); + } + /// + /// Tries to parse a provided user mention string. + /// + /// The user mention. + /// The UserId of the user. + public static bool TryParseUser(string text, out ulong userId) + { + if (text.Length >= 3 && text[0] == '<' && text[1] == '@' && text[text.Length - 1] == '>') + { + if (text.Length >= 4 && text[2] == '!') + text = text.Substring(3, text.Length - 4); //<@!123> + else + text = text.Substring(2, text.Length - 3); //<@123> + + if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out userId)) + return true; + } + userId = 0; + return false; + } + + /// + /// Parses a provided channel mention string. + /// + /// Invalid mention format. + public static ulong ParseChannel(string text) + { + if (TryParseChannel(text, out ulong id)) + return id; + throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text)); + } + /// + /// Tries to parse a provided channel mention string. + /// + public static bool TryParseChannel(string text, out ulong channelId) + { + if (text.Length >= 3 && text[0] == '<' && text[1] == '#' && text[text.Length - 1] == '>') + { + text = text.Substring(2, text.Length - 3); //<#123> + + if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out channelId)) + return true; + } + channelId = 0; + return false; + } + + /// + /// Parses a provided role mention string. + /// + /// Invalid mention format. + public static ulong ParseRole(string text) + { + if (TryParseRole(text, out ulong id)) + return id; + throw new ArgumentException(message: "Invalid mention format.", paramName: nameof(text)); + } + /// + /// Tries to parse a provided role mention string. + /// + public static bool TryParseRole(string text, out ulong roleId) + { + if (text.Length >= 4 && text[0] == '<' && text[1] == '@' && text[2] == '&' && text[text.Length - 1] == '>') + { + text = text.Substring(3, text.Length - 4); //<@&123> + + if (ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out roleId)) + return true; + } + roleId = 0; + return false; + } + + internal static string Resolve(IMessage msg, int startIndex, TagHandling userHandling, TagHandling channelHandling, TagHandling roleHandling, TagHandling everyoneHandling, TagHandling emojiHandling) + { + var text = new StringBuilder(msg.Content.Substring(startIndex)); + var tags = msg.Tags; + int indexOffset = -startIndex; + + foreach (var tag in tags) + { + if (tag.Index < startIndex) + continue; + + string newText = ""; + switch (tag.Type) + { + case TagType.UserMention: + if (userHandling == TagHandling.Ignore) + continue; + newText = ResolveUserMention(tag, userHandling); + break; + case TagType.ChannelMention: + if (channelHandling == TagHandling.Ignore) + continue; + newText = ResolveChannelMention(tag, channelHandling); + break; + case TagType.RoleMention: + if (roleHandling == TagHandling.Ignore) + continue; + newText = ResolveRoleMention(tag, roleHandling); + break; + case TagType.EveryoneMention: + if (everyoneHandling == TagHandling.Ignore) + continue; + newText = ResolveEveryoneMention(tag, everyoneHandling); + break; + case TagType.HereMention: + if (everyoneHandling == TagHandling.Ignore) + continue; + newText = ResolveHereMention(tag, everyoneHandling); + break; + case TagType.Emoji: + if (emojiHandling == TagHandling.Ignore) + continue; + newText = ResolveEmoji(tag, emojiHandling); + break; + } + text.Remove(tag.Index + indexOffset, tag.Length); + text.Insert(tag.Index + indexOffset, newText); + indexOffset += newText.Length - tag.Length; + } + return text.ToString(); + } + internal static string ResolveUserMention(ITag tag, TagHandling mode) + { + if (mode != TagHandling.Remove) + { + var user = tag.Value as IUser; + var guildUser = user as IGuildUser; + switch (mode) + { + case TagHandling.Name: + if (user != null) + return $"@{guildUser?.Nickname ?? user?.Username}"; + else + return ""; + case TagHandling.NameNoPrefix: + if (user != null) + return $"{guildUser?.Nickname ?? user?.Username}"; + else + return ""; + case TagHandling.FullName: + if (user != null) + return user.DiscriminatorValue != 0 ? $"@{user.Username}#{user.Discriminator}" : user.Username; + else + return ""; + case TagHandling.FullNameNoPrefix: + if (user != null) + return user.DiscriminatorValue != 0 ? $"@{user.Username}#{user.Discriminator}" : user.Username; + else + return ""; + case TagHandling.Sanitize: + if (guildUser != null && guildUser.Nickname == null) + return MentionUser($"{SanitizeChar}{tag.Key}"); + else + return MentionUser($"{SanitizeChar}{tag.Key}"); + } + } + return ""; + } + internal static string ResolveChannelMention(ITag tag, TagHandling mode) + { + if (mode != TagHandling.Remove) + { + var channel = tag.Value as IChannel; + switch (mode) + { + case TagHandling.Name: + case TagHandling.FullName: + if (channel != null) + return $"#{channel.Name}"; + else + return ""; + case TagHandling.NameNoPrefix: + case TagHandling.FullNameNoPrefix: + if (channel != null) + return $"{channel.Name}"; + else + return ""; + case TagHandling.Sanitize: + return MentionChannel($"{SanitizeChar}{tag.Key}"); + } + } + return ""; + } + internal static string ResolveRoleMention(ITag tag, TagHandling mode) + { + if (mode != TagHandling.Remove) + { + var role = tag.Value as IRole; + switch (mode) + { + case TagHandling.Name: + case TagHandling.FullName: + if (role != null) + return $"@{role.Name}"; + else + return ""; + case TagHandling.NameNoPrefix: + case TagHandling.FullNameNoPrefix: + if (role != null) + return $"{role.Name}"; + else + return ""; + case TagHandling.Sanitize: + return MentionRole($"{SanitizeChar}{tag.Key}"); + } + } + return ""; + } + internal static string ResolveEveryoneMention(ITag tag, TagHandling mode) + { + if (mode != TagHandling.Remove) + { + switch (mode) + { + case TagHandling.Name: + case TagHandling.FullName: + case TagHandling.NameNoPrefix: + case TagHandling.FullNameNoPrefix: + return "everyone"; + case TagHandling.Sanitize: + return $"@{SanitizeChar}everyone"; + } + } + return ""; + } + internal static string ResolveHereMention(ITag tag, TagHandling mode) + { + if (mode != TagHandling.Remove) + { + switch (mode) + { + case TagHandling.Name: + case TagHandling.FullName: + case TagHandling.NameNoPrefix: + case TagHandling.FullNameNoPrefix: + return "here"; + case TagHandling.Sanitize: + return $"@{SanitizeChar}here"; + } + } + return ""; + } + internal static string ResolveEmoji(ITag tag, TagHandling mode) + { + if (mode != TagHandling.Remove) + { + Emote emoji = (Emote)tag.Value; + + //Remove if its name contains any bad chars (prevents a few tag exploits) + for (int i = 0; i < emoji.Name.Length; i++) + { + char c = emoji.Name[i]; + if (!char.IsLetterOrDigit(c) && c != '_' && c != '-') + return ""; + } + + switch (mode) + { + case TagHandling.Name: + case TagHandling.FullName: + return $":{emoji.Name}:"; + case TagHandling.NameNoPrefix: + case TagHandling.FullNameNoPrefix: + return $"{emoji.Name}"; + case TagHandling.Sanitize: + return $"<{emoji.Id}{SanitizeChar}:{SanitizeChar}{emoji.Name}>"; + } + } + return ""; + } + } +} diff --git a/src/Discord.Net.Core/Utils/Optional.cs b/src/Discord.Net.Core/Utils/Optional.cs new file mode 100644 index 0000000..0885e49 --- /dev/null +++ b/src/Discord.Net.Core/Utils/Optional.cs @@ -0,0 +1,62 @@ +using System; +using System.Diagnostics; + +namespace Discord +{ + //Based on https://github.com/dotnet/coreclr/blob/master/src/mscorlib/src/System/Nullable.cs + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct Optional + { + public static Optional Unspecified => default; + private readonly T _value; + + /// Gets the value for this parameter. + /// This property has no value set. + public T Value + { + get + { + if (!IsSpecified) + throw new InvalidOperationException("This property has no value set."); + return _value; + } + } + /// Returns true if this value has been specified. + public bool IsSpecified { get; } + + /// Creates a new Parameter with the provided value. + public Optional(T value) + { + _value = value; + IsSpecified = true; + } + + public T GetValueOrDefault() => _value; + public T GetValueOrDefault(T defaultValue) => IsSpecified ? _value : defaultValue; + + public override bool Equals(object other) + { + if (!IsSpecified) + return other == null; + if (other == null) + return false; + return _value.Equals(other); + } + public override int GetHashCode() => IsSpecified ? _value.GetHashCode() : 0; + + public override string ToString() => IsSpecified ? _value?.ToString() : null; + private string DebuggerDisplay => IsSpecified ? _value?.ToString() ?? "" : ""; + + public static implicit operator Optional(T value) => new(value); + public static explicit operator T(Optional value) => value.Value; + } + public static class Optional + { + public static Optional Create() => Optional.Unspecified; + public static Optional Create(T value) => new(value); + + public static T? ToNullable(this Optional val) + where T : struct + => val.IsSpecified ? val.Value : null; + } +} diff --git a/src/Discord.Net.Core/Utils/Paging/Page.cs b/src/Discord.Net.Core/Utils/Paging/Page.cs new file mode 100644 index 0000000..d94e1d5 --- /dev/null +++ b/src/Discord.Net.Core/Utils/Paging/Page.cs @@ -0,0 +1,22 @@ +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord +{ + internal class Page : IReadOnlyCollection + { + private readonly IReadOnlyCollection _items; + public int Index { get; } + + public Page(PageInfo info, IEnumerable source) + { + Index = info.Page; + _items = source.ToImmutableArray(); + } + + int IReadOnlyCollection.Count => _items.Count; + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _items.GetEnumerator(); + } +} diff --git a/src/Discord.Net.Core/Utils/Paging/PageInfo.cs b/src/Discord.Net.Core/Utils/Paging/PageInfo.cs new file mode 100644 index 0000000..65813df --- /dev/null +++ b/src/Discord.Net.Core/Utils/Paging/PageInfo.cs @@ -0,0 +1,23 @@ +namespace Discord +{ + internal class PageInfo + { + public int Page { get; set; } + public ulong? Position { get; set; } + public int? Count { get; set; } + public int PageSize { get; set; } + public int? Remaining { get; set; } + + internal PageInfo(ulong? pos, int? count, int pageSize) + { + Page = 1; + Position = pos; + Count = count; + Remaining = count; + PageSize = pageSize; + + if (Count != null && Count.Value < PageSize) + PageSize = Count.Value; + } + } +} diff --git a/src/Discord.Net.Core/Utils/Paging/PagedEnumerator.cs b/src/Discord.Net.Core/Utils/Paging/PagedEnumerator.cs new file mode 100644 index 0000000..29ce3fd --- /dev/null +++ b/src/Discord.Net.Core/Utils/Paging/PagedEnumerator.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord +{ + internal class PagedAsyncEnumerable : IAsyncEnumerable> + { + public int PageSize { get; } + + private readonly ulong? _start; + private readonly int? _count; + private readonly Func>> _getPage; + private readonly Func, bool> _nextPage; + + public PagedAsyncEnumerable(int pageSize, Func>> getPage, Func, bool> nextPage = null, + ulong? start = null, int? count = null) + { + PageSize = pageSize; + _start = start; + _count = count; + + _getPage = getPage; + _nextPage = nextPage; + } + + public IAsyncEnumerator> GetAsyncEnumerator(CancellationToken cancellationToken = new CancellationToken()) => new Enumerator(this, cancellationToken); + internal sealed class Enumerator : IAsyncEnumerator> + { + private readonly PagedAsyncEnumerable _source; + private readonly CancellationToken _token; + private readonly PageInfo _info; + + public IReadOnlyCollection Current { get; private set; } + + public Enumerator(PagedAsyncEnumerable source, CancellationToken token) + { + _source = source; + _token = token; + _info = new PageInfo(source._start, source._count, source.PageSize); + } + + public async ValueTask MoveNextAsync() + { + if (_info.Remaining == 0) + return false; + + var data = await _source._getPage(_info, _token).ConfigureAwait(false); + Current = new Page(_info, data); + + _info.Page++; + if (_info.Remaining != null) + { + if (Current.Count >= _info.Remaining) + _info.Remaining = 0; + else + _info.Remaining -= Current.Count; + } + else + { + if (Current.Count == 0) + _info.Remaining = 0; + } + _info.PageSize = _info.Remaining != null ? Math.Min(_info.Remaining.Value, _source.PageSize) : _source.PageSize; + + if (_info.Remaining != 0) + { + if (!_source._nextPage(_info, data)) + _info.Remaining = 0; + } + + return true; + } + + public ValueTask DisposeAsync() + { + Current = null; + return default; + } + } + } +} diff --git a/src/Discord.Net.Core/Utils/Permissions.cs b/src/Discord.Net.Core/Utils/Permissions.cs new file mode 100644 index 0000000..1bc3bf6 --- /dev/null +++ b/src/Discord.Net.Core/Utils/Permissions.cs @@ -0,0 +1,174 @@ +using System.Runtime.CompilerServices; + +namespace Discord +{ + internal static class Permissions + { + public const int MaxBits = 53; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static PermValue GetValue(ulong allow, ulong deny, ChannelPermission flag) + => GetValue(allow, deny, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static PermValue GetValue(ulong allow, ulong deny, GuildPermission flag) + => GetValue(allow, deny, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static PermValue GetValue(ulong allow, ulong deny, ulong flag) + { + if (HasFlag(allow, flag)) + return PermValue.Allow; + else if (HasFlag(deny, flag)) + return PermValue.Deny; + else + return PermValue.Inherit; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool GetValue(ulong value, ChannelPermission flag) + => GetValue(value, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool GetValue(ulong value, GuildPermission flag) + => GetValue(value, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool GetValue(ulong value, ulong flag) => HasFlag(value, flag); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetValue(ref ulong rawValue, bool? value, ChannelPermission flag) + => SetValue(ref rawValue, value, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetValue(ref ulong rawValue, bool? value, GuildPermission flag) + => SetValue(ref rawValue, value, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetValue(ref ulong rawValue, bool? value, ulong flag) + { + if (value.HasValue) + { + if (value == true) + SetFlag(ref rawValue, flag); + else + UnsetFlag(ref rawValue, flag); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetValue(ref ulong allow, ref ulong deny, PermValue? value, ChannelPermission flag) + => SetValue(ref allow, ref deny, value, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetValue(ref ulong allow, ref ulong deny, PermValue? value, GuildPermission flag) + => SetValue(ref allow, ref deny, value, (ulong)flag); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetValue(ref ulong allow, ref ulong deny, PermValue? value, ulong flag) + { + if (value.HasValue) + { + switch (value) + { + case PermValue.Allow: + SetFlag(ref allow, flag); + UnsetFlag(ref deny, flag); + break; + case PermValue.Deny: + UnsetFlag(ref allow, flag); + SetFlag(ref deny, flag); + break; + default: + UnsetFlag(ref allow, flag); + UnsetFlag(ref deny, flag); + break; + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool HasFlag(ulong value, ulong flag) => (value & flag) == flag; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void SetFlag(ref ulong value, ulong flag) => value |= flag; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void UnsetFlag(ref ulong value, ulong flag) => value &= ~flag; + + public static ChannelPermissions ToChannelPerms(IGuildChannel channel, ulong guildPermissions) + => new ChannelPermissions(guildPermissions & ChannelPermissions.All(channel).RawValue); + public static ulong ResolveGuild(IGuild guild, IGuildUser user) + { + ulong resolvedPermissions = 0; + + if (user.Id == guild.OwnerId) + resolvedPermissions = GuildPermissions.All.RawValue; //Owners always have all permissions + else if (user.IsWebhook) + resolvedPermissions = GuildPermissions.Webhook.RawValue; + else + { + foreach (var roleId in user.RoleIds) + resolvedPermissions |= guild.GetRole(roleId)?.Permissions.RawValue ?? 0; + if (GetValue(resolvedPermissions, GuildPermission.Administrator)) + resolvedPermissions = GuildPermissions.All.RawValue; //Administrators always have all permissions + } + return resolvedPermissions; + } + + /*public static ulong ResolveChannel(IGuildUser user, IGuildChannel channel) + { + return ResolveChannel(user, channel, ResolveGuild(user)); + }*/ + public static ulong ResolveChannel(IGuild guild, IGuildUser user, IGuildChannel channel, ulong guildPermissions) + { + ulong resolvedPermissions = 0; + + ulong mask = ChannelPermissions.All(channel).RawValue; + if (GetValue(guildPermissions, GuildPermission.Administrator)) //Includes owner + resolvedPermissions = mask; //Owners and administrators always have all permissions + else + { + //Start with this user's guild permissions + resolvedPermissions = guildPermissions; + + //Give/Take Everyone permissions + var perms = channel.GetPermissionOverwrite(guild.EveryoneRole); + if (perms != null) + resolvedPermissions = (resolvedPermissions & ~perms.Value.DenyValue) | perms.Value.AllowValue; + + //Give/Take Role permissions + ulong deniedPermissions = 0UL, allowedPermissions = 0UL; + foreach (var roleId in user.RoleIds) + { + IRole role; + if (roleId != guild.EveryoneRole.Id && (role = guild.GetRole(roleId)) != null) + { + perms = channel.GetPermissionOverwrite(role); + if (perms != null) + { + allowedPermissions |= perms.Value.AllowValue; + deniedPermissions |= perms.Value.DenyValue; + } + } + } + resolvedPermissions = (resolvedPermissions & ~deniedPermissions) | allowedPermissions; + + //Give/Take User permissions + perms = channel.GetPermissionOverwrite(user); + if (perms != null) + resolvedPermissions = (resolvedPermissions & ~perms.Value.DenyValue) | perms.Value.AllowValue; + + if (channel is ITextChannel) + { + if (!GetValue(resolvedPermissions, ChannelPermission.ViewChannel)) + { + //No read permission on a text channel removes all other permissions + resolvedPermissions = 0; + } + else if (!GetValue(resolvedPermissions, ChannelPermission.SendMessages)) + { + //No send permissions on a text channel removes all send-related permissions + resolvedPermissions &= ~(ulong)ChannelPermission.SendTTSMessages; + resolvedPermissions &= ~(ulong)ChannelPermission.MentionEveryone; + resolvedPermissions &= ~(ulong)ChannelPermission.EmbedLinks; + resolvedPermissions &= ~(ulong)ChannelPermission.AttachFiles; + } + } + resolvedPermissions &= mask; //Ensure we didn't get any permissions this channel doesn't support (from guildPerms, for example) + } + + return resolvedPermissions; + } + } +} diff --git a/src/Discord.Net.Core/Utils/Preconditions.cs b/src/Discord.Net.Core/Utils/Preconditions.cs new file mode 100644 index 0000000..c3f2785 --- /dev/null +++ b/src/Discord.Net.Core/Utils/Preconditions.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord +{ + internal static class Preconditions + { + #region Objects + /// must not be . + public static void NotNull(T obj, string name, string msg = null) where T : class { if (obj == null) throw CreateNotNullException(name, msg); } + /// must not be . + public static void NotNull(Optional obj, string name, string msg = null) where T : class { if (obj.IsSpecified && obj.Value == null) throw CreateNotNullException(name, msg); } + + private static ArgumentNullException CreateNotNullException(string name, string msg) + { + if (msg == null) + return new ArgumentNullException(paramName: name); + else + return new ArgumentNullException(paramName: name, message: msg); + } + #endregion + + #region Strings + /// cannot be blank. + public static void NotEmpty(string obj, string name, string msg = null) { if (obj.Length == 0) throw CreateNotEmptyException(name, msg); } + /// cannot be blank. + public static void NotEmpty(Optional obj, string name, string msg = null) { if (obj.IsSpecified && obj.Value.Length == 0) throw CreateNotEmptyException(name, msg); } + /// cannot be blank. + /// must not be . + public static void NotNullOrEmpty(string obj, string name, string msg = null) + { + if (obj == null) + throw CreateNotNullException(name, msg); + if (obj.Length == 0) + throw CreateNotEmptyException(name, msg); + } + /// cannot be blank. + /// must not be . + public static void NotNullOrEmpty(Optional obj, string name, string msg = null) + { + if (obj.IsSpecified) + { + if (obj.Value == null) + throw CreateNotNullException(name, msg); + if (obj.Value.Length == 0) + throw CreateNotEmptyException(name, msg); + } + } + /// cannot be blank. + /// must not be . + public static void NotNullOrWhitespace(string obj, string name, string msg = null) + { + if (obj == null) + throw CreateNotNullException(name, msg); + if (obj.Trim().Length == 0) + throw CreateNotEmptyException(name, msg); + } + /// cannot be blank. + /// must not be . + public static void NotNullOrWhitespace(Optional obj, string name, string msg = null) + { + if (obj.IsSpecified) + { + if (obj.Value == null) + throw CreateNotNullException(name, msg); + if (obj.Value.Trim().Length == 0) + throw CreateNotEmptyException(name, msg); + } + } + + private static ArgumentException CreateNotEmptyException(string name, string msg) + => new ArgumentException(message: msg ?? "Argument cannot be blank.", paramName: name); + + #endregion + + #region Message Validation + + public static void WebhookMessageAtLeastOneOf(string text = null, MessageComponent components = null, ICollection embeds = null, + IEnumerable attachments = null) + { + if (!string.IsNullOrEmpty(text)) + return; + + if (components != null && components.Components.Count != 0) + return; + + if (attachments != null && attachments.Count() != 0) + return; + + if (embeds != null && embeds.Count != 0) + return; + + throw new ArgumentException($"At least one of 'Content', 'Embeds', 'Components' or 'Attachments' must be specified."); + } + + public static void MessageAtLeastOneOf(string text = null, MessageComponent components = null, ICollection embeds = null, + ICollection stickers = null, IEnumerable attachments = null) + { + if (!string.IsNullOrEmpty(text)) + return; + + if (components != null && components.Components.Count != 0) + return; + + if (stickers != null && stickers.Count != 0) + return; + + if (attachments != null && attachments.Count() != 0) + return; + + if (embeds != null && embeds.Count != 0) + return; + + throw new ArgumentException($"At least one of 'Content', 'Embeds', 'Components', 'Stickers' or 'Attachments' must be specified."); + } + + #endregion + + #region Numerics + /// Value may not be equal to . + public static void NotEqual(sbyte obj, sbyte value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(byte obj, byte value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(short obj, short value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(ushort obj, ushort value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(int obj, int value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(uint obj, uint value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(long obj, long value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(ulong obj, ulong value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, sbyte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, byte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, short value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, ushort value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, int value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, uint value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, long value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, ulong value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(sbyte? obj, sbyte value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(byte? obj, byte value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(short? obj, short value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(ushort? obj, ushort value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(int? obj, int value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(uint? obj, uint value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(long? obj, long value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(ulong? obj, ulong value, string name, string msg = null) { if (obj == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, sbyte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, byte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, short value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, ushort value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, int value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, uint value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, long value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + /// Value may not be equal to . + public static void NotEqual(Optional obj, ulong value, string name, string msg = null) { if (obj.IsSpecified && obj.Value == value) throw CreateNotEqualException(name, msg, value); } + + private static ArgumentException CreateNotEqualException(string name, string msg, T value) + => new ArgumentException(message: msg ?? $"Value may not be equal to {value}.", paramName: name); + + /// Value must be at least . + public static void AtLeast(sbyte obj, sbyte value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(byte obj, byte value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(short obj, short value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(ushort obj, ushort value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(int obj, int value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(uint obj, uint value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(long obj, long value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(ulong obj, ulong value, string name, string msg = null) { if (obj < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, sbyte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, byte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, short value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, ushort value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, int value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, uint value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, long value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + /// Value must be at least . + public static void AtLeast(Optional obj, ulong value, string name, string msg = null) { if (obj.IsSpecified && obj.Value < value) throw CreateAtLeastException(name, msg, value); } + + private static ArgumentException CreateAtLeastException(string name, string msg, T value) + => new ArgumentException(message: msg ?? $"Value must be at least {value}.", paramName: name); + + /// Value must be greater than . + public static void GreaterThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(byte obj, byte value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(short obj, short value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(ushort obj, ushort value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(int obj, int value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(uint obj, uint value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(long obj, long value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(ulong obj, ulong value, string name, string msg = null) { if (obj <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, sbyte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, byte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, short value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, ushort value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, int value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, uint value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, long value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + /// Value must be greater than . + public static void GreaterThan(Optional obj, ulong value, string name, string msg = null) { if (obj.IsSpecified && obj.Value <= value) throw CreateGreaterThanException(name, msg, value); } + + private static ArgumentException CreateGreaterThanException(string name, string msg, T value) + => new ArgumentException(message: msg ?? $"Value must be greater than {value}.", paramName: name); + + /// Value must be at most . + public static void AtMost(sbyte obj, sbyte value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(byte obj, byte value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(short obj, short value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(ushort obj, ushort value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(int obj, int value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(uint obj, uint value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(long obj, long value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(ulong obj, ulong value, string name, string msg = null) { if (obj > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, sbyte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, byte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, short value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, ushort value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, int value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, uint value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, long value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + /// Value must be at most . + public static void AtMost(Optional obj, ulong value, string name, string msg = null) { if (obj.IsSpecified && obj.Value > value) throw CreateAtMostException(name, msg, value); } + + private static ArgumentException CreateAtMostException(string name, string msg, T value) + => new ArgumentException(message: msg ?? $"Value must be at most {value}.", paramName: name); + + /// Value must be less than . + public static void LessThan(sbyte obj, sbyte value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(byte obj, byte value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(short obj, short value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(ushort obj, ushort value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(int obj, int value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(uint obj, uint value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(long obj, long value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(ulong obj, ulong value, string name, string msg = null) { if (obj >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, sbyte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, byte value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, short value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, ushort value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, int value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, uint value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, long value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + /// Value must be less than . + public static void LessThan(Optional obj, ulong value, string name, string msg = null) { if (obj.IsSpecified && obj.Value >= value) throw CreateLessThanException(name, msg, value); } + + private static ArgumentException CreateLessThanException(string name, string msg, T value) + => new ArgumentException(message: msg ?? $"Value must be less than {value}.", paramName: name); + #endregion + + #region Bulk Delete + /// Messages are younger than 2 weeks. + public static void YoungerThanTwoWeeks(ulong[] collection, string name) + { + var minimum = SnowflakeUtils.ToSnowflake(DateTimeOffset.UtcNow.Subtract(TimeSpan.FromDays(14))); + for (var i = 0; i < collection.Length; i++) + { + if (collection[i] == 0) + continue; + if (collection[i] <= minimum) + throw new ArgumentOutOfRangeException(name, "Messages must be younger than two weeks old."); + } + } + /// The everyone role cannot be assigned to a user. + public static void NotEveryoneRole(ulong[] roles, ulong guildId, string name) + { + for (var i = 0; i < roles.Length; i++) + { + if (roles[i] == guildId) + throw new ArgumentException(message: "The everyone role cannot be assigned to a user.", paramName: name); + } + } + #endregion + + #region SlashCommandOptions + + /// or is null. + /// or are either empty or their length exceed limits. + public static void Options(string name, string description) + { + // Make sure the name matches the requirements from discord + NotNullOrEmpty(name, nameof(name)); + NotNullOrEmpty(description, nameof(description)); + AtLeast(name.Length, 1, nameof(name)); + AtMost(name.Length, SlashCommandBuilder.MaxNameLength, nameof(name)); + AtLeast(description.Length, 1, nameof(description)); + AtMost(description.Length, SlashCommandBuilder.MaxDescriptionLength, nameof(description)); + } + + #endregion + } +} diff --git a/src/Discord.Net.Core/Utils/RoleUtils.cs b/src/Discord.Net.Core/Utils/RoleUtils.cs new file mode 100644 index 0000000..444afe6 --- /dev/null +++ b/src/Discord.Net.Core/Utils/RoleUtils.cs @@ -0,0 +1,18 @@ +namespace Discord +{ + internal static class RoleUtils + { + public static int Compare(IRole left, IRole right) + { + if (left == null) + return -1; + if (right == null) + return 1; + var result = left.Position.CompareTo(right.Position); + // As per Discord's documentation, a tie is broken by ID + if (result != 0) + return result; + return left.Id.CompareTo(right.Id); + } + } +} diff --git a/src/Discord.Net.Core/Utils/SnowflakeUtils.cs b/src/Discord.Net.Core/Utils/SnowflakeUtils.cs new file mode 100644 index 0000000..e52c993 --- /dev/null +++ b/src/Discord.Net.Core/Utils/SnowflakeUtils.cs @@ -0,0 +1,29 @@ +using System; + +namespace Discord +{ + /// + /// Provides a series of helper methods for handling snowflake identifiers. + /// + public static class SnowflakeUtils + { + /// + /// Resolves the time of which the snowflake is generated. + /// + /// The snowflake identifier to resolve. + /// + /// A representing the time for when the object is generated. + /// + public static DateTimeOffset FromSnowflake(ulong value) + => DateTimeOffset.FromUnixTimeMilliseconds((long)((value >> 22) + 1420070400000UL)); + /// + /// Generates a pseudo-snowflake identifier with a . + /// + /// The time to be used in the new snowflake. + /// + /// A representing the newly generated snowflake identifier. + /// + public static ulong ToSnowflake(DateTimeOffset value) + => ((ulong)value.ToUnixTimeMilliseconds() - 1420070400000UL) << 22; + } +} diff --git a/src/Discord.Net.Core/Utils/TokenUtils.cs b/src/Discord.Net.Core/Utils/TokenUtils.cs new file mode 100644 index 0000000..55e40f0 --- /dev/null +++ b/src/Discord.Net.Core/Utils/TokenUtils.cs @@ -0,0 +1,184 @@ +using System; +using System.Globalization; +using System.Text; + +namespace Discord +{ + /// + /// Provides a series of helper methods for handling Discord login tokens. + /// + public static class TokenUtils + { + /// + /// The minimum length of a Bot token. + /// + /// + /// This value was determined by comparing against the examples in the Discord + /// documentation, and pre-existing tokens. + /// + internal const int MinBotTokenLength = 58; + + internal const char Base64Padding = '='; + + /// + /// Pads a base64-encoded string with 0, 1, or 2 '=' characters, + /// if the string is not a valid multiple of 4. + /// Does not ensure that the provided string contains only valid base64 characters. + /// Strings that already contain padding will not have any more padding applied. + /// + /// + /// A string that would require 3 padding characters is considered to be already corrupt. + /// Some older bot tokens may require padding, as the format provided by Discord + /// does not include this padding in the token. + /// + /// The base64 encoded string to pad with characters. + /// A string containing the base64 padding. + /// + /// Thrown if would require an invalid number of padding characters. + /// + /// + /// Thrown if is null, empty, or whitespace. + /// + internal static string PadBase64String(string encodedBase64) + { + if (string.IsNullOrWhiteSpace(encodedBase64)) + throw new ArgumentNullException(paramName: encodedBase64, + message: "The supplied base64-encoded string was null or whitespace."); + + // do not pad if already contains padding characters + if (encodedBase64.IndexOf(Base64Padding) != -1) + return encodedBase64; + + // based from https://stackoverflow.com/a/1228744 + var padding = (4 - (encodedBase64.Length % 4)) % 4; + if (padding == 3) + // can never have 3 characters of padding + throw new FormatException("The provided base64 string is corrupt, as it requires an invalid amount of padding."); + else if (padding == 0) + return encodedBase64; + return encodedBase64.PadRight(encodedBase64.Length + padding, Base64Padding); + } + + /// + /// Decodes a base 64 encoded string into a ulong value. + /// + /// A base 64 encoded string containing a User Id. + /// A ulong containing the decoded value of the string, or null if the value was invalid. + internal static ulong? DecodeBase64UserId(string encoded) + { + if (string.IsNullOrWhiteSpace(encoded)) + return null; + + try + { + // re-add base64 padding if missing + encoded = PadBase64String(encoded); + // decode the base64 string + var bytes = Convert.FromBase64String(encoded); + var idStr = Encoding.UTF8.GetString(bytes); + // try to parse a ulong from the resulting string + if (ulong.TryParse(idStr, NumberStyles.None, CultureInfo.InvariantCulture, out var id)) + return id; + } + catch (DecoderFallbackException) + { + // ignore exception, can be thrown by GetString + } + catch (FormatException) + { + // ignore exception, can be thrown if base64 string is invalid + } + catch (ArgumentException) + { + // ignore exception, can be thrown by BitConverter, or by PadBase64String + } + return null; + } + + /// + /// Checks the validity of a bot token by attempting to decode a ulong userid + /// from the bot token. + /// + /// + /// The bot token to validate. + /// + /// + /// True if the bot token was valid, false if it was not. + /// + internal static bool CheckBotTokenValidity(string message) + { + if (string.IsNullOrWhiteSpace(message)) + return false; + + // split each component of the JWT + var segments = message.Split('.'); + + // ensure that there are three parts + if (segments.Length != 3) + return false; + // return true if the user id could be determined + return DecodeBase64UserId(segments[0]).HasValue; + } + + /// + /// The set of all characters that are not allowed inside of a token. + /// + internal static char[] IllegalTokenCharacters = new char[] + { + ' ', '\t', '\r', '\n' + }; + + /// + /// Checks if the given token contains a whitespace or newline character + /// that would fail to log in. + /// + /// The token to validate. + /// + /// True if the token contains a whitespace or newline character. + /// + internal static bool CheckContainsIllegalCharacters(string token) + => token.IndexOfAny(IllegalTokenCharacters) != -1; + + /// + /// Checks the validity of the supplied token of a specific type. + /// + /// The type of token to validate. + /// The token value to validate. + /// Thrown when the supplied token string is , empty, or contains only whitespace. + /// Thrown when the supplied or token value is invalid. + public static void ValidateToken(TokenType tokenType, string token) + { + // A Null or WhiteSpace token of any type is invalid. + if (string.IsNullOrWhiteSpace(token)) + throw new ArgumentNullException(paramName: nameof(token), message: "A token cannot be null, empty, or contain only whitespace."); + // ensure that there are no whitespace or newline characters + if (CheckContainsIllegalCharacters(token)) + throw new ArgumentException(message: "The token contains a whitespace or newline character. Ensure that the token has been properly trimmed.", paramName: nameof(token)); + + switch (tokenType) + { + case TokenType.Webhook: + // no validation is performed on Webhook tokens + break; + case TokenType.Bearer: + // no validation is performed on Bearer tokens + break; + case TokenType.Bot: + // bot tokens are assumed to be at least 58 characters in length + // this value was determined by referencing examples in the discord documentation, and by comparing with + // pre-existing tokens + if (token.Length < MinBotTokenLength) + throw new ArgumentException(message: $"A Bot token must be at least {MinBotTokenLength} characters in length. " + + "Ensure that the Bot Token provided is not an OAuth client secret.", paramName: nameof(token)); + // check the validity of the bot token by decoding the ulong userid from the jwt + if (!CheckBotTokenValidity(token)) + throw new ArgumentException(message: "The Bot token was invalid. " + + "Ensure that the Bot Token provided is not an OAuth client secret.", paramName: nameof(token)); + break; + default: + // All unrecognized TokenTypes (including User tokens) are considered to be invalid. + throw new ArgumentException(message: "Unrecognized TokenType.", paramName: nameof(token)); + } + } + } +} diff --git a/src/Discord.Net.Core/Utils/UrlValidation.cs b/src/Discord.Net.Core/Utils/UrlValidation.cs new file mode 100644 index 0000000..55ae3bd --- /dev/null +++ b/src/Discord.Net.Core/Utils/UrlValidation.cs @@ -0,0 +1,42 @@ +using System; + +namespace Discord.Utils +{ + internal static class UrlValidation + { + /// + /// Not full URL validation right now. Just ensures protocol is present and that it's either http or https + /// should be used for url buttons. + /// + /// The URL to validate before sending to Discord. + /// to allow the attachment:// protocol; otherwise . + /// A URL must include a protocol (http or https). + /// true if URL is valid by our standard, false if null, throws an error upon invalid. + public static bool Validate(string url, bool allowAttachments = false) + { + if (string.IsNullOrEmpty(url)) + return false; + if (!(url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || (allowAttachments ? url.StartsWith("attachment://", StringComparison.Ordinal) : false))) + throw new InvalidOperationException($"The url {url} must include a protocol (either {(allowAttachments ? "HTTP, HTTPS, or ATTACHMENT" : "HTTP or HTTPS")})"); + return true; + } + + /// + /// Not full URL validation right now. Just Ensures the protocol is either http, https, or discord + /// should be used everything other than url buttons. + /// + /// The URL to validate before sending to discord. + /// A URL must include a protocol (either http, https, or discord). + /// true if the URL is valid by our standard, false if null, throws an error upon invalid. + public static bool ValidateButton(string url) + { + if (string.IsNullOrEmpty(url)) + return false; + if (!(url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) || + url.StartsWith("discord://", StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"The url {url} must include a protocol (either HTTP, HTTPS, or DISCORD)"); + return true; + } + } +} diff --git a/src/Discord.Net.DebugTools/Discord.Net.DebugTools.csproj b/src/Discord.Net.DebugTools/Discord.Net.DebugTools.csproj new file mode 100644 index 0000000..c2bdaa6 --- /dev/null +++ b/src/Discord.Net.DebugTools/Discord.Net.DebugTools.csproj @@ -0,0 +1,15 @@ + + + + Discord.Net.DebugTools + Discord + A Discord.Net extension adding some helper classes for diagnosing issues. + net45;netstandard1.3 + + + + + + + + diff --git a/src/Discord.Net.DebugTools/UnstableRestClient.cs b/src/Discord.Net.DebugTools/UnstableRestClient.cs new file mode 100644 index 0000000..847b8d6 --- /dev/null +++ b/src/Discord.Net.DebugTools/UnstableRestClient.cs @@ -0,0 +1,154 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Rest +{ + internal sealed class UnstableRestClient : IRestClient, IDisposable + { + private const double FailureRate = 0.10; //10% + + private const int HR_SECURECHANNELFAILED = -2146233079; + + private readonly HttpClient _client; + private readonly string _baseUrl; + private readonly JsonSerializer _errorDeserializer; + private readonly Random _rand; + private CancellationToken _cancelToken; + private bool _isDisposed; + + public DefaultRestClient(string baseUrl) + { + _baseUrl = baseUrl; + + _client = new HttpClient(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + UseCookies = false, + UseProxy = false + }); + SetHeader("accept-encoding", "gzip, deflate"); + + _cancelToken = CancellationToken.None; + _errorDeserializer = new JsonSerializer(); + _rand = new Random(); + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + _client.Dispose(); + _isDisposed = true; + } + } + public void Dispose() + { + Dispose(true); + } + + public void SetHeader(string key, string value) + { + _client.DefaultRequestHeaders.Remove(key); + if (value != null) + _client.DefaultRequestHeaders.Add(key, value); + } + public void SetCancelToken(CancellationToken cancelToken) + { + _cancelToken = cancelToken; + } + + public async Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly) + { + string uri = Path.Combine(_baseUrl, endpoint); + using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) + return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); + } + public async Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly) + { + string uri = Path.Combine(_baseUrl, endpoint); + using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) + { + restRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); + return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); + } + } + public async Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, CancellationToken cancelToken, bool headerOnly) + { + string uri = Path.Combine(_baseUrl, endpoint); + using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) + { + var content = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)); + if (multipartParams != null) + { + foreach (var p in multipartParams) + { + switch (p.Value) + { + case string stringValue: { content.Add(new StringContent(stringValue), p.Key); continue; } + case byte[] byteArrayValue: { content.Add(new ByteArrayContent(byteArrayValue), p.Key); continue; } + case Stream streamValue: { content.Add(new StreamContent(streamValue), p.Key); continue; } + case MultipartFile fileValue: + { + var stream = fileValue.Stream; + if (!stream.CanSeek) + { + var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream).ConfigureAwait(false); + memoryStream.Position = 0; + stream = memoryStream; + } + content.Add(new StreamContent(stream), p.Key, fileValue.Filename); + continue; + } + default: throw new InvalidOperationException($"Unsupported param type \"{p.Value.GetType().Name}\""); + } + } + } + restRequest.Content = content; + return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); + } + } + + private async Task SendInternalAsync(HttpRequestMessage request, CancellationToken cancelToken, bool headerOnly) + { + if (!UnstableCheck()) + throw new TimeoutException(); + + cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cancelToken, cancelToken).Token; + HttpResponseMessage response = await _client.SendAsync(request, cancelToken).ConfigureAwait(false); + + var headers = response.Headers.ToDictionary(x => x.Key, x => x.Value.FirstOrDefault(), StringComparer.OrdinalIgnoreCase); + var stream = !headerOnly ? await response.Content.ReadAsStreamAsync().ConfigureAwait(false) : null; + + return new RestResponse(response.StatusCode, headers, stream); + } + + private static readonly HttpMethod _patch = new HttpMethod("PATCH"); + private HttpMethod GetMethod(string method) + { + switch (method) + { + case "DELETE": return HttpMethod.Delete; + case "GET": return HttpMethod.Get; + case "PATCH": return _patch; + case "POST": return HttpMethod.Post; + case "PUT": return HttpMethod.Put; + default: throw new ArgumentOutOfRangeException(nameof(method), $"Unknown HttpMethod: {method}"); + } + } + + private bool UnstableCheck() + { + return _rand.NextDouble() > FailureRate; + } + } +} diff --git a/src/Discord.Net.DebugTools/UnstableRestClientProvider.cs b/src/Discord.Net.DebugTools/UnstableRestClientProvider.cs new file mode 100644 index 0000000..80ed91c --- /dev/null +++ b/src/Discord.Net.DebugTools/UnstableRestClientProvider.cs @@ -0,0 +1,9 @@ +using Discord.Net.Rest; + +namespace Discord.Net.Providers.UnstableUdpSocket +{ + public static class UnstableRestClientProvider + { + public static readonly RestCientProvider Instance = () => new UnstableRestClientProvider(); + } +} diff --git a/src/Discord.Net.DebugTools/UnstableUdpClient.cs b/src/Discord.Net.DebugTools/UnstableUdpClient.cs new file mode 100644 index 0000000..297c689 --- /dev/null +++ b/src/Discord.Net.DebugTools/UnstableUdpClient.cs @@ -0,0 +1,142 @@ +using Discord.Net.Udp; +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Providers.UnstableUdpSocket +{ + internal class UnstableUdpSocket : IUdpSocket, IDisposable + { + private const double FailureRate = 0.10; //10% + + public event Func ReceivedDatagram; + + private readonly SemaphoreSlim _lock; + private readonly Random _rand; + private UdpClient _udp; + private IPEndPoint _destination; + private CancellationTokenSource _cancelTokenSource; + private CancellationToken _cancelToken, _parentToken; + private Task _task; + private bool _isDisposed; + + public UnstableUdpSocket() + { + _lock = new SemaphoreSlim(1, 1); + _rand = new Random(); + _cancelTokenSource = new CancellationTokenSource(); + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + StopInternalAsync(true).GetAwaiter().GetResult(); + _isDisposed = true; + } + } + public void Dispose() + { + Dispose(true); + } + + + public async Task StartAsync() + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await StartInternalAsync(_cancelToken).ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + public async Task StartInternalAsync(CancellationToken cancelToken) + { + await StopInternalAsync().ConfigureAwait(false); + + _cancelTokenSource = new CancellationTokenSource(); + _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _cancelTokenSource.Token).Token; + + _udp = new UdpClient(0); + + _task = RunAsync(_cancelToken); + } + public async Task StopAsync() + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await StopInternalAsync().ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + public async Task StopInternalAsync(bool isDisposing = false) + { + try { _cancelTokenSource.Cancel(false); } catch { } + + if (!isDisposing) + await (_task ?? Task.Delay(0)).ConfigureAwait(false); + + if (_udp != null) + { + try { _udp.Dispose(); } + catch { } + _udp = null; + } + } + + public void SetDestination(string host, int port) + { + var entry = Dns.GetHostEntryAsync(host).GetAwaiter().GetResult(); + _destination = new IPEndPoint(entry.AddressList[0], port); + } + public void SetCancelToken(CancellationToken cancelToken) + { + _parentToken = cancelToken; + _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _cancelTokenSource.Token).Token; + } + + public async Task SendAsync(byte[] data, int index, int count) + { + if (!UnstableCheck()) + return; + + if (index != 0) //Should never happen? + { + var newData = new byte[count]; + Buffer.BlockCopy(data, index, newData, 0, count); + data = newData; + } + + await _udp.SendAsync(data, count, _destination).ConfigureAwait(false); + } + + private async Task RunAsync(CancellationToken cancelToken) + { + var closeTask = Task.Delay(-1, cancelToken); + while (!cancelToken.IsCancellationRequested) + { + var receiveTask = _udp.ReceiveAsync(); + var task = await Task.WhenAny(closeTask, receiveTask).ConfigureAwait(false); + if (task == closeTask) + break; + + var result = receiveTask.Result; + await ReceivedDatagram(result.Buffer, 0, result.Buffer.Length).ConfigureAwait(false); + } + } + + private bool UnstableCheck() + { + return _rand.NextDouble() > FailureRate; + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.DebugTools/UnstableUdpClientProvider.cs b/src/Discord.Net.DebugTools/UnstableUdpClientProvider.cs new file mode 100644 index 0000000..e785146 --- /dev/null +++ b/src/Discord.Net.DebugTools/UnstableUdpClientProvider.cs @@ -0,0 +1,9 @@ +using Discord.Net.Udp; + +namespace Discord.Net.Providers.UnstableUdpSocket +{ + public static class UnstableUdpSocketProvider + { + public static readonly UdpSocketProvider Instance = () => new UnstableUdpSocket(); + } +} diff --git a/src/Discord.Net.DebugTools/UnstableWebSocketClient.cs b/src/Discord.Net.DebugTools/UnstableWebSocketClient.cs new file mode 100644 index 0000000..4f45503 --- /dev/null +++ b/src/Discord.Net.DebugTools/UnstableWebSocketClient.cs @@ -0,0 +1,259 @@ +using Discord.Net.WebSockets; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Providers.UnstableWebSocket +{ + internal class UnstableWebSocketClient : IWebSocketClient, IDisposable + { + public const int ReceiveChunkSize = 16 * 1024; //16KB + public const int SendChunkSize = 4 * 1024; //4KB + private const int HR_TIMEOUT = -2147012894; + private const double FailureRate = 0.10; //10% + + public event Func BinaryMessage; + public event Func TextMessage; + public event Func Closed; + + private readonly SemaphoreSlim _lock; + private readonly Dictionary _headers; + private readonly Random _rand; + private ClientWebSocket _client; + private Task _task; + private CancellationTokenSource _cancelTokenSource; + private CancellationToken _cancelToken, _parentToken; + private bool _isDisposed, _isDisconnecting; + + public UnstableWebSocketClient() + { + _lock = new SemaphoreSlim(1, 1); + _rand = new Random(); + _cancelTokenSource = new CancellationTokenSource(); + _cancelToken = CancellationToken.None; + _parentToken = CancellationToken.None; + _headers = new Dictionary(); + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + DisconnectInternalAsync(true).GetAwaiter().GetResult(); + _isDisposed = true; + } + } + public void Dispose() + { + Dispose(true); + } + + public async Task ConnectAsync(string host) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(host).ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + private async Task ConnectInternalAsync(string host) + { + await DisconnectInternalAsync().ConfigureAwait(false); + + _cancelTokenSource = new CancellationTokenSource(); + _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _cancelTokenSource.Token).Token; + + _client = new ClientWebSocket(); + _client.Options.Proxy = null; + _client.Options.KeepAliveInterval = TimeSpan.Zero; + foreach (var header in _headers) + { + if (header.Value != null) + _client.Options.SetRequestHeader(header.Key, header.Value); + } + + await _client.ConnectAsync(new Uri(host), _cancelToken).ConfigureAwait(false); + _task = RunAsync(_cancelToken); + } + + public async Task DisconnectAsync() + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync().ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + private async Task DisconnectInternalAsync(bool isDisposing = false) + { + try { _cancelTokenSource.Cancel(false); } catch { } + + _isDisconnecting = true; + try + { + await (_task ?? Task.Delay(0)).ConfigureAwait(false); + _task = null; + } + finally { _isDisconnecting = false; } + + if (_client != null) + { + if (!isDisposing) + { + try { await _client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "", new CancellationToken()); } + catch { } + } + try { _client.Dispose(); } + catch { } + + _client = null; + } + } + private async Task OnClosed(Exception ex) + { + if (_isDisconnecting) + return; //Ignore, this disconnect was requested. + + System.Diagnostics.Debug.WriteLine("OnClosed - " + ex.Message); + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(false); + } + finally + { + _lock.Release(); + } + await Closed(ex); + } + + public void SetHeader(string key, string value) + { + _headers[key] = value; + } + public void SetCancelToken(CancellationToken cancelToken) + { + _parentToken = cancelToken; + _cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _cancelTokenSource.Token).Token; + } + + public async Task SendAsync(byte[] data, int index, int count, bool isText) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + if (!UnstableCheck()) + return; + + if (_client == null) return; + + int frameCount = (int)Math.Ceiling((double)count / SendChunkSize); + + for (int i = 0; i < frameCount; i++, index += SendChunkSize) + { + bool isLast = i == (frameCount - 1); + + int frameSize; + if (isLast) + frameSize = count - (i * SendChunkSize); + else + frameSize = SendChunkSize; + + var type = isText ? WebSocketMessageType.Text : WebSocketMessageType.Binary; + await _client.SendAsync(new ArraySegment(data, index, count), type, isLast, _cancelToken).ConfigureAwait(false); + } + } + finally + { + _lock.Release(); + } + } + + private async Task RunAsync(CancellationToken cancelToken) + { + var buffer = new ArraySegment(new byte[ReceiveChunkSize]); + + try + { + while (!cancelToken.IsCancellationRequested) + { + WebSocketReceiveResult socketResult = await _client.ReceiveAsync(buffer, cancelToken).ConfigureAwait(false); + byte[] result; + int resultCount; + + if (socketResult.MessageType == WebSocketMessageType.Close) + throw new WebSocketClosedException((int)socketResult.CloseStatus, socketResult.CloseStatusDescription); + + if (!socketResult.EndOfMessage) + { + //This is a large message (likely just READY), lets create a temporary expandable stream + using (var stream = new MemoryStream()) + { + stream.Write(buffer.Array, 0, socketResult.Count); + do + { + if (cancelToken.IsCancellationRequested) return; + socketResult = await _client.ReceiveAsync(buffer, cancelToken).ConfigureAwait(false); + stream.Write(buffer.Array, 0, socketResult.Count); + } + while (socketResult == null || !socketResult.EndOfMessage); + + //Use the internal buffer if we can get it + resultCount = (int)stream.Length; +#if MSTRYBUFFER + if (stream.TryGetBuffer(out var streamBuffer)) + result = streamBuffer.Array; + else + result = stream.ToArray(); +#else + result = stream.GetBuffer(); +#endif + } + } + else + { + //Small message + resultCount = socketResult.Count; + result = buffer.Array; + } + + if (socketResult.MessageType == WebSocketMessageType.Text) + { + string text = Encoding.UTF8.GetString(result, 0, resultCount); + await TextMessage(text).ConfigureAwait(false); + } + else + await BinaryMessage(result, 0, resultCount).ConfigureAwait(false); + } + } + catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) + { + var _ = OnClosed(new Exception("Connection timed out.", ex)); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + //This cannot be awaited otherwise we'll deadlock when DiscordApiClient waits for this task to complete. + var _ = OnClosed(ex); + } + } + + private bool UnstableCheck() + { + return _rand.NextDouble() > FailureRate; + } + } +} \ No newline at end of file diff --git a/src/Discord.Net.DebugTools/UnstableWebSocketClientProvider.cs b/src/Discord.Net.DebugTools/UnstableWebSocketClientProvider.cs new file mode 100644 index 0000000..9619e88 --- /dev/null +++ b/src/Discord.Net.DebugTools/UnstableWebSocketClientProvider.cs @@ -0,0 +1,9 @@ +using Discord.Net.WebSockets; + +namespace Discord.Net.Providers.UnstableWebSocket +{ + public static class UnstableWebSocketProvider + { + public static readonly WebSocketProvider Instance = () => new UnstableWebSocketClient(); + } +} diff --git a/src/Discord.Net.Examples/Discord.Net.Examples.csproj b/src/Discord.Net.Examples/Discord.Net.Examples.csproj new file mode 100644 index 0000000..5fca615 --- /dev/null +++ b/src/Discord.Net.Examples/Discord.Net.Examples.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + + + + + + + + + + + + + + + + + diff --git a/src/Discord.Net.Interactions/Attributes/AutocompleteAttribute.cs b/src/Discord.Net.Interactions/Attributes/AutocompleteAttribute.cs new file mode 100644 index 0000000..c8a3428 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/AutocompleteAttribute.cs @@ -0,0 +1,36 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the to . + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class AutocompleteAttribute : Attribute + { + /// + /// Type of the . + /// + public Type AutocompleteHandlerType { get; } + + /// + /// Set the to and define a to handle + /// Autocomplete interactions targeting the parameter this is applied to. + /// + /// + /// must be set to to use this constructor. + /// + public AutocompleteAttribute(Type autocompleteHandlerType) + { + if (!typeof(IAutocompleteHandler).IsAssignableFrom(autocompleteHandlerType)) + throw new InvalidOperationException($"{autocompleteHandlerType.FullName} isn't a valid {nameof(IAutocompleteHandler)} type"); + + AutocompleteHandlerType = autocompleteHandlerType; + } + + /// + /// Set the to without specifying a . + /// + public AutocompleteAttribute() { } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ChannelTypesAttribute.cs b/src/Discord.Net.Interactions/Attributes/ChannelTypesAttribute.cs new file mode 100644 index 0000000..1f498e3 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ChannelTypesAttribute.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Specify the target channel types for a option. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class ChannelTypesAttribute : Attribute + { + /// + /// Gets the allowed channel types for this option. + /// + public IReadOnlyCollection ChannelTypes { get; } + + /// + /// Specify the target channel types for a option. + /// + /// The allowed channel types for this option. + public ChannelTypesAttribute(params ChannelType[] channelTypes) + { + if (channelTypes is null) + throw new ArgumentNullException(nameof(channelTypes)); + + ChannelTypes = channelTypes.ToImmutableArray(); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ChoiceAttribute.cs b/src/Discord.Net.Interactions/Attributes/ChoiceAttribute.cs new file mode 100644 index 0000000..b20c575 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ChoiceAttribute.cs @@ -0,0 +1,64 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Add a pre-determined argument value to a command parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] + public class ChoiceAttribute : Attribute + { + /// + /// Gets the name of the choice. + /// + public string Name { get; } + + /// + /// Gets the type of this choice. + /// + public SlashCommandChoiceType Type { get; } + + /// + /// Gets the value that will be used whenever this choice is selected. + /// + public object Value { get; } + + private ChoiceAttribute(string name) + { + Name = name; + } + + /// + /// Create a parameter choice with type . + /// + /// Name of the choice. + /// Predefined value of the choice. + public ChoiceAttribute(string name, string value) : this(name) + { + Type = SlashCommandChoiceType.String; + Value = value; + } + + /// + /// Create a parameter choice with type . + /// + /// Name of the choice. + /// Predefined value of the choice. + public ChoiceAttribute(string name, int value) : this(name) + { + Type = SlashCommandChoiceType.Integer; + Value = value; + } + + /// + /// Create a parameter choice with type . + /// + /// Name of the choice. + /// Predefined value of the choice. + public ChoiceAttribute(string name, double value) : this(name) + { + Type = SlashCommandChoiceType.Number; + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/CommandContextTypeAttribute.cs b/src/Discord.Net.Interactions/Attributes/CommandContextTypeAttribute.cs new file mode 100644 index 0000000..1d4c687 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/CommandContextTypeAttribute.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions; + +/// +/// Specifies context types this command can be executed in. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public class CommandContextTypeAttribute : Attribute +{ + /// + /// Gets context types this command can be executed in. + /// + public IReadOnlyCollection ContextTypes { get; } + + /// + /// Sets the property of an application command or module. + /// + /// Context types set for the command. + public CommandContextTypeAttribute(params InteractionContextType[] contextTypes) + { + ContextTypes = contextTypes?.Distinct().ToImmutableArray() + ?? throw new ArgumentNullException(nameof(contextTypes)); + + if (ContextTypes.Count == 0) + throw new ArgumentException("A command must have at least one supported context type.", nameof(contextTypes)); + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/AutocompleteCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/AutocompleteCommandAttribute.cs new file mode 100644 index 0000000..df46dcf --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/AutocompleteCommandAttribute.cs @@ -0,0 +1,39 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create an Autocomplete Command. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class AutocompleteCommandAttribute : Attribute + { + /// + /// Gets the name of the target parameter. + /// + public string ParameterName { get; } + + /// + /// Gets the name of the target command. + /// + public string CommandName { get; } + + /// + /// Get the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Create a command for Autocomplete interaction handling. + /// + /// Name of the target parameter. + /// Name of the target command. + /// Set the run mode of the command. + public AutocompleteCommandAttribute(string parameterName, string commandName, RunMode runMode = RunMode.Default) + { + ParameterName = parameterName; + CommandName = commandName; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/ComponentInteractionAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/ComponentInteractionAttribute.cs new file mode 100644 index 0000000..e615a9b --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/ComponentInteractionAttribute.cs @@ -0,0 +1,53 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Discord.Interactions +{ + /// + /// Create a Message Component interaction handler, CustomId represents + /// the CustomId of the Message Component that will be handled. + /// + /// + /// s will add prefixes to this command if is set to + /// CustomID supports a Wild Card pattern where you can use the to match a set of CustomIDs. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class ComponentInteractionAttribute : Attribute + { + /// + /// Gets the string to compare the Message Component CustomIDs with. + /// + public string CustomId { get; } + + /// + /// Gets if s will be ignored while creating this command and this method will be treated as a top level command. + /// + public bool IgnoreGroupNames { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Gets or sets whether the should be treated as a raw Regex pattern. + /// + /// + /// defaults to the pattern used before 3.9.0. + /// + public bool TreatAsRegex { get; set; } = false; + + /// + /// Create a command for component interaction handling. + /// + /// String to compare the Message Component CustomIDs with. + /// If s will be ignored while creating this command and this method will be treated as a top level command. + /// Set the run mode of the command. + public ComponentInteractionAttribute(string customId, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) + { + CustomId = customId; + IgnoreGroupNames = ignoreGroupNames; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/ContextCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/ContextCommandAttribute.cs new file mode 100644 index 0000000..7144206 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/ContextCommandAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Reflection; + +namespace Discord.Interactions +{ + /// + /// Base attribute for creating a Context Commands. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public abstract class ContextCommandAttribute : Attribute + { + /// + /// Gets the name of this Context Command. + /// + public string Name { get; } + + /// + /// Gets the type of this Context Command. + /// + public ApplicationCommandType CommandType { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + internal ContextCommandAttribute(string name, ApplicationCommandType commandType, RunMode runMode = RunMode.Default) + { + Name = name; + CommandType = commandType; + RunMode = runMode; + } + + internal virtual void CheckMethodDefinition(MethodInfo methodInfo) { } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/MessageCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/MessageCommandAttribute.cs new file mode 100644 index 0000000..d8f4b9b --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/MessageCommandAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.Reflection; + +namespace Discord.Interactions +{ + /// + /// Create a Message Context Command. + /// + /// + /// s won't add prefixes to this command. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class MessageCommandAttribute : ContextCommandAttribute + { + /// + /// Register a method as a Message Context Command. + /// + /// Name of the context command. + public MessageCommandAttribute(string name) : base(name, ApplicationCommandType.Message) { } + + internal override void CheckMethodDefinition(MethodInfo methodInfo) + { + var parameters = methodInfo.GetParameters(); + + if (parameters.Length != 1 || !typeof(IMessage).IsAssignableFrom(parameters[0].ParameterType)) + throw new InvalidOperationException($"Message Commands must have only one parameter that is a type of {nameof(IMessage)}"); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs new file mode 100644 index 0000000..c88ed7a --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/ModalInteractionAttribute.cs @@ -0,0 +1,52 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create a Modal interaction handler. CustomId represents + /// the CustomId of the Modal that will be handled. + /// + /// + /// s will add prefixes to this command if is set to + /// CustomID supports a Wild Card pattern where you can use the to match a set of CustomIDs. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public sealed class ModalInteractionAttribute : Attribute + { + /// + /// Gets the string to compare the Modal CustomIDs with. + /// + public string CustomId { get; } + + /// + /// Gets if s will be ignored while creating this command and this method will be treated as a top level command. + /// + public bool IgnoreGroupNames { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Gets or sets whether the should be treated as a raw Regex pattern. + /// + /// + /// defaults to the pattern used before 3.9.0. + /// + public bool TreatAsRegex { get; set; } = false; + + /// + /// Create a command for modal interaction handling. + /// + /// String to compare the modal CustomIDs with. + /// If s will be ignored while creating this command and this method will be treated as a top level command. + /// Set the run mode of the command. + public ModalInteractionAttribute(string customId, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) + { + CustomId = customId; + IgnoreGroupNames = ignoreGroupNames; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/SlashCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/SlashCommandAttribute.cs new file mode 100644 index 0000000..4e2f201 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/SlashCommandAttribute.cs @@ -0,0 +1,49 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create an Slash Application Command. + /// + /// + /// prefix will be used to created nested Slash Application Commands. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class SlashCommandAttribute : Attribute + { + /// + /// Gets the name of the Slash Command. + /// + public string Name { get; } + + /// + /// Gets the description of the Slash Command. + /// + public string Description { get; } + + /// + /// Gets if s will be ignored while creating this command and this method will be treated as a top level command. + /// + public bool IgnoreGroupNames { get; } + + /// + /// Gets the run mode this command gets executed with. + /// + public RunMode RunMode { get; } + + /// + /// Register a method as a Slash Command. + /// + /// Name of the command. + /// Description of the command. + /// If , s will be ignored while creating this command and this method will be treated as a top level command. + /// Set the run mode of the command. + public SlashCommandAttribute(string name, string description, bool ignoreGroupNames = false, RunMode runMode = RunMode.Default) + { + Name = name; + Description = description; + IgnoreGroupNames = ignoreGroupNames; + RunMode = runMode; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Commands/UserCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/Commands/UserCommandAttribute.cs new file mode 100644 index 0000000..60a493f --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Commands/UserCommandAttribute.cs @@ -0,0 +1,29 @@ +using System; +using System.Reflection; + +namespace Discord.Interactions +{ + /// + /// Create an User Context Command. + /// + /// + /// s won't add prefixes to this command. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class UserCommandAttribute : ContextCommandAttribute + { + /// + /// Register a command as a User Context Command. + /// + /// Name of this User Context Command. + public UserCommandAttribute(string name) : base(name, ApplicationCommandType.User) { } + + internal override void CheckMethodDefinition(MethodInfo methodInfo) + { + var parameters = methodInfo.GetParameters(); + + if (parameters.Length != 1 || !typeof(IUser).IsAssignableFrom(parameters[0].ParameterType)) + throw new InvalidOperationException($"User Commands must have only one parameter that is a type of {nameof(IUser)}"); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ComplexParameterAttribute.cs b/src/Discord.Net.Interactions/Attributes/ComplexParameterAttribute.cs new file mode 100644 index 0000000..952ca06 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ComplexParameterAttribute.cs @@ -0,0 +1,30 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Registers a parameter as a complex parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class ComplexParameterAttribute : Attribute + { + /// + /// Gets the parameter array of the constructor method that should be prioritized. + /// + public Type[] PrioritizedCtorSignature { get; } + + /// + /// Registers a slash command parameter as a complex parameter. + /// + public ComplexParameterAttribute() { } + + /// + /// Registers a slash command parameter as a complex parameter with a specified constructor signature. + /// + /// Type array of the preferred constructor parameters. + public ComplexParameterAttribute(Type[] types) + { + PrioritizedCtorSignature = types; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ComplexParameterCtorAttribute.cs b/src/Discord.Net.Interactions/Attributes/ComplexParameterCtorAttribute.cs new file mode 100644 index 0000000..59ee337 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ComplexParameterCtorAttribute.cs @@ -0,0 +1,10 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Tag a type constructor as the preferred Complex command constructor. + /// + [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = true)] + public class ComplexParameterCtorAttribute : Attribute { } +} diff --git a/src/Discord.Net.Interactions/Attributes/DefaultMemberPermissionAttribute.cs b/src/Discord.Net.Interactions/Attributes/DefaultMemberPermissionAttribute.cs new file mode 100644 index 0000000..ec79da1 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/DefaultMemberPermissionAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Sets the of an application command or module. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class DefaultMemberPermissionsAttribute : Attribute + { + /// + /// Gets the default permission required to use this command. + /// + public GuildPermission Permissions { get; } + + /// + /// Sets the of an application command or module. + /// + /// The default permission required to use this command. + public DefaultMemberPermissionsAttribute(GuildPermission permissions) + { + Permissions = permissions; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/DefaultPermissionAttribute.cs b/src/Discord.Net.Interactions/Attributes/DefaultPermissionAttribute.cs new file mode 100644 index 0000000..99d73ea --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/DefaultPermissionAttribute.cs @@ -0,0 +1,26 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the "Default Permission" property of an Application Command. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + [Obsolete($"Soon to be deprecated, use Permissions-v2 attributes like {nameof(EnabledInDmAttribute)} and {nameof(DefaultMemberPermissionsAttribute)}")] + public class DefaultPermissionAttribute : Attribute + { + /// + /// Gets whether the users are allowed to use a Slash Command by default or not. + /// + public bool IsDefaultPermission { get; } + + /// + /// Set the default permission of a Slash Command. + /// + /// if the users are allowed to use this command. + public DefaultPermissionAttribute(bool isDefaultPermission) + { + IsDefaultPermission = isDefaultPermission; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/DontAutoRegisterAttribute.cs b/src/Discord.Net.Interactions/Attributes/DontAutoRegisterAttribute.cs new file mode 100644 index 0000000..18deb78 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/DontAutoRegisterAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// s with this attribute will not be registered by the or + /// methods. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class DontAutoRegisterAttribute : Attribute + { + } +} diff --git a/src/Discord.Net.Interactions/Attributes/EnabledInDmAttribute.cs b/src/Discord.Net.Interactions/Attributes/EnabledInDmAttribute.cs new file mode 100644 index 0000000..dedab9e --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/EnabledInDmAttribute.cs @@ -0,0 +1,26 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Sets the property of an application command or module. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + [Obsolete("This attribute will be deprecated soon. Configure with CommandContextTypes attribute instead.")] + public class EnabledInDmAttribute : Attribute + { + /// + /// Gets whether or not this command can be used in DMs. + /// + public bool IsEnabled { get; } + + /// + /// Sets the property of an application command or module. + /// + /// Whether or not this command can be used in DMs. + public EnabledInDmAttribute(bool isEnabled) + { + IsEnabled = isEnabled; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/EnumChoiceAttribute.cs b/src/Discord.Net.Interactions/Attributes/EnumChoiceAttribute.cs new file mode 100644 index 0000000..c7f83b6 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/EnumChoiceAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Customize the displayed value of a slash command choice enum. Only works with the default enum type converter. + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] + public class ChoiceDisplayAttribute : Attribute + { + /// + /// Gets the name of the parameter. + /// + public string Name { get; } = null; + + /// + /// Modify the default name and description values of a Slash Command parameter. + /// + /// Name of the parameter. + public ChoiceDisplayAttribute(string name) + { + Name = name; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/GroupAttribute.cs b/src/Discord.Net.Interactions/Attributes/GroupAttribute.cs new file mode 100644 index 0000000..e3d892c --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/GroupAttribute.cs @@ -0,0 +1,35 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Create nested Slash Commands by marking a module as a command group. + /// + /// + /// commands wil not be affected by this. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public class GroupAttribute : Attribute + { + /// + /// Gets the name of the group. + /// + public string Name { get; } + + /// + /// Gets the description of the group. + /// + public string Description { get; } + + /// + /// Create a command group. + /// + /// Name of the group. + /// Description of the group. + public GroupAttribute(string name, string description) + { + Name = name; + Description = description; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/IntegrationTypeAttribute.cs b/src/Discord.Net.Interactions/Attributes/IntegrationTypeAttribute.cs new file mode 100644 index 0000000..2fa0d9c --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/IntegrationTypeAttribute.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions; + +/// +/// Specifies install method for the command. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public class IntegrationTypeAttribute : Attribute +{ + /// + /// Gets integration install types for this command. + /// + public IReadOnlyCollection IntegrationTypes { get; } + + /// + /// Sets the property of an application command or module. + /// + /// Integration install types set for the command. + public IntegrationTypeAttribute(params ApplicationIntegrationType[] integrationTypes) + { + IntegrationTypes = integrationTypes?.Distinct().ToImmutableArray() + ?? throw new ArgumentNullException(nameof(integrationTypes)); + + if (integrationTypes.Length == 0) + throw new ArgumentException("A command must have at least one integration type.", nameof(integrationTypes)); + } +} diff --git a/src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs b/src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs new file mode 100644 index 0000000..2172886 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/MaxLengthAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Sets the maximum length allowed for a string type parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class MaxLengthAttribute : Attribute + { + /// + /// Gets the maximum length allowed for a string type parameter. + /// + public int Length { get; } + + /// + /// Sets the maximum length allowed for a string type parameter. + /// + /// Maximum string length allowed. + public MaxLengthAttribute(int length) + { + Length = length; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/MaxValueAttribute.cs b/src/Discord.Net.Interactions/Attributes/MaxValueAttribute.cs new file mode 100644 index 0000000..1b46a4e --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/MaxValueAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the maximum value permitted for a number type parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class MaxValueAttribute : Attribute + { + /// + /// Gets the maximum value permitted. + /// + public double Value { get; } + + /// + /// Set the maximum value permitted for a number type parameter. + /// + /// The maximum value permitted. + public MaxValueAttribute(double value) + { + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs b/src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs new file mode 100644 index 0000000..8050f99 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/MinLengthAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Sets the minimum length allowed for a string type parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class MinLengthAttribute : Attribute + { + /// + /// Gets the minimum length allowed for a string type parameter. + /// + public int Length { get; } + + /// + /// Sets the minimum length allowed for a string type parameter. + /// + /// Minimum string length allowed. + public MinLengthAttribute(int length) + { + Length = length; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/MinValueAttribute.cs b/src/Discord.Net.Interactions/Attributes/MinValueAttribute.cs new file mode 100644 index 0000000..cce7a3b --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/MinValueAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Set the minimum value permitted for a number type parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public sealed class MinValueAttribute : Attribute + { + /// + /// Gets the minimum value permitted. + /// + public double Value { get; } + + /// + /// Set the minimum value permitted for a number type parameter. + /// + /// The minimum value permitted. + public MinValueAttribute(double value) + { + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs new file mode 100644 index 0000000..fdeb8c4 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/InputLabelAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Creates a custom label for an modal input. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public class InputLabelAttribute : Attribute + { + /// + /// Gets the label of the input. + /// + public string Label { get; } + + /// + /// Creates a custom label for an modal input. + /// + /// The label of the input. + public InputLabelAttribute(string label) + { + Label = label; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs new file mode 100644 index 0000000..e9b8772 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalInputAttribute.cs @@ -0,0 +1,30 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Mark an property as a modal input field. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] + public abstract class ModalInputAttribute : Attribute + { + /// + /// Gets the custom id of the text input. + /// + public string CustomId { get; } + + /// + /// Gets the type of the component. + /// + public abstract ComponentType ComponentType { get; } + + /// + /// Create a new . + /// + /// The custom id of the input. + protected ModalInputAttribute(string customId) + { + CustomId = customId; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs new file mode 100644 index 0000000..4439e1d --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/ModalTextInputAttribute.cs @@ -0,0 +1,55 @@ +namespace Discord.Interactions +{ + /// + /// Marks a property as a text input. + /// + public sealed class ModalTextInputAttribute : ModalInputAttribute + { + /// + public override ComponentType ComponentType => ComponentType.TextInput; + + /// + /// Gets the style of the text input. + /// + public TextInputStyle Style { get; } + + /// + /// Gets the placeholder of the text input. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum length of the text input. + /// + public int MinLength { get; } + + /// + /// Gets the maximum length of the text input. + /// + public int MaxLength { get; } + + /// + /// Gets the initial value to be displayed by this input. + /// + public string InitialValue { get; } + + /// + /// Create a new . + /// + /// The custom id of the text input.> + /// The style of the text input. + /// The placeholder of the text input. + /// The minimum length of the text input's content. + /// The maximum length of the text input's content. + /// The initial value to be displayed by this input. + public ModalTextInputAttribute(string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, int minLength = 1, int maxLength = 4000, string initValue = null) + : base(customId) + { + Style = style; + Placeholder = placeholder; + MinLength = minLength; + MaxLength = maxLength; + InitialValue = initValue; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs b/src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs new file mode 100644 index 0000000..1f580ff --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Modals/RequiredInputAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Sets the input as required or optional. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + public class RequiredInputAttribute : Attribute + { + /// + /// Gets whether or not user input is required for this input. + /// + public bool IsRequired { get; } + + /// + /// Sets the input as required or optional. + /// + /// Whether or not user input is required for this input. + public RequiredInputAttribute(bool isRequired = true) + { + IsRequired = isRequired; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/NsfwCommandAttribute.cs b/src/Discord.Net.Interactions/Attributes/NsfwCommandAttribute.cs new file mode 100644 index 0000000..b218edb --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/NsfwCommandAttribute.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Sets the property of an application command or module. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class NsfwCommandAttribute : Attribute + { + /// + /// Gets whether or not this command is age restricted. + /// + public bool IsNsfw { get; } + + /// + /// Sets the property of an application command or module. + /// + /// Whether or not this command is age restricted. + public NsfwCommandAttribute(bool isNsfw) + { + IsNsfw = isNsfw; + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/ParameterPreconditionAttribute.cs b/src/Discord.Net.Interactions/Attributes/ParameterPreconditionAttribute.cs new file mode 100644 index 0000000..2687aa9 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/ParameterPreconditionAttribute.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the parameter to pass the specified precondition before execution can begin. + /// + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true, Inherited = true)] + public abstract class ParameterPreconditionAttribute : Attribute + { + /// + /// Gets the error message to be returned if execution context doesn't pass the precondition check. + /// + /// + /// When overridden in a derived class, uses the supplied string + /// as the error message if the precondition doesn't pass. + /// Setting this for a class that doesn't override + /// this property is a no-op. + /// + public virtual string ErrorMessage { get; } + + /// + /// Checks whether the condition is met before execution of the command. + /// + /// The context of the command. + /// The parameter of the command being checked against. + /// The raw value of the parameter. + /// The service collection used for dependency injection. + public abstract Task CheckRequirementsAsync(IInteractionContext context, IParameterInfo parameterInfo, object value, + IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Attributes/PreconditionAttribute.cs b/src/Discord.Net.Interactions/Attributes/PreconditionAttribute.cs new file mode 100644 index 0000000..706f648 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/PreconditionAttribute.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the module or class to pass the specified precondition before execution can begin. + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public abstract class PreconditionAttribute : Attribute + { + /// + /// Gets the group that this precondition belongs to. + /// + /// + /// of the same group require only one of the preconditions to pass in order to + /// be successful (A || B). Specifying = or not at all will + /// require *all* preconditions to pass, just like normal (A && B). + /// + public string Group { get; set; } = null; + + /// + /// Gets the error message to be returned if execution context doesn't pass the precondition check. + /// + /// + /// When overridden in a derived class, uses the supplied string + /// as the error message if the precondition doesn't pass. + /// Setting this for a class that doesn't override + /// this property is a no-op. + /// + public virtual string ErrorMessage { get; } + + /// + /// Checks if the command to be executed meets the precondition requirements. + /// + /// The context of the command. + /// The command being executed. + /// The service collection used for dependency injection. + public abstract Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Preconditions/RequireBotPermissionAttribute.cs b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireBotPermissionAttribute.cs new file mode 100644 index 0000000..1dd3092 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireBotPermissionAttribute.cs @@ -0,0 +1,84 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the bot to have a specific permission in the channel a command is invoked in. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireBotPermissionAttribute : PreconditionAttribute + { + /// + /// Gets the specified of the precondition. + /// + public GuildPermission? GuildPermission { get; } + /// + /// Gets the specified of the precondition. + /// + public ChannelPermission? ChannelPermission { get; } + /// + /// Gets or sets the error message if the precondition + /// fails due to being run outside of a Guild channel. + /// + public string NotAGuildErrorMessage { get; set; } + + /// + /// Requires the bot account to have a specific . + /// + /// + /// This precondition will always fail if the command is being invoked in a . + /// + /// + /// The that the bot must have. Multiple permissions can be specified + /// by ORing the permissions together. + /// + public RequireBotPermissionAttribute(GuildPermission permission) + { + GuildPermission = permission; + ChannelPermission = null; + } + /// + /// Requires that the bot account to have a specific . + /// + /// + /// The that the bot must have. Multiple permissions can be + /// specified by ORing the permissions together. + /// + public RequireBotPermissionAttribute(ChannelPermission permission) + { + ChannelPermission = permission; + GuildPermission = null; + } + + /// + public override async Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + { + IGuildUser guildUser = null; + if (context.Guild != null) + guildUser = await context.Guild.GetCurrentUserAsync().ConfigureAwait(false); + + if (GuildPermission.HasValue) + { + if (guildUser == null) + return PreconditionResult.FromError(NotAGuildErrorMessage ?? "Command must be used in a guild channel."); + if (!guildUser.GuildPermissions.Has(GuildPermission.Value)) + return PreconditionResult.FromError(ErrorMessage ?? $"Bot requires guild permission {GuildPermission.Value}."); + } + + if (ChannelPermission.HasValue) + { + ChannelPermissions perms; + if (context.Channel is IGuildChannel guildChannel) + perms = guildUser.GetPermissions(guildChannel); + else + perms = ChannelPermissions.All(context.Channel); + + if (!perms.Has(ChannelPermission.Value)) + return PreconditionResult.FromError(ErrorMessage ?? $"Bot requires channel permission {ChannelPermission.Value}."); + } + + return PreconditionResult.FromSuccess(); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Preconditions/RequireContextAttribute.cs b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireContextAttribute.cs new file mode 100644 index 0000000..057055f --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireContextAttribute.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Defines the type of command context (i.e. where the command is being executed). + /// + [Flags] + public enum ContextType + { + /// + /// Specifies the command to be executed within a guild. + /// + Guild = 0x01, + /// + /// Specifies the command to be executed within a DM. + /// + DM = 0x02, + /// + /// Specifies the command to be executed within a group. + /// + Group = 0x04 + } + + /// + /// Requires the command to be invoked in a specified context (e.g. in guild, DM). + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireContextAttribute : PreconditionAttribute + { + /// + /// Gets the context required to execute the command. + /// + public ContextType Contexts { get; } + + /// Requires the command to be invoked in the specified context. + /// The type of context the command can be invoked in. Multiple contexts can be specified by ORing the contexts together. + /// + /// + /// [Command("secret")] + /// [RequireContext(ContextType.DM | ContextType.Group)] + /// public Task PrivateOnlyAsync() + /// { + /// return ReplyAsync("shh, this command is a secret"); + /// } + /// + /// + public RequireContextAttribute(ContextType contexts) + { + Contexts = contexts; + } + + /// + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + { + bool isValid = false; + + if ((Contexts & ContextType.Guild) != 0) + isValid = !context.Interaction.IsDMInteraction; + if ((Contexts & ContextType.DM) != 0) + isValid = context.Interaction.IsDMInteraction; + + if (isValid) + return Task.FromResult(PreconditionResult.FromSuccess()); + else + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"Invalid context for command; accepted contexts: {Contexts}.")); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Preconditions/RequireNsfwAttribute.cs b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireNsfwAttribute.cs new file mode 100644 index 0000000..f943ae0 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireNsfwAttribute.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the command to be invoked in a channel marked NSFW. + /// + /// + /// The precondition will restrict the access of the command or module to be accessed within a guild channel + /// that has been marked as mature or NSFW. If the channel is not of type or the + /// channel is not marked as NSFW, the precondition will fail with an erroneous . + /// + /// + /// The following example restricts the command too-cool to an NSFW-enabled channel only. + /// + /// public class DankModule : ModuleBase + /// { + /// [Command("cool")] + /// public Task CoolAsync() + /// => ReplyAsync("I'm cool for everyone."); + /// + /// [RequireNsfw] + /// [Command("too-cool")] + /// public Task TooCoolAsync() + /// => ReplyAsync("You can only see this if you're cool enough."); + /// } + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireNsfwAttribute : PreconditionAttribute + { + /// + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + { + if (context.Channel is ITextChannel text && text.IsNsfw) + return Task.FromResult(PreconditionResult.FromSuccess()); + else + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? "This command may only be invoked in an NSFW channel.")); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Preconditions/RequireOwnerAttribute.cs b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireOwnerAttribute.cs new file mode 100644 index 0000000..827ede2 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireOwnerAttribute.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the command to be invoked by the owner of the bot. + /// + /// + /// This precondition will restrict the access of the command or module to the owner of the Discord application. + /// If the precondition fails to be met, an erroneous will be returned with the + /// message "Command can only be run by the owner of the bot." + /// + /// This precondition will only work if the account has a of + /// ;otherwise, this precondition will always fail. + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireOwnerAttribute : PreconditionAttribute + { + /// + public override async Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo command, IServiceProvider services) + { + switch (context.Client.TokenType) + { + case TokenType.Bot: + var application = await context.Client.GetApplicationInfoAsync().ConfigureAwait(false); + if (context.User.Id != application.Owner.Id) + return PreconditionResult.FromError(ErrorMessage ?? "Command can only be run by the owner of the bot."); + return PreconditionResult.FromSuccess(); + default: + return PreconditionResult.FromError($"{nameof(RequireOwnerAttribute)} is not supported by this {nameof(TokenType)}."); + } + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Preconditions/RequireRoleAttribute.cs b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireRoleAttribute.cs new file mode 100644 index 0000000..7126d55 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireRoleAttribute.cs @@ -0,0 +1,73 @@ +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the user invoking the command to have a specified role. + /// + public class RequireRoleAttribute : PreconditionAttribute + { + /// + /// Gets the specified Role name of the precondition. + /// + public string RoleName { get; } + + /// + /// Gets the specified Role ID of the precondition. + /// + public ulong? RoleId { get; } + + /// + /// Gets or sets the error message if the precondition + /// fails due to being run outside of a Guild channel. + /// + public string NotAGuildErrorMessage { get; set; } + + /// + /// Requires that the user invoking the command to have a specific Role. + /// + /// Id of the role that the user must have. + public RequireRoleAttribute(ulong roleId) + { + RoleId = roleId; + } + + /// + /// Requires that the user invoking the command to have a specific Role. + /// + /// Name of the role that the user must have. + public RequireRoleAttribute(string roleName) + { + RoleName = roleName; + } + + /// + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) + { + if (context.User is not IGuildUser guildUser) + return Task.FromResult(PreconditionResult.FromError(NotAGuildErrorMessage ?? "Command must be used in a guild channel.")); + + if (RoleId.HasValue) + { + if (guildUser.RoleIds.Contains(RoleId.Value)) + return Task.FromResult(PreconditionResult.FromSuccess()); + else + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"User requires guild role {context.Guild.GetRole(RoleId.Value).Name}.")); + } + + if (!string.IsNullOrEmpty(RoleName)) + { + var roleNames = guildUser.RoleIds.Select(x => guildUser.Guild.GetRole(x).Name); + + if (roleNames.Contains(RoleName)) + return Task.FromResult(PreconditionResult.FromSuccess()); + else + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"User requires guild role {RoleName}.")); + } + + return Task.FromResult(PreconditionResult.FromSuccess()); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/Preconditions/RequireUserPermissionAttribute.cs b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireUserPermissionAttribute.cs new file mode 100644 index 0000000..0f6ecfc --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/Preconditions/RequireUserPermissionAttribute.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Requires the user invoking the command to have a specified permission. + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)] + public class RequireUserPermissionAttribute : PreconditionAttribute + { + /// + /// Gets the specified of the precondition. + /// + public GuildPermission? GuildPermission { get; } + /// + /// Gets the specified of the precondition. + /// + public ChannelPermission? ChannelPermission { get; } + /// + /// Gets or sets the error message if the precondition + /// fails due to being run outside of a Guild channel. + /// + public string NotAGuildErrorMessage { get; set; } + + /// + /// Requires that the user invoking the command to have a specific . + /// + /// + /// This precondition will always fail if the command is being invoked in a . + /// + /// + /// The that the user must have. Multiple permissions can be + /// specified by ORing the permissions together. + /// + public RequireUserPermissionAttribute(GuildPermission guildPermission) + { + GuildPermission = guildPermission; + } + + /// + /// Requires that the user invoking the command to have a specific . + /// + /// + /// The that the user must have. Multiple permissions can be + /// specified by ORing the permissions together. + /// + public RequireUserPermissionAttribute(ChannelPermission channelPermission) + { + ChannelPermission = channelPermission; + } + + /// + public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) + { + var guildUser = context.User as IGuildUser; + + if (GuildPermission.HasValue) + { + if (guildUser == null) + return Task.FromResult(PreconditionResult.FromError(NotAGuildErrorMessage ?? "Command must be used in a guild channel.")); + if (!guildUser.GuildPermissions.Has(GuildPermission.Value)) + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"User requires guild permission {GuildPermission.Value}.")); + } + + if (ChannelPermission.HasValue) + { + ChannelPermissions perms; + if (context.Channel is IGuildChannel guildChannel) + perms = guildUser.GetPermissions(guildChannel); + else + perms = ChannelPermissions.All(context.Channel); + + if (!perms.Has(ChannelPermission.Value)) + return Task.FromResult(PreconditionResult.FromError(ErrorMessage ?? $"User requires channel permission {ChannelPermission.Value}.")); + } + + return Task.FromResult(PreconditionResult.FromSuccess()); + } + } +} diff --git a/src/Discord.Net.Interactions/Attributes/SummaryAttribute.cs b/src/Discord.Net.Interactions/Attributes/SummaryAttribute.cs new file mode 100644 index 0000000..4bbc9f4 --- /dev/null +++ b/src/Discord.Net.Interactions/Attributes/SummaryAttribute.cs @@ -0,0 +1,32 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Customize the name and description of an Slash Application Command parameter. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] + public class SummaryAttribute : Attribute + { + /// + /// Gets the name of the parameter. + /// + public string Name { get; } = null; + + /// + /// Gets the description of the parameter. + /// + public string Description { get; } = null; + + /// + /// Modify the default name and description values of a Slash Command parameter. + /// + /// Name of the parameter. + /// Description of the parameter. + public SummaryAttribute(string name = null, string description = null) + { + Name = name; + Description = description; + } + } +} diff --git a/src/Discord.Net.Interactions/AutocompleteHandlers/AutocompleteHandler.cs b/src/Discord.Net.Interactions/AutocompleteHandlers/AutocompleteHandler.cs new file mode 100644 index 0000000..636ac84 --- /dev/null +++ b/src/Discord.Net.Interactions/AutocompleteHandlers/AutocompleteHandler.cs @@ -0,0 +1,106 @@ +using Discord.Rest; +using Discord.WebSocket; +using System; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base class for creating Autocompleters. uses Autocompleters to generate parameter suggestions. + /// + public abstract class AutocompleteHandler : IAutocompleteHandler + { + /// + public InteractionService InteractionService { get; set; } + + /// + public abstract Task GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services); + + protected virtual string GetLogString(IInteractionContext context) + { + var interaction = (context.Interaction as IAutocompleteInteraction); + return $"{interaction.Data.CommandName}: {interaction.Data.Current.Name} Autocomplete"; + } + + /// + public Task ExecuteAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services) + { + switch (InteractionService._runMode) + { + case RunMode.Sync: + { + return ExecuteInternalAsync(context, autocompleteInteraction, parameter, services); + } + case RunMode.Async: + _ = Task.Run(async () => + { + await ExecuteInternalAsync(context, autocompleteInteraction, parameter, services).ConfigureAwait(false); + }); + break; + default: + throw new InvalidOperationException($"RunMode {InteractionService._runMode} is not supported."); + } + + return Task.FromResult((IResult)ExecuteResult.FromSuccess()); + } + + private async Task ExecuteInternalAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services) + { + try + { + var result = await GenerateSuggestionsAsync(context, autocompleteInteraction, parameter, services).ConfigureAwait(false); + + if (result.IsSuccess) + { + var task = autocompleteInteraction.RespondAsync(result.Suggestions); + + await task; + + if (task is Task strTask) + { + var payload = strTask.Result; + + if (context is IRestInteractionContext {InteractionResponseCallback: not null} restContext) + await restContext.InteractionResponseCallback.Invoke(payload).ConfigureAwait(false); + else + await InteractionService._restResponseCallback(context, payload).ConfigureAwait(false); + } + } + await InteractionService._autocompleteHandlerExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + return result; + } + catch (Exception ex) + { + var originalEx = ex; + while (ex is TargetInvocationException) + ex = ex.InnerException; + + await InteractionService._cmdLogger.ErrorAsync(ex).ConfigureAwait(false); + + var result = ExecuteResult.FromError(ex); + await InteractionService._autocompleteHandlerExecutedEvent.InvokeAsync(this, context, result).ConfigureAwait(false); + + if (InteractionService._throwOnError) + { + if (ex == originalEx) + throw; + else + ExceptionDispatchInfo.Capture(ex).Throw(); + } + + return result; + } + finally + { + await InteractionService._cmdLogger.VerboseAsync($"Executed {GetLogString(context)}").ConfigureAwait(false); + } + } + } +} + + diff --git a/src/Discord.Net.Interactions/AutocompleteHandlers/IAutocompleteHandler.cs b/src/Discord.Net.Interactions/AutocompleteHandlers/IAutocompleteHandler.cs new file mode 100644 index 0000000..8072b30 --- /dev/null +++ b/src/Discord.Net.Interactions/AutocompleteHandlers/IAutocompleteHandler.cs @@ -0,0 +1,43 @@ +using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represent a Autocomplete handler object that can be executed to generate parameter suggestions. + /// + public interface IAutocompleteHandler + { + /// + /// Gets the the underlying command service. + /// + InteractionService InteractionService { get; } + + /// + /// Will be used to generate parameter suggestions. + /// + /// Command execution context. + /// Autocomplete Interaction payload. + /// Parameter information of the target parameter. + /// Dependencies that will be used to create the module instance. + /// + /// A task representing the execution process. The task result contains the Autocompletion result. + /// + Task GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services); + + /// + /// Executes the with the provided context. + /// + /// The execution context. + /// AutocompleteInteraction payload. + /// Parameter information of the target parameter. + /// Dependencies that will be used to create the module instance. + /// + /// A task representing the execution process. The task result contains the execution result. + /// + Task ExecuteAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, + IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/AutocompleteCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/AutocompleteCommandBuilder.cs new file mode 100644 index 0000000..c383f1b --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/AutocompleteCommandBuilder.cs @@ -0,0 +1,76 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class AutocompleteCommandBuilder : CommandBuilder + { + /// + /// Gets the name of the target parameter. + /// + public string ParameterName { get; set; } + + /// + /// Gets the name of the target command. + /// + public string CommandName { get; set; } + + protected override AutocompleteCommandBuilder Instance => this; + + internal AutocompleteCommandBuilder(ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public AutocompleteCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public AutocompleteCommandBuilder WithParameterName(string name) + { + ParameterName = name; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public AutocompleteCommandBuilder WithCommandName(string name) + { + CommandName = name; + return this; + } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override AutocompleteCommandBuilder AddParameter(Action configure) + { + var parameter = new CommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override AutocompleteCommandInfo Build(ModuleInfo module, InteractionService commandService) => + new AutocompleteCommandInfo(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/CommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/CommandBuilder.cs new file mode 100644 index 0000000..4a63e1f --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/CommandBuilder.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents the base builder class for creating . + /// + /// The this builder yields when built. + /// Inherited type. + /// Builder type for this commands parameters. + public abstract class CommandBuilder : ICommandBuilder + where TInfo : class, ICommandInfo + where TBuilder : CommandBuilder + where TParamBuilder : class, IParameterBuilder + { + private readonly List _attributes; + private readonly List _preconditions; + private readonly List _parameters; + + protected abstract TBuilder Instance { get; } + + /// + public ModuleBuilder Module { get; } + + /// + public ExecuteCallback Callback { get; internal set; } + + /// + public string Name { get; internal set; } + + /// + public string MethodName { get; set; } + + /// + public bool IgnoreGroupNames { get; set; } + + /// + public bool TreatNameAsRegex { get; set; } + + /// + public RunMode RunMode { get; set; } + + /// + public IReadOnlyList Attributes => _attributes; + + /// + public IReadOnlyList Parameters => _parameters; + + /// + public IReadOnlyList Preconditions => _preconditions; + + /// + IReadOnlyList ICommandBuilder.Parameters => Parameters; + + internal CommandBuilder(ModuleBuilder module) + { + _attributes = new List(); + _preconditions = new List(); + _parameters = new List(); + + Module = module; + } + + protected CommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : this(module) + { + Name = name; + Callback = callback; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithName(string name) + { + Name = name; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithMethodName(string name) + { + MethodName = name; + return Instance; + } + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public TBuilder WithAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder SetRunMode(RunMode runMode) + { + RunMode = runMode; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithNameAsRegex(bool value) + { + TreatNameAsRegex = value; + return Instance; + } + + /// + /// Adds parameter builders to . + /// + /// New parameter builders to be added to . + /// + /// The builder instance. + /// + public TBuilder AddParameters(params TParamBuilder[] parameters) + { + _parameters.AddRange(parameters); + return Instance; + } + + /// + /// Adds preconditions to . + /// + /// New preconditions to be added to . + /// + /// The builder instance. + /// + public TBuilder WithPreconditions(params PreconditionAttribute[] preconditions) + { + _preconditions.AddRange(preconditions); + return Instance; + } + + /// + public abstract TBuilder AddParameter(Action configure); + + internal abstract TInfo Build(ModuleInfo module, InteractionService commandService); + + //ICommandBuilder + /// + ICommandBuilder ICommandBuilder.WithName(string name) => + WithName(name); + + /// + ICommandBuilder ICommandBuilder.WithMethodName(string name) => + WithMethodName(name); + ICommandBuilder ICommandBuilder.WithAttributes(params Attribute[] attributes) => + WithAttributes(attributes); + + /// + ICommandBuilder ICommandBuilder.SetRunMode(RunMode runMode) => + SetRunMode(runMode); + + /// + ICommandBuilder ICommandBuilder.WithNameAsRegex(bool value) => + WithNameAsRegex(value); + + /// + ICommandBuilder ICommandBuilder.AddParameters(params IParameterBuilder[] parameters) => + AddParameters(parameters as TParamBuilder); + + /// + ICommandBuilder ICommandBuilder.WithPreconditions(params PreconditionAttribute[] preconditions) => + WithPreconditions(preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs new file mode 100644 index 0000000..f53e2f3 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ComponentCommandBuilder.cs @@ -0,0 +1,40 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class ComponentCommandBuilder : CommandBuilder + { + protected override ComponentCommandBuilder Instance => this; + + internal ComponentCommandBuilder(ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public ComponentCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override ComponentCommandBuilder AddParameter(Action configure) + { + var parameter = new ComponentCommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override ComponentCommandInfo Build(ModuleInfo module, InteractionService commandService) => + new ComponentCommandInfo(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ContextCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ContextCommandBuilder.cs new file mode 100644 index 0000000..44891b6 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ContextCommandBuilder.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class ContextCommandBuilder : CommandBuilder + { + protected override ContextCommandBuilder Instance => this; + + /// + /// Gets the type of this command. + /// + public ApplicationCommandType CommandType { get; set; } + + /// + /// Gets the default permission of this command. + /// + [Obsolete($"To be deprecated soon, use {nameof(IsEnabledInDm)} and {nameof(DefaultMemberPermissions)} instead.")] + public bool DefaultPermission { get; set; } = true; + + /// + /// Gets whether this command can be used in DMs. + /// + public bool IsEnabledInDm { get; set; } = true; + + /// + /// Gets whether this command is age restricted. + /// + public bool IsNsfw { get; set; } = false; + + /// + /// Gets the default permissions needed for executing this command. + /// + public GuildPermission? DefaultMemberPermissions { get; set; } = null; + + /// + /// Gets the install method for this command. + /// + public HashSet IntegrationTypes { get; set; } = null; + + /// + /// Gets the context types this command can be executed in. + /// + public HashSet ContextTypes { get; set; } = null; + + internal ContextCommandBuilder(ModuleBuilder module) : base(module) + { + IntegrationTypes = module.IntegrationTypes; + ContextTypes = module.ContextTypes; +#pragma warning disable CS0618 // Type or member is obsolete + IsEnabledInDm = module.IsEnabledInDm; +#pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public ContextCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ContextCommandBuilder SetType(ApplicationCommandType commandType) + { + CommandType = commandType; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + [Obsolete($"To be deprecated soon, use {nameof(SetEnabledInDm)} and {nameof(WithDefaultMemberPermissions)} instead.")] + public ContextCommandBuilder SetDefaultPermission(bool defaultPermision) + { + DefaultPermission = defaultPermision; + return this; + } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override ContextCommandBuilder AddParameter(Action configure) + { + var parameter = new CommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ContextCommandBuilder SetEnabledInDm(bool isEnabled) + { + IsEnabledInDm = isEnabled; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ContextCommandBuilder SetNsfw(bool isNsfw) + { + IsNsfw = isNsfw; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ContextCommandBuilder WithDefaultMemberPermissions(GuildPermission permissions) + { + DefaultMemberPermissions = permissions; + return this; + } + + /// + /// Sets the of this . + /// + /// Install types for this command. + /// The builder instance. + public ContextCommandBuilder WithIntegrationTypes(params ApplicationIntegrationType[] integrationTypes) + { + IntegrationTypes = new HashSet(integrationTypes); + return this; + } + + /// + /// Sets the of this . + /// + /// Context types the command can be executed in. + /// The builder instance. + public ContextCommandBuilder WithContextTypes(params InteractionContextType[] contextTypes) + { + ContextTypes = new HashSet(contextTypes); + return this; + } + + internal override ContextCommandInfo Build(ModuleInfo module, InteractionService commandService) => + ContextCommandInfo.Create(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ICommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ICommandBuilder.cs new file mode 100644 index 0000000..a8036e6 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ICommandBuilder.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represent a command builder for creating . + /// + public interface ICommandBuilder + { + /// + /// Gets the execution delegate of this command. + /// + ExecuteCallback Callback { get; } + + /// + /// Gets the parent module of this command. + /// + ModuleBuilder Module { get; } + + /// + /// Gets the name of this command. + /// + string Name { get; } + + /// + /// Gets or sets the method name of this command. + /// + string MethodName { get; set; } + + /// + /// Gets or sets if this command will be registered and executed as a standalone command, unaffected by the s of + /// of the commands parents. + /// + bool IgnoreGroupNames { get; set; } + + /// + /// Gets or sets whether the should be directly used as a Regex pattern. + /// + bool TreatNameAsRegex { get; set; } + + /// + /// Gets or sets the run mode this command gets executed with. + /// + RunMode RunMode { get; set; } + + /// + /// Gets a collection of the attributes of this command. + /// + IReadOnlyList Attributes { get; } + + /// + /// Gets a collection of the parameters of this command. + /// + IReadOnlyList Parameters { get; } + + /// + /// Gets a collection of the preconditions of this command. + /// + IReadOnlyList Preconditions { get; } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + ICommandBuilder WithName(string name); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + ICommandBuilder WithMethodName(string name); + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + ICommandBuilder WithAttributes(params Attribute[] attributes); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + ICommandBuilder SetRunMode(RunMode runMode); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + ICommandBuilder WithNameAsRegex(bool value); + + /// + /// Adds parameter builders to . + /// + /// New parameter builders to be added to . + /// + /// The builder instance. + /// + ICommandBuilder AddParameters(params IParameterBuilder[] parameters); + + /// + /// Adds preconditions to . + /// + /// New preconditions to be added to . + /// + /// The builder instance. + /// + ICommandBuilder WithPreconditions(params PreconditionAttribute[] preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs new file mode 100644 index 0000000..dfc76c6 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/ModalCommandBuilder.cs @@ -0,0 +1,44 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating a . + /// + public class ModalCommandBuilder : CommandBuilder + { + protected override ModalCommandBuilder Instance => this; + + /// + /// Initializes a new . + /// + /// Parent module of this modal. + public ModalCommandBuilder(ModuleBuilder module) : base(module) { } + + /// + /// Initializes a new . + /// + /// Parent module of this modal. + /// Name of this modal. + /// Execution callback of this modal. + public ModalCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Adds a modal parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override ModalCommandBuilder AddParameter(Action configure) + { + var parameter = new ModalCommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + internal override ModalCommandInfo Build(ModuleInfo module, InteractionService commandService) => + new(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Commands/SlashCommandBuilder.cs b/src/Discord.Net.Interactions/Builders/Commands/SlashCommandBuilder.cs new file mode 100644 index 0000000..8771138 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Commands/SlashCommandBuilder.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class SlashCommandBuilder : CommandBuilder + { + protected override SlashCommandBuilder Instance => this; + + /// + /// Gets and sets the description of this command. + /// + public string Description { get; set; } + + /// + /// Gets and sets the default permission of this command. + /// + [Obsolete($"To be deprecated soon, use {nameof(IsEnabledInDm)} and {nameof(DefaultMemberPermissions)} instead.")] + public bool DefaultPermission { get; set; } = true; + + /// + /// Gets whether this command can be used in DMs. + /// + public bool IsEnabledInDm { get; set; } = true; + + /// + /// Gets whether this command is age restricted. + /// + public bool IsNsfw { get; set; } = false; + + /// + /// Gets the default permissions needed for executing this command. + /// + public GuildPermission? DefaultMemberPermissions { get; set; } = null; + + /// + /// Gets or sets the install method for this command. + /// + public HashSet IntegrationTypes { get; set; } = null; + + /// + /// Gets or sets the context types this command can be executed in. + /// + public HashSet ContextTypes { get; set; } = null; + + internal SlashCommandBuilder(ModuleBuilder module) : base(module) + { + IntegrationTypes = module.IntegrationTypes; + ContextTypes = module.ContextTypes; +#pragma warning disable CS0618 // Type or member is obsolete + IsEnabledInDm = module.IsEnabledInDm; +#pragma warning restore CS0618 // Type or member is obsolete + } + + /// + /// Initializes a new . + /// + /// Parent module of this command. + /// Name of this command. + /// Execution callback of this command. + public SlashCommandBuilder(ModuleBuilder module, string name, ExecuteCallback callback) : base(module, name, callback) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + [Obsolete($"To be deprecated soon, use {nameof(SetEnabledInDm)} and {nameof(WithDefaultMemberPermissions)} instead.")] + public SlashCommandBuilder WithDefaultPermission(bool permission) + { + DefaultPermission = permission; + return Instance; + } + + /// + /// Adds a command parameter to the parameters collection. + /// + /// factory. + /// + /// The builder instance. + /// + public override SlashCommandBuilder AddParameter(Action configure) + { + var parameter = new SlashCommandParameterBuilder(this); + configure(parameter); + AddParameters(parameter); + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandBuilder SetEnabledInDm(bool isEnabled) + { + IsEnabledInDm = isEnabled; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandBuilder SetNsfw(bool isNsfw) + { + IsNsfw = isNsfw; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandBuilder WithDefaultMemberPermissions(GuildPermission permissions) + { + DefaultMemberPermissions = permissions; + return this; + } + + /// + /// Sets the on this . + /// + /// Install types for this command. + /// The builder instance. + public SlashCommandBuilder WithIntegrationTypes(params ApplicationIntegrationType[] integrationTypes) + { + IntegrationTypes = integrationTypes is not null + ? new HashSet(integrationTypes) + : null; + return this; + } + + /// + /// Sets the on this . + /// + /// Context types the command can be executed in. + /// The builder instance. + public SlashCommandBuilder WithContextTypes(params InteractionContextType[] contextTypes) + { + ContextTypes = contextTypes is not null + ? new HashSet(contextTypes) + : null; + return this; + } + + internal override SlashCommandInfo Build(ModuleInfo module, InteractionService commandService) => + new SlashCommandInfo(this, module, commandService); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs new file mode 100644 index 0000000..68c26fd --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/IInputComponentBuilder.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Discord.Interactions.Builders +{ + /// + /// Represent a builder for creating . + /// + public interface IInputComponentBuilder + { + /// + /// Gets the parent modal of this input component. + /// + ModalBuilder Modal { get; } + + /// + /// Gets the custom id of this input component. + /// + string CustomId { get; } + + /// + /// Gets the label of this input component. + /// + string Label { get; } + + /// + /// Gets whether this input component is required. + /// + bool IsRequired { get; } + + /// + /// Gets the component type of this input component. + /// + ComponentType ComponentType { get; } + + /// + /// Get the reference type of this input component. + /// + Type Type { get; } + + /// + /// Get the of this component's property. + /// + PropertyInfo PropertyInfo { get; } + + /// + /// Get the assigned to this input. + /// + ComponentTypeConverter TypeConverter { get; } + + /// + /// Gets the default value of this input component. + /// + object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this component. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithCustomId(string customId); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithLabel(string label); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder SetIsRequired(bool isRequired); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder WithType(Type type); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IInputComponentBuilder SetDefaultValue(object value); + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + IInputComponentBuilder WithAttributes(params Attribute[] attributes); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs new file mode 100644 index 0000000..af0ab3a --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/InputComponentBuilder.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents the base builder class for creating . + /// + /// The this builder yields when built. + /// Inherited type. + public abstract class InputComponentBuilder : IInputComponentBuilder + where TInfo : InputComponentInfo + where TBuilder : InputComponentBuilder + { + private readonly List _attributes; + protected abstract TBuilder Instance { get; } + + /// + public ModalBuilder Modal { get; } + + /// + public string CustomId { get; set; } + + /// + public string Label { get; set; } + + /// + public bool IsRequired { get; set; } = true; + + /// + public ComponentType ComponentType { get; internal set; } + + /// + public Type Type { get; private set; } + + /// + public PropertyInfo PropertyInfo { get; internal set; } + + /// + public ComponentTypeConverter TypeConverter { get; private set; } + + /// + public object DefaultValue { get; set; } + + /// + public IReadOnlyCollection Attributes => _attributes; + + /// + /// Creates an instance of + /// + /// Parent modal of this input component. + public InputComponentBuilder(ModalBuilder modal) + { + Modal = modal; + _attributes = new(); + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithCustomId(string customId) + { + CustomId = customId; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithLabel(string label) + { + Label = label; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder SetIsRequired(bool isRequired) + { + IsRequired = isRequired; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithComponentType(ComponentType componentType) + { + ComponentType = componentType; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder WithType(Type type) + { + Type = type; + TypeConverter = Modal._interactionService.GetComponentTypeConverter(type); + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TBuilder SetDefaultValue(object value) + { + DefaultValue = value; + return Instance; + } + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public TBuilder WithAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return Instance; + } + + internal abstract TInfo Build(ModalInfo modal); + + //IInputComponentBuilder + /// + IInputComponentBuilder IInputComponentBuilder.WithCustomId(string customId) => WithCustomId(customId); + + /// + IInputComponentBuilder IInputComponentBuilder.WithLabel(string label) => WithCustomId(label); + + /// + IInputComponentBuilder IInputComponentBuilder.WithType(Type type) => WithType(type); + + /// + IInputComponentBuilder IInputComponentBuilder.SetDefaultValue(object value) => SetDefaultValue(value); + + /// + IInputComponentBuilder IInputComponentBuilder.WithAttributes(params Attribute[] attributes) => WithAttributes(attributes); + + /// + IInputComponentBuilder IInputComponentBuilder.SetIsRequired(bool isRequired) => SetIsRequired(isRequired); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs new file mode 100644 index 0000000..728b97a --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/Inputs/TextInputComponentBuilder.cs @@ -0,0 +1,109 @@ +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public class TextInputComponentBuilder : InputComponentBuilder + { + protected override TextInputComponentBuilder Instance => this; + + /// + /// Gets and sets the style of the text input. + /// + public TextInputStyle Style { get; set; } + + /// + /// Gets and sets the placeholder of the text input. + /// + public string Placeholder { get; set; } + + /// + /// Gets and sets the minimum length of the text input. + /// + public int MinLength { get; set; } + + /// + /// Gets and sets the maximum length of the text input. + /// + public int MaxLength { get; set; } + + /// + /// Gets and sets the initial value to be displayed by this input. + /// + public string InitialValue { get; set; } + + /// + /// Initializes a new . + /// + /// Parent modal of this component. + public TextInputComponentBuilder(ModalBuilder modal) : base(modal) { } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithStyle(TextInputStyle style) + { + Style = style; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithPlaceholder(string placeholder) + { + Placeholder = placeholder; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithMinLength(int minLength) + { + MinLength = minLength; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithMaxLength(int maxLength) + { + MaxLength = maxLength; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public TextInputComponentBuilder WithInitialValue(string value) + { + InitialValue = value; + return this; + } + + internal override TextInputComponentInfo Build(ModalInfo modal) => + new(this, modal); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs new file mode 100644 index 0000000..66aeadf --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Modals/ModalBuilder.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public class ModalBuilder + { + internal readonly InteractionService _interactionService; + internal readonly List _components; + + /// + /// Gets the initialization delegate for this modal. + /// + public ModalInitializer ModalInitializer { get; internal set; } + + /// + /// Gets the title of this modal. + /// + public string Title { get; set; } + + /// + /// Gets the implementation used to initialize this object. + /// + public Type Type { get; } + + /// + /// Gets a collection of the components of this modal. + /// + public IReadOnlyCollection Components => _components; + + internal ModalBuilder(Type type, InteractionService interactionService) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + Type = type; + + _interactionService = interactionService; + _components = new(); + } + + /// + /// Initializes a new + /// + /// The initialization delegate for this modal. + public ModalBuilder(Type type, ModalInitializer modalInitializer, InteractionService interactionService) : this(type, interactionService) + { + ModalInitializer = modalInitializer; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModalBuilder WithTitle(string title) + { + Title = title; + return this; + } + + /// + /// Adds text components to . + /// + /// Text Component builder factory. + /// + /// The builder instance. + /// + public ModalBuilder AddTextComponent(Action configure) + { + var builder = new TextInputComponentBuilder(this); + configure(builder); + _components.Add(builder); + return this; + } + + internal ModalInfo Build() => new(this); + } +} diff --git a/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs b/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs new file mode 100644 index 0000000..2fd2892 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/ModuleBuilder.cs @@ -0,0 +1,482 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public class ModuleBuilder + { + private readonly List _attributes; + private readonly List _preconditions; + private readonly List _subModules; + private readonly List _slashCommands; + private readonly List _contextCommands; + private readonly List _componentCommands; + private readonly List _autocompleteCommands; + private readonly List _modalCommands; + + /// + /// Gets the underlying Interaction Service. + /// + public InteractionService InteractionService { get; } + + /// + /// Gets the parent module if this module is a sub-module. + /// + public ModuleBuilder Parent { get; } + + /// + /// Gets the name of this module. + /// + public string Name { get; internal set; } + + /// + /// Gets and sets the group name of this module. + /// + public string SlashGroupName { get; set; } + + /// + /// Gets whether this has a . + /// + public bool IsSlashGroup => !string.IsNullOrEmpty(SlashGroupName); + + /// + /// Gets and sets the description of this module. + /// + public string Description { get; set; } + + /// + /// Gets and sets the default permission of this module. + /// + [Obsolete($"To be deprecated soon, use {nameof(ContextTypes)} and {nameof(DefaultMemberPermissions)} instead.")] + public bool DefaultPermission { get; set; } = true; + + /// + /// Gets whether this command can be used in DMs. + /// + [Obsolete("This property will be deprecated soon. Use ContextTypes instead.")] + public bool IsEnabledInDm { get; set; } = true; + + /// + /// Gets whether this command is age restricted. + /// + public bool IsNsfw { get; set; } = false; + + /// + /// Gets the default permissions needed for executing this command. + /// + public GuildPermission? DefaultMemberPermissions { get; set; } = null; + + /// + /// Gets and sets whether this has a . + /// + public bool DontAutoRegister { get; set; } = false; + + /// + /// Gets a collection of the attributes of this module. + /// + public IReadOnlyList Attributes => _attributes; + + /// + /// Gets a collection of the preconditions of this module. + /// + public IReadOnlyCollection Preconditions => _preconditions; + + /// + /// Gets a collection of the sub-modules of this module. + /// + public IReadOnlyList SubModules => _subModules; + + /// + /// Gets a collection of the Slash Commands of this module. + /// + public IReadOnlyList SlashCommands => _slashCommands; + + /// + /// Gets a collection of the Context Commands of this module. + /// + public IReadOnlyList ContextCommands => _contextCommands; + + /// + /// Gets a collection of the Component Commands of this module. + /// + public IReadOnlyList ComponentCommands => _componentCommands; + + /// + /// Gets a collection of the Autocomplete Commands of this module. + /// + public IReadOnlyList AutocompleteCommands => _autocompleteCommands; + + /// + /// Gets a collection of the Modal Commands of this module. + /// + public IReadOnlyList ModalCommands => _modalCommands; + + /// + /// Gets or sets the install method for this command. + /// + public HashSet IntegrationTypes { get; set; } = null; + + /// + /// Gets or sets the context types this command can be executed in. + /// + public HashSet ContextTypes { get; set; } = null; + + internal TypeInfo TypeInfo { get; set; } + + internal ModuleBuilder(InteractionService interactionService, ModuleBuilder parent = null) + { + InteractionService = interactionService; + Parent = parent; + + _attributes = new List(); + _subModules = new List(); + _slashCommands = new List(); + _contextCommands = new List(); + _componentCommands = new List(); + _autocompleteCommands = new List(); + _modalCommands = new List(); + _preconditions = new List(); + } + + /// + /// Initializes a new . + /// + /// The underlying Interaction Service. + /// Name of this module. + /// Parent module of this sub-module. + public ModuleBuilder(InteractionService interactionService, string name, ModuleBuilder parent = null) : this(interactionService, parent) + { + Name = name; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModuleBuilder WithGroupName(string name) + { + SlashGroupName = name; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModuleBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + [Obsolete($"To be deprecated soon, use {nameof(SetEnabledInDm)} and {nameof(WithDefaultMemberPermissions)} instead.")] + public ModuleBuilder WithDefaultPermission(bool permission) + { + DefaultPermission = permission; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + [Obsolete("This method will be deprecated soon. Use WithContextTypes instead.")] + public ModuleBuilder SetEnabledInDm(bool isEnabled) + { + IsEnabledInDm = isEnabled; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModuleBuilder SetNsfw(bool isNsfw) + { + IsNsfw = isNsfw; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ModuleBuilder WithDefaultMemberPermissions(GuildPermission permissions) + { + DefaultMemberPermissions = permissions; + return this; + } + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public ModuleBuilder AddAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return this; + } + + /// + /// Adds preconditions to . + /// + /// New preconditions to be added to . + /// + /// The builder instance. + /// + public ModuleBuilder AddPreconditions(params PreconditionAttribute[] preconditions) + { + _preconditions.AddRange(preconditions); + return this; + } + + /// + /// Adds slash command builder to . + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddSlashCommand(Action configure) + { + var command = new SlashCommandBuilder(this); + configure(command); + _slashCommands.Add(command); + return this; + } + + /// + /// Adds slash command builder to . + /// + /// Name of the command. + /// Command callback to be executed. + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddSlashCommand(string name, ExecuteCallback callback, Action configure) + { + var command = new SlashCommandBuilder(this, name, callback); + configure(command); + _slashCommands.Add(command); + return this; + } + + /// + /// Adds context command builder to . + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddContextCommand(Action configure) + { + var command = new ContextCommandBuilder(this); + configure(command); + _contextCommands.Add(command); + return this; + } + + /// + /// Adds context command builder to . + /// + /// Name of the command. + /// Command callback to be executed. + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddContextCommand(string name, ExecuteCallback callback, Action configure) + { + var command = new ContextCommandBuilder(this, name, callback); + configure(command); + _contextCommands.Add(command); + return this; + } + + /// + /// Adds component command builder to . + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddComponentCommand(Action configure) + { + var command = new ComponentCommandBuilder(this); + configure(command); + _componentCommands.Add(command); + return this; + } + + /// + /// Adds component command builder to . + /// + /// Name of the command. + /// Command callback to be executed. + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddComponentCommand(string name, ExecuteCallback callback, Action configure) + { + var command = new ComponentCommandBuilder(this, name, callback); + configure(command); + _componentCommands.Add(command); + return this; + } + + /// + /// Adds autocomplete command builder to . + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddAutocompleteCommand(Action configure) + { + var command = new AutocompleteCommandBuilder(this); + configure(command); + _autocompleteCommands.Add(command); + return this; + } + + /// + /// Adds autocomplete command builder to . + /// + /// Name of the command. + /// Command callback to be executed. + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddSlashCommand(string name, ExecuteCallback callback, Action configure) + { + var command = new AutocompleteCommandBuilder(this, name, callback); + configure(command); + _autocompleteCommands.Add(command); + return this; + + } + + /// + /// Adds a modal command builder to . + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddModalCommand(Action configure) + { + var command = new ModalCommandBuilder(this); + configure(command); + _modalCommands.Add(command); + return this; + } + + /// + /// Adds a modal command builder to . + /// + /// Name of the command. + /// Command callback to be executed. + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddModalCommand(string name, ExecuteCallback callback, Action configure) + { + var command = new ModalCommandBuilder(this, name, callback); + configure(command); + _modalCommands.Add(command); + return this; + } + + /// + /// Adds sub-module builder to . + /// + /// factory. + /// + /// The builder instance. + /// + public ModuleBuilder AddModule(Action configure) + { + var subModule = new ModuleBuilder(InteractionService, this); + configure(subModule); + _subModules.Add(subModule); + return this; + } + + /// + /// Sets the on this . + /// + /// Install types for this command. + /// The builder instance. + public ModuleBuilder WithIntegrationTypes(params ApplicationIntegrationType[] integrationTypes) + { + IntegrationTypes = new HashSet(integrationTypes); + return this; + } + + /// + /// Sets the on this . + /// + /// Context types the command can be executed in. + /// The builder instance. + public ModuleBuilder WithContextTypes(params InteractionContextType[] contextTypes) + { + ContextTypes = new HashSet(contextTypes); + return this; + } + + internal ModuleInfo Build(InteractionService interactionService, IServiceProvider services, ModuleInfo parent = null) + { + if (TypeInfo is not null && ModuleClassBuilder.IsValidModuleDefinition(TypeInfo)) + { + var instance = ReflectionUtils.CreateObject(TypeInfo, interactionService, services); + + try + { + instance.Construct(this, interactionService); + var moduleInfo = new ModuleInfo(this, interactionService, services, parent); + instance.OnModuleBuilding(interactionService, moduleInfo); + return moduleInfo; + } + finally + { + (instance as IDisposable)?.Dispose(); + } + } + else + return new(this, interactionService, services, parent); + } + } +} diff --git a/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs new file mode 100644 index 0000000..7a06c05 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/ModuleClassBuilder.cs @@ -0,0 +1,748 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Discord.Interactions.Builders +{ + internal static class ModuleClassBuilder + { + private static readonly TypeInfo ModuleTypeInfo = typeof(IInteractionModuleBase).GetTypeInfo(); + + public const int MaxCommandDepth = 3; + + public static async Task> SearchAsync(Assembly assembly, InteractionService commandService) + { + static bool IsLoadableModule(TypeInfo info) + { + return info.DeclaredMethods.Any(x => x.GetCustomAttribute() != null); + } + + var result = new List(); + + foreach (var type in assembly.DefinedTypes) + { + if ((type.IsPublic || type.IsNestedPublic) && IsValidModuleDefinition(type)) + { + result.Add(type); + } + else if (IsLoadableModule(type)) + { + await commandService._cmdLogger.WarningAsync($"Class {type.FullName} is not public and cannot be loaded.").ConfigureAwait(false); + } + } + return result; + } + + public static async Task> BuildAsync(IEnumerable validTypes, InteractionService commandService, + IServiceProvider services) + { + var topLevelGroups = validTypes.Where(x => x.DeclaringType == null || !IsValidModuleDefinition(x.DeclaringType.GetTypeInfo())); + var built = new List(); + + var result = new Dictionary(); + + foreach (var type in topLevelGroups) + { + var builder = new ModuleBuilder(commandService); + + BuildModule(builder, type, commandService, services); + BuildSubModules(builder, type.DeclaredNestedTypes, built, commandService, services); + built.Add(type); + + var moduleInfo = builder.Build(commandService, services); + + result.Add(type.AsType(), moduleInfo); + } + + await commandService._cmdLogger.DebugAsync($"Successfully built {built.Count} Slash Command modules.").ConfigureAwait(false); + + return result; + } + + private static void BuildModule(ModuleBuilder builder, TypeInfo typeInfo, InteractionService commandService, + IServiceProvider services) + { + var attributes = typeInfo.GetCustomAttributes(); + + builder.Name = typeInfo.Name; + builder.TypeInfo = typeInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case GroupAttribute group: + { + builder.SlashGroupName = group.Name; + builder.Description = group.Description; + } + break; +#pragma warning disable CS0618 // Type or member is obsolete + case DefaultPermissionAttribute defPermission: + { + builder.DefaultPermission = defPermission.IsDefaultPermission; + } + break; +#pragma warning restore CS0618 // Type or member is obsolete +#pragma warning disable CS0618 // Type or member is obsolete + case EnabledInDmAttribute enabledInDm: + { + builder.IsEnabledInDm = enabledInDm.IsEnabled; + } + break; +#pragma warning restore CS0618 // Type or member is obsolete + case DefaultMemberPermissionsAttribute memberPermission: + { + builder.DefaultMemberPermissions = memberPermission.Permissions; + } + break; + case PreconditionAttribute precondition: + builder.AddPreconditions(precondition); + break; + case DontAutoRegisterAttribute dontAutoRegister: + builder.DontAutoRegister = true; + break; + case NsfwCommandAttribute nsfwCommand: + builder.SetNsfw(nsfwCommand.IsNsfw); + break; + case CommandContextTypeAttribute contextType: + builder.WithContextTypes(contextType.ContextTypes?.ToArray()); + break; + case IntegrationTypeAttribute integrationType: + builder.WithIntegrationTypes(integrationType.IntegrationTypes?.ToArray()); + break; + default: + builder.AddAttributes(attribute); + break; + } + } + + var methods = typeInfo.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + var validSlashCommands = methods.Where(IsValidSlashCommandDefinition); + var validContextCommands = methods.Where(IsValidContextCommandDefinition); + var validInteractions = methods.Where(IsValidComponentCommandDefinition); + var validAutocompleteCommands = methods.Where(IsValidAutocompleteCommandDefinition); + var validModalCommands = methods.Where(IsValidModalCommanDefinition); + + Func createInstance = commandService._useCompiledLambda ? + ReflectionUtils.CreateLambdaBuilder(typeInfo, commandService) : ReflectionUtils.CreateBuilder(typeInfo, commandService); + + foreach (var method in validSlashCommands) + builder.AddSlashCommand(x => BuildSlashCommand(x, createInstance, method, commandService, services)); + + foreach (var method in validContextCommands) + builder.AddContextCommand(x => BuildContextCommand(x, createInstance, method, commandService, services)); + + foreach (var method in validInteractions) + builder.AddComponentCommand(x => BuildComponentCommand(x, createInstance, method, commandService, services)); + + foreach (var method in validAutocompleteCommands) + builder.AddAutocompleteCommand(x => BuildAutocompleteCommand(x, createInstance, method, commandService, services)); + + foreach (var method in validModalCommands) + builder.AddModalCommand(x => BuildModalCommand(x, createInstance, method, commandService, services)); + } + + private static void BuildSubModules(ModuleBuilder parent, IEnumerable subModules, IList builtTypes, InteractionService commandService, + IServiceProvider services, int slashGroupDepth = 0) + { + foreach (var submodule in subModules.Where(IsValidModuleDefinition)) + { + if (builtTypes.Contains(submodule)) + continue; + + parent.AddModule((builder) => + { + BuildModule(builder, submodule, commandService, services); + + if (slashGroupDepth >= MaxCommandDepth - 1) + throw new InvalidOperationException($"Slash Commands only support {MaxCommandDepth - 1} command prefixes for sub-commands"); + + BuildSubModules(builder, submodule.DeclaredNestedTypes, builtTypes, commandService, services, builder.IsSlashGroup ? slashGroupDepth + 1 : slashGroupDepth); + }); + builtTypes.Add(submodule); + } + } + + private static void BuildSlashCommand(SlashCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case SlashCommandAttribute command: + { + builder.Name = command.Name; + builder.Description = command.Description; + builder.IgnoreGroupNames = command.IgnoreGroupNames; + builder.RunMode = command.RunMode; + } + break; +#pragma warning disable CS0618 // Type or member is obsolete + case DefaultPermissionAttribute defaultPermission: + { + builder.DefaultPermission = defaultPermission.IsDefaultPermission; + } + break; + case EnabledInDmAttribute enabledInDm: + { + builder.IsEnabledInDm = enabledInDm.IsEnabled; + } + break; +#pragma warning restore CS0618 // Type or member is obsolete + case DefaultMemberPermissionsAttribute memberPermission: + { + builder.DefaultMemberPermissions = memberPermission.Permissions; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + case NsfwCommandAttribute nsfwCommand: + builder.SetNsfw(nsfwCommand.IsNsfw); + break; + case CommandContextTypeAttribute contextType: + builder.WithContextTypes(contextType.ContextTypes.ToArray()); + break; + case IntegrationTypeAttribute integrationType: + builder.WithIntegrationTypes(integrationType.IntegrationTypes.ToArray()); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildSlashParameter(x, parameter, services)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static void BuildContextCommand(ContextCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ContextCommandAttribute command: + { + builder.Name = command.Name; + builder.CommandType = command.CommandType; + builder.RunMode = command.RunMode; + + command.CheckMethodDefinition(methodInfo); + } + break; +#pragma warning disable CS0618 // Type or member is obsolete + case DefaultPermissionAttribute defaultPermission: + { + builder.DefaultPermission = defaultPermission.IsDefaultPermission; + } + break; + case EnabledInDmAttribute enabledInDm: + { + builder.IsEnabledInDm = enabledInDm.IsEnabled; + } + break; +#pragma warning restore CS0618 // Type or member is obsolete + case DefaultMemberPermissionsAttribute memberPermission: + { + builder.DefaultMemberPermissions = memberPermission.Permissions; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + case NsfwCommandAttribute nsfwCommand: + builder.SetNsfw(nsfwCommand.IsNsfw); + break; + case CommandContextTypeAttribute contextType: + builder.WithContextTypes(contextType.ContextTypes.ToArray()); + break; + case IntegrationTypeAttribute integrationType: + builder.WithIntegrationTypes(integrationType.IntegrationTypes.ToArray()); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildParameter(x, parameter)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static void BuildComponentCommand(ComponentCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ComponentInteractionAttribute interaction: + { + builder.Name = interaction.CustomId; + builder.RunMode = interaction.RunMode; + builder.IgnoreGroupNames = interaction.IgnoreGroupNames; + builder.TreatNameAsRegex = interaction.TreatAsRegex; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + var wildCardCount = RegexUtils.GetWildCardCount(builder.Name, commandService._wildCardExp); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildComponentParameter(x, parameter, parameter.Position >= wildCardCount)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static void BuildAutocompleteCommand(AutocompleteCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case AutocompleteCommandAttribute autocomplete: + { + builder.ParameterName = autocomplete.ParameterName; + builder.CommandName = autocomplete.CommandName; + builder.Name = autocomplete.CommandName + " " + autocomplete.ParameterName; + builder.RunMode = autocomplete.RunMode; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + var parameters = methodInfo.GetParameters(); + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildParameter(x, parameter)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static void BuildModalCommand(ModalCommandBuilder builder, Func createInstance, MethodInfo methodInfo, + InteractionService commandService, IServiceProvider services) + { + var parameters = methodInfo.GetParameters(); + + if (parameters.Count(x => typeof(IModal).IsAssignableFrom(x.ParameterType)) > 1) + throw new InvalidOperationException($"A modal command can only have one {nameof(IModal)} parameter."); + + if (!typeof(IModal).IsAssignableFrom(parameters.Last().ParameterType)) + throw new InvalidOperationException($"Last parameter of a modal command must be an implementation of {nameof(IModal)}"); + + var attributes = methodInfo.GetCustomAttributes(); + + builder.MethodName = methodInfo.Name; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ModalInteractionAttribute modal: + { + builder.Name = modal.CustomId; + builder.RunMode = modal.RunMode; + builder.IgnoreGroupNames = modal.IgnoreGroupNames; + builder.TreatNameAsRegex = modal.TreatAsRegex; + } + break; + case PreconditionAttribute precondition: + builder.WithPreconditions(precondition); + break; + default: + builder.WithAttributes(attribute); + break; + } + } + + foreach (var parameter in parameters) + builder.AddParameter(x => BuildParameter(x, parameter)); + + builder.Callback = CreateCallback(createInstance, methodInfo, commandService); + } + + private static ExecuteCallback CreateCallback(Func createInstance, + MethodInfo methodInfo, InteractionService commandService) + { + Func commandInvoker = commandService._useCompiledLambda ? + ReflectionUtils.CreateMethodInvoker(methodInfo) : (module, args) => methodInfo.Invoke(module, args) as Task; + + async Task ExecuteCallback(IInteractionContext context, object[] args, IServiceProvider serviceProvider, ICommandInfo commandInfo) + { + var instance = createInstance(serviceProvider); + instance.SetContext(context); + + try + { + await instance.BeforeExecuteAsync(commandInfo).ConfigureAwait(false); + instance.BeforeExecute(commandInfo); + var task = commandInvoker(instance, args) ?? Task.Delay(0); + + if (task is Task runtimeTask) + { + return await runtimeTask.ConfigureAwait(false); + } + else + { + await task.ConfigureAwait(false); + return ExecuteResult.FromSuccess(); + + } + } + catch (Exception ex) + { + var interactionException = new InteractionException(commandInfo, context, ex); + await commandService._cmdLogger.ErrorAsync(interactionException).ConfigureAwait(false); + return ExecuteResult.FromError(interactionException); + } + finally + { + await instance.AfterExecuteAsync(commandInfo).ConfigureAwait(false); + instance.AfterExecute(commandInfo); + (instance as IDisposable)?.Dispose(); + } + } + + return ExecuteCallback; + } + + #region Parameters + private static void BuildSlashParameter(SlashCommandParameterBuilder builder, ParameterInfo paramInfo, IServiceProvider services) + { + var attributes = paramInfo.GetCustomAttributes(); + var paramType = paramInfo.ParameterType; + + builder.Name = paramInfo.Name; + builder.Description = paramInfo.Name; + builder.IsRequired = !paramInfo.IsOptional; + builder.DefaultValue = paramInfo.DefaultValue; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case SummaryAttribute description: + { + if (!string.IsNullOrEmpty(description.Name)) + builder.Name = description.Name; + + if (!string.IsNullOrEmpty(description.Description)) + builder.Description = description.Description; + } + break; + case ChoiceAttribute choice: + builder.WithChoices(new ParameterChoice(choice.Name, choice.Value)); + break; + case ParamArrayAttribute _: + builder.IsParameterArray = true; + break; + case ParameterPreconditionAttribute precondition: + builder.AddPreconditions(precondition); + break; + case ChannelTypesAttribute channelTypes: + builder.WithChannelTypes(channelTypes.ChannelTypes); + break; + case AutocompleteAttribute autocomplete: + builder.Autocomplete = true; + if (autocomplete.AutocompleteHandlerType is not null) + builder.WithAutocompleteHandler(autocomplete.AutocompleteHandlerType, services); + break; + case MaxValueAttribute maxValue: + builder.MaxValue = maxValue.Value; + break; + case MinValueAttribute minValue: + builder.MinValue = minValue.Value; + break; + case MinLengthAttribute minLength: + builder.MinLength = minLength.Length; + break; + case MaxLengthAttribute maxLength: + builder.MaxLength = maxLength.Length; + break; + case ComplexParameterAttribute complexParameter: + { + builder.IsComplexParameter = true; + ConstructorInfo ctor = GetComplexParameterConstructor(paramInfo.ParameterType.GetTypeInfo(), complexParameter); + + foreach (var parameter in ctor.GetParameters()) + { + if (parameter.IsDefined(typeof(ComplexParameterAttribute))) + throw new InvalidOperationException("You cannot create nested complex parameters."); + + builder.AddComplexParameterField(fieldBuilder => BuildSlashParameter(fieldBuilder, parameter, services)); + } + + var initializer = builder.Command.Module.InteractionService._useCompiledLambda ? + ReflectionUtils.CreateLambdaConstructorInvoker(paramInfo.ParameterType.GetTypeInfo()) : ctor.Invoke; + builder.ComplexParameterInitializer = args => initializer(args); + } + break; + default: + builder.AddAttributes(attribute); + break; + } + } + + builder.SetParameterType(paramType, services); + + // Replace pascal casings with '-' + builder.Name = Regex.Replace(builder.Name, "(?<=[a-z])(?=[A-Z])", "-").ToLower(); + } + + private static void BuildComponentParameter(ComponentCommandParameterBuilder builder, ParameterInfo paramInfo, bool isComponentParam) + { + builder.SetIsRouteSegment(!isComponentParam); + BuildParameter(builder, paramInfo); + } + + private static void BuildParameter(ParameterBuilder builder, ParameterInfo paramInfo) + where TInfo : class, IParameterInfo + where TBuilder : ParameterBuilder + { + var attributes = paramInfo.GetCustomAttributes(); + var paramType = paramInfo.ParameterType; + + builder.Name = paramInfo.Name; + builder.IsRequired = !paramInfo.IsOptional; + builder.DefaultValue = paramInfo.DefaultValue; + builder.SetParameterType(paramType); + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ParameterPreconditionAttribute precondition: + builder.AddPreconditions(precondition); + break; + case ParamArrayAttribute _: + builder.IsParameterArray = true; + break; + default: + builder.AddAttributes(attribute); + break; + } + } + } + #endregion + + #region Modals + public static ModalInfo BuildModalInfo(Type modalType, InteractionService interactionService) + { + if (!typeof(IModal).IsAssignableFrom(modalType)) + throw new InvalidOperationException($"{modalType.FullName} isn't an implementation of {typeof(IModal).FullName}"); + + var instance = Activator.CreateInstance(modalType, false) as IModal; + + try + { + var builder = new ModalBuilder(modalType, interactionService) + { + Title = instance.Title + }; + + var inputs = modalType.GetProperties().Where(IsValidModalInputDefinition); + + foreach (var prop in inputs) + { + var componentType = prop.GetCustomAttribute()?.ComponentType; + + switch (componentType) + { + case ComponentType.TextInput: + builder.AddTextComponent(x => BuildTextInput(x, prop, prop.GetValue(instance))); + break; + case null: + throw new InvalidOperationException($"{prop.Name} of {prop.DeclaringType.Name} isn't a valid modal input field."); + default: + throw new InvalidOperationException($"Component type {componentType} cannot be used in modals."); + } + } + + var memberInit = ReflectionUtils.CreateLambdaMemberInit(modalType.GetTypeInfo(), modalType.GetConstructor(Type.EmptyTypes), x => x.IsDefined(typeof(ModalInputAttribute))); + builder.ModalInitializer = (args) => memberInit(Array.Empty(), args); + return builder.Build(); + } + finally + { + (instance as IDisposable)?.Dispose(); + } + } + + private static void BuildTextInput(TextInputComponentBuilder builder, PropertyInfo propertyInfo, object defaultValue) + { + var attributes = propertyInfo.GetCustomAttributes(); + + builder.Label = propertyInfo.Name; + builder.DefaultValue = defaultValue; + builder.WithType(propertyInfo.PropertyType); + builder.PropertyInfo = propertyInfo; + + foreach (var attribute in attributes) + { + switch (attribute) + { + case ModalTextInputAttribute textInput: + builder.CustomId = textInput.CustomId; + builder.ComponentType = textInput.ComponentType; + builder.Style = textInput.Style; + builder.Placeholder = textInput.Placeholder; + builder.MaxLength = textInput.MaxLength; + builder.MinLength = textInput.MinLength; + builder.InitialValue = textInput.InitialValue; + break; + case RequiredInputAttribute requiredInput: + builder.IsRequired = requiredInput.IsRequired; + break; + case InputLabelAttribute inputLabel: + builder.Label = inputLabel.Label; + break; + default: + builder.WithAttributes(attribute); + break; + } + } + } + #endregion + + internal static bool IsValidModuleDefinition(TypeInfo typeInfo) + { + return ModuleTypeInfo.IsAssignableFrom(typeInfo) && + !typeInfo.IsAbstract && + !typeInfo.ContainsGenericParameters; + } + + private static bool IsValidSlashCommandDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(SlashCommandAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + + private static bool IsValidContextCommandDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(ContextCommandAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + + private static bool IsValidComponentCommandDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(ComponentInteractionAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod; + } + + private static bool IsValidAutocompleteCommandDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(AutocompleteCommandAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod && + methodInfo.GetParameters().Length == 0; + } + + private static bool IsValidModalCommanDefinition(MethodInfo methodInfo) + { + return methodInfo.IsDefined(typeof(ModalInteractionAttribute)) && + (methodInfo.ReturnType == typeof(Task) || methodInfo.ReturnType == typeof(Task)) && + !methodInfo.IsStatic && + !methodInfo.IsGenericMethod && + typeof(IModal).IsAssignableFrom(methodInfo.GetParameters().Last().ParameterType); + } + + private static bool IsValidModalInputDefinition(PropertyInfo propertyInfo) + { + return propertyInfo.SetMethod?.IsPublic == true && + propertyInfo.SetMethod?.IsStatic == false && + propertyInfo.IsDefined(typeof(ModalInputAttribute)); + } + + private static ConstructorInfo GetComplexParameterConstructor(TypeInfo typeInfo, ComplexParameterAttribute complexParameter) + { + var ctors = typeInfo.GetConstructors(); + + if (ctors.Length == 0) + throw new InvalidOperationException($"No constructor found for \"{typeInfo.FullName}\"."); + + if (complexParameter.PrioritizedCtorSignature is not null) + { + var ctor = typeInfo.GetConstructor(complexParameter.PrioritizedCtorSignature); + + if (ctor is null) + throw new InvalidOperationException($"No constructor was found with the signature: {string.Join(",", complexParameter.PrioritizedCtorSignature.Select(x => x.Name))}"); + + return ctor; + } + + var prioritizedCtors = ctors.Where(x => x.IsDefined(typeof(ComplexParameterCtorAttribute), true)); + + switch (prioritizedCtors.Count()) + { + case > 1: + throw new InvalidOperationException($"{nameof(ComplexParameterCtorAttribute)} can only be used once in a type."); + case 1: + return prioritizedCtors.First(); + } + + switch (ctors.Length) + { + case > 1: + throw new InvalidOperationException($"Multiple constructors found for \"{typeInfo.FullName}\"."); + default: + return ctors.First(); + } + } + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/CommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/CommandParameterBuilder.cs new file mode 100644 index 0000000..d3aa85c --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/CommandParameterBuilder.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class CommandParameterBuilder : ParameterBuilder + { + protected override CommandParameterBuilder Instance => this; + + internal CommandParameterBuilder(ICommandBuilder command) : base(command) { } + + /// + /// Initializes a new . + /// + /// Parent command of this parameter. + /// Name of this command. + /// Type of this parameter. + public CommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { } + + internal override CommandParameterInfo Build(ICommandInfo command) => + new CommandParameterInfo(this, command); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/ComponentCommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/ComponentCommandParameterBuilder.cs new file mode 100644 index 0000000..d9f1463 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/ComponentCommandParameterBuilder.cs @@ -0,0 +1,77 @@ +using System; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public class ComponentCommandParameterBuilder : ParameterBuilder + { + /// + /// Get the assigned to this parameter, if is . + /// + public ComponentTypeConverter TypeConverter { get; private set; } + + /// + /// Get the assigned to this parameter, if is . + /// + public TypeReader TypeReader { get; private set; } + + /// + /// Gets whether this parameter is a CustomId segment or a Component value parameter. + /// + public bool IsRouteSegmentParameter { get; private set; } + + /// + protected override ComponentCommandParameterBuilder Instance => this; + + internal ComponentCommandParameterBuilder(ICommandBuilder command) : base(command) { } + + /// + /// Initializes a new . + /// + /// Parent command of this parameter. + /// Name of this command. + /// Type of this parameter. + public ComponentCommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { } + + /// + public override ComponentCommandParameterBuilder SetParameterType(Type type) => SetParameterType(type, null); + + /// + /// Sets . + /// + /// New value of the . + /// Service container to be used to resolve the dependencies of this parameters . + /// + /// The builder instance. + /// + public ComponentCommandParameterBuilder SetParameterType(Type type, IServiceProvider services) + { + base.SetParameterType(type); + + if (IsRouteSegmentParameter) + TypeReader = Command.Module.InteractionService.GetTypeReader(type); + else + TypeConverter = Command.Module.InteractionService.GetComponentTypeConverter(ParameterType, services); + + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public ComponentCommandParameterBuilder SetIsRouteSegment(bool isRouteSegment) + { + IsRouteSegmentParameter = isRouteSegment; + return this; + } + + internal override ComponentCommandParameterInfo Build(ICommandInfo command) + => new(this, command); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/IParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/IParameterBuilder.cs new file mode 100644 index 0000000..a11f722 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/IParameterBuilder.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represent a command builder for creating . + /// + public interface IParameterBuilder + { + /// + /// Gets the parent command of this parameter. + /// + ICommandBuilder Command { get; } + + /// + /// Gets the name of this parameter. + /// + string Name { get; } + + /// + /// Gets the type of this parameter. + /// + Type ParameterType { get; } + + /// + /// Gets whether this parameter is required. + /// + bool IsRequired { get; } + + /// + /// Gets whether this parameter is . + /// + bool IsParameterArray { get; } + + /// + /// Gets the default value of this parameter. + /// + object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this command. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Gets a collection of the preconditions of this command. + /// + IReadOnlyCollection Preconditions { get; } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder WithName(string name); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder SetParameterType(Type type); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder SetRequired(bool isRequired); + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + IParameterBuilder SetDefaultValue(object defaultValue); + + /// + /// Adds attributes to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + IParameterBuilder AddAttributes(params Attribute[] attributes); + + /// + /// Adds preconditions to . + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + IParameterBuilder AddPreconditions(params ParameterPreconditionAttribute[] preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs new file mode 100644 index 0000000..8cb9b3a --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/ModalCommandParameterBuilder.cs @@ -0,0 +1,52 @@ +using System; + +namespace Discord.Interactions.Builders +{ + + /// + /// Represents a builder for creating . + /// + public class ModalCommandParameterBuilder : ParameterBuilder + { + protected override ModalCommandParameterBuilder Instance => this; + + /// + /// Gets the built class for this parameter, if is . + /// + public ModalInfo Modal { get; private set; } + + /// + /// Gets whether or not this parameter is an . + /// + public bool IsModalParameter => Modal is not null; + + /// + /// Gets the assigned to this parameter, if is . + /// + public TypeReader TypeReader { get; private set; } + + internal ModalCommandParameterBuilder(ICommandBuilder command) : base(command) { } + + /// + /// Initializes a new . + /// + /// Parent command of this parameter. + /// Name of this command. + /// Type of this parameter. + public ModalCommandParameterBuilder(ICommandBuilder command, string name, Type type) : base(command, name, type) { } + + /// + public override ModalCommandParameterBuilder SetParameterType(Type type) + { + if (typeof(IModal).IsAssignableFrom(type)) + Modal = ModalUtils.GetOrAdd(type, Command.Module.InteractionService); + else + TypeReader = Command.Module.InteractionService.GetTypeReader(type); + + return base.SetParameterType(type); + } + + internal override ModalCommandParameterInfo Build(ICommandInfo command) => + new(this, command); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/ParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/ParameterBuilder.cs new file mode 100644 index 0000000..bf27a45 --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/ParameterBuilder.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents the base builder class for creating . + /// + /// The this builder yields when built. + /// Inherited type. + public abstract class ParameterBuilder : IParameterBuilder + where TInfo : class, IParameterInfo + where TBuilder : ParameterBuilder + { + private readonly List _preconditions; + private readonly List _attributes; + + /// + public ICommandBuilder Command { get; } + + /// + public string Name { get; internal set; } + + /// + public Type ParameterType { get; private set; } + + /// + public bool IsRequired { get; set; } = true; + + /// + public bool IsParameterArray { get; set; } = false; + + /// + public object DefaultValue { get; set; } + + /// + public IReadOnlyCollection Attributes => _attributes; + + /// + public IReadOnlyCollection Preconditions => _preconditions; + protected abstract TBuilder Instance { get; } + + internal ParameterBuilder(ICommandBuilder command) + { + _attributes = new List(); + _preconditions = new List(); + + Command = command; + } + + protected ParameterBuilder(ICommandBuilder command, string name, Type type) : this(command) + { + Name = name; + SetParameterType(type); + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder WithName(string name) + { + Name = name; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder SetParameterType(Type type) + { + ParameterType = type; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder SetRequired(bool isRequired) + { + IsRequired = isRequired; + return Instance; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public virtual TBuilder SetDefaultValue(object defaultValue) + { + DefaultValue = defaultValue; + return Instance; + } + + /// + /// Adds attributes to + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public virtual TBuilder AddAttributes(params Attribute[] attributes) + { + _attributes.AddRange(attributes); + return Instance; + } + + /// + /// Adds preconditions to + /// + /// New attributes to be added to . + /// + /// The builder instance. + /// + public virtual TBuilder AddPreconditions(params ParameterPreconditionAttribute[] attributes) + { + _preconditions.AddRange(attributes); + return Instance; + } + + internal abstract TInfo Build(ICommandInfo command); + + //IParameterBuilder + /// + IParameterBuilder IParameterBuilder.WithName(string name) => + WithName(name); + + /// + IParameterBuilder IParameterBuilder.SetParameterType(Type type) => + SetParameterType(type); + + /// + IParameterBuilder IParameterBuilder.SetRequired(bool isRequired) => + SetRequired(isRequired); + + /// + IParameterBuilder IParameterBuilder.SetDefaultValue(object defaultValue) => + SetDefaultValue(defaultValue); + + /// + IParameterBuilder IParameterBuilder.AddAttributes(params Attribute[] attributes) => + AddAttributes(attributes); + + /// + IParameterBuilder IParameterBuilder.AddPreconditions(params ParameterPreconditionAttribute[] preconditions) => + AddPreconditions(preconditions); + } +} diff --git a/src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs b/src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs new file mode 100644 index 0000000..c3e104e --- /dev/null +++ b/src/Discord.Net.Interactions/Builders/Parameters/SlashCommandParameterBuilder.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions.Builders +{ + /// + /// Represents a builder for creating . + /// + public sealed class SlashCommandParameterBuilder : ParameterBuilder + { + private readonly List _choices = new(); + private readonly List _channelTypes = new(); + private readonly List _complexParameterFields = new(); + + /// + /// Gets or sets the description of this parameter. + /// + public string Description { get; set; } + + /// + /// Gets or sets the max value of this parameter. + /// + public double? MaxValue { get; set; } + + /// + /// Gets or sets the min value of this parameter. + /// + public double? MinValue { get; set; } + + /// + /// Gets or sets the minimum length allowed for a string type parameter. + /// + public int? MinLength { get; set; } + + /// + /// Gets or sets the maximum length allowed for a string type parameter. + /// + public int? MaxLength { get; set; } + + /// + /// Gets a collection of the choices of this command. + /// + public IReadOnlyCollection Choices => _choices; + + /// + /// Gets a collection of the channel types of this command. + /// + public IReadOnlyCollection ChannelTypes => _channelTypes; + + /// + /// Gets the constructor parameters of this parameter, if is . + /// + public IReadOnlyCollection ComplexParameterFields => _complexParameterFields; + + /// + /// Gets or sets whether this parameter should be configured for Autocomplete Interactions. + /// + public bool Autocomplete { get; set; } + + /// + /// Gets or sets the of this parameter. + /// + public TypeConverter TypeConverter { get; private set; } + + /// + /// Gets whether this type should be treated as a complex parameter. + /// + public bool IsComplexParameter { get; internal set; } + + /// + /// Gets the initializer delegate for this parameter, if is . + /// + public ComplexParameterInitializer ComplexParameterInitializer { get; internal set; } + + /// + /// Gets or sets the of this parameter. + /// + public IAutocompleteHandler AutocompleteHandler { get; set; } + protected override SlashCommandParameterBuilder Instance => this; + + internal SlashCommandParameterBuilder(ICommandBuilder command) : base(command) { } + + /// + /// Initializes a new . + /// + /// Parent command of this parameter. + /// Name of this command. + /// Type of this parameter. + public SlashCommandParameterBuilder(ICommandBuilder command, string name, Type type, ComplexParameterInitializer complexParameterInitializer = null) + : base(command, name, type) + { + ComplexParameterInitializer = complexParameterInitializer; + + if (complexParameterInitializer is not null) + IsComplexParameter = true; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithMinValue(double value) + { + MinValue = value; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithMaxValue(double value) + { + MaxValue = value; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithMinLength(int length) + { + MinLength = length; + return this; + } + + /// + /// Sets . + /// + /// New value of the . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithMaxLength(int length) + { + MaxLength = length; + return this; + } + + /// + /// Adds parameter choices to . + /// + /// New choices to be added to . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithChoices(params ParameterChoice[] options) + { + _choices.AddRange(options); + return this; + } + + /// + /// Adds channel types to . + /// + /// New channel types to be added to . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithChannelTypes(params ChannelType[] channelTypes) + { + _channelTypes.AddRange(channelTypes); + return this; + } + + /// + /// Adds channel types to . + /// + /// New channel types to be added to . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithChannelTypes(IEnumerable channelTypes) + { + _channelTypes.AddRange(channelTypes); + return this; + } + + /// + /// Sets . + /// + /// Type of the . + /// Service container to be used to resolve the dependencies of this parameters . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder WithAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) + { + AutocompleteHandler = Command.Module.InteractionService.GetAutocompleteHandler(autocompleteHandlerType, services); + return this; + } + + /// + public override SlashCommandParameterBuilder SetParameterType(Type type) => SetParameterType(type, null); + + /// + /// Sets . + /// + /// New value of the . + /// Service container to be used to resolve the dependencies of this parameters . + /// + /// The builder instance. + /// + public SlashCommandParameterBuilder SetParameterType(Type type, IServiceProvider services = null) + { + base.SetParameterType(type); + + if (!IsComplexParameter) + TypeConverter = Command.Module.InteractionService.GetTypeConverter(ParameterType, services); + + return this; + } + + /// + /// Adds a parameter builders to . + /// + /// factory. + /// + /// The builder instance. + /// + /// Thrown if the added field has a . + public SlashCommandParameterBuilder AddComplexParameterField(Action configure) + { + SlashCommandParameterBuilder builder = new(Command); + configure(builder); + + if (builder.IsComplexParameter) + throw new InvalidOperationException("You cannot create nested complex parameters."); + + _complexParameterFields.Add(builder); + return this; + } + + /// + /// Adds parameter builders to . + /// + /// New parameter builders to be added to . + /// + /// The builder instance. + /// + /// Thrown if the added field has a . + public SlashCommandParameterBuilder AddComplexParameterFields(params SlashCommandParameterBuilder[] fields) + { + if (fields.Any(x => x.IsComplexParameter)) + throw new InvalidOperationException("You cannot create nested complex parameters."); + + _complexParameterFields.AddRange(fields); + return this; + } + + internal override SlashCommandParameterInfo Build(ICommandInfo command) => + new SlashCommandParameterInfo(this, command as SlashCommandInfo); + } +} diff --git a/src/Discord.Net.Interactions/Discord.Net.Interactions.csproj b/src/Discord.Net.Interactions/Discord.Net.Interactions.csproj index 74abf5c..9508d3a 100644 --- a/src/Discord.Net.Interactions/Discord.Net.Interactions.csproj +++ b/src/Discord.Net.Interactions/Discord.Net.Interactions.csproj @@ -1,10 +1,29 @@ - + + - Exe - net6.0 - enable - enable + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + Discord.Interactions + Discord.Net.Interactions + A Discord.Net extension adding support for Application Commands. + 5 + True + false + false + + + + + + + + + + + + + + diff --git a/src/Discord.Net.Interactions/Entities/IModal.cs b/src/Discord.Net.Interactions/Entities/IModal.cs new file mode 100644 index 0000000..572a880 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/IModal.cs @@ -0,0 +1,13 @@ +namespace Discord.Interactions +{ + /// + /// Represents a generic for use with the interaction service. + /// + public interface IModal + { + /// + /// Gets the modal's title. + /// + string Title { get; } + } +} diff --git a/src/Discord.Net.Interactions/Entities/ITypeConverter.cs b/src/Discord.Net.Interactions/Entities/ITypeConverter.cs new file mode 100644 index 0000000..c692b29 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/ITypeConverter.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal interface ITypeConverter + { + public bool CanConvertTo(Type type); + + public Task ReadAsync(IInteractionContext context, T option, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Entities/ParameterChoice.cs b/src/Discord.Net.Interactions/Entities/ParameterChoice.cs new file mode 100644 index 0000000..b129963 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/ParameterChoice.cs @@ -0,0 +1,24 @@ +namespace Discord.Interactions +{ + /// + /// Represents a Slash Command parameter choice. + /// + public class ParameterChoice + { + /// + /// Gets the name of the choice. + /// + public string Name { get; } + + /// + /// Gets the value of the choice. + /// + public object Value { get; } + + internal ParameterChoice(string name, object value) + { + Name = name; + Value = value; + } + } +} diff --git a/src/Discord.Net.Interactions/Entities/SlashCommandChoiceType.cs b/src/Discord.Net.Interactions/Entities/SlashCommandChoiceType.cs new file mode 100644 index 0000000..c3497d5 --- /dev/null +++ b/src/Discord.Net.Interactions/Entities/SlashCommandChoiceType.cs @@ -0,0 +1,21 @@ +namespace Discord.Interactions +{ + /// + /// Supported types of pre-defined parameter choices. + /// + public enum SlashCommandChoiceType + { + /// + /// Discord type for . + /// + String, + /// + /// Discord type for . + /// + Integer, + /// + /// Discord type for . + /// + Number + } +} diff --git a/src/Discord.Net.Interactions/Extensions/AutocompleteOptionComparer.cs b/src/Discord.Net.Interactions/Extensions/AutocompleteOptionComparer.cs new file mode 100644 index 0000000..9f16afa --- /dev/null +++ b/src/Discord.Net.Interactions/Extensions/AutocompleteOptionComparer.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Discord.Interactions +{ + internal class AutocompleteOptionComparer : IComparer + { + public int Compare(ApplicationCommandOptionType x, ApplicationCommandOptionType y) + { + if (x == ApplicationCommandOptionType.SubCommandGroup) + { + if (y == ApplicationCommandOptionType.SubCommandGroup) + return 0; + else + return 1; + } + else if (x == ApplicationCommandOptionType.SubCommand) + { + if (y == ApplicationCommandOptionType.SubCommandGroup) + return -1; + else if (y == ApplicationCommandOptionType.SubCommand) + return 0; + else + return 1; + } + else + { + if (y == ApplicationCommandOptionType.SubCommand || y == ApplicationCommandOptionType.SubCommandGroup) + return -1; + else + return 0; + } + } + } +} diff --git a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs new file mode 100644 index 0000000..377d673 --- /dev/null +++ b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs @@ -0,0 +1,91 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + public static class IDiscordInteractionExtentions + { + /// + /// Respond to an interaction with a . + /// + /// Type of the implementation. + /// The interaction to respond to. + /// Delegate that can be used to modify the modal. + /// The request options for this request. + /// A task that represents the asynchronous operation of responding to the interaction. + public static Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, RequestOptions options = null, Action modifyModal = null) + where T : class, IModal + { + if (!ModalUtils.TryGet(out var modalInfo)) + throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); + + return SendModalResponseAsync(interaction, customId, modalInfo, options, modifyModal); + } + + /// + /// Respond to an interaction with a . + /// + /// + /// This method overload uses the parameter to create a new + /// if there isn't a built one already in cache. + /// + /// Type of the implementation. + /// The interaction to respond to. + /// Interaction service instance that should be used to build s. + /// The request options for this request. + /// Delegate that can be used to modify the modal. + /// + public static Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, InteractionService interactionService, + RequestOptions options = null, Action modifyModal = null) + where T : class, IModal + { + var modalInfo = ModalUtils.GetOrAdd(interactionService); + + return SendModalResponseAsync(interaction, customId, modalInfo, options, modifyModal); + } + + /// + /// Respond to an interaction with an and fills the value fields of the modal using the property values of the provided + /// instance. + /// + /// Type of the implementation. + /// The interaction to respond to. + /// The instance to get field values from. + /// The request options for this request. + /// Delegate that can be used to modify the modal. + /// + public static Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, T modal, RequestOptions options = null, + Action modifyModal = null) + where T : class, IModal + { + if (!ModalUtils.TryGet(out var modalInfo)) + throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); + + var builder = new ModalBuilder(modal.Title, customId); + + foreach (var input in modalInfo.Components) + switch (input) + { + case TextInputComponentInfo textComponent: + { + builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, + textComponent.MaxLength, textComponent.IsRequired, textComponent.Getter(modal) as string); + } + break; + default: + throw new InvalidOperationException($"{input.GetType().FullName} isn't a valid component info class"); + } + + if (modifyModal is not null) + modifyModal(builder); + + return interaction.RespondWithModalAsync(builder.Build(), options); + } + + private static Task SendModalResponseAsync(IDiscordInteraction interaction, string customId, ModalInfo modalInfo, RequestOptions options = null, Action modifyModal = null) + { + var modal = modalInfo.ToModal(customId, modifyModal); + return interaction.RespondWithModalAsync(modal, options); + } + } +} diff --git a/src/Discord.Net.Interactions/Extensions/RestExtensions.cs b/src/Discord.Net.Interactions/Extensions/RestExtensions.cs new file mode 100644 index 0000000..917da68 --- /dev/null +++ b/src/Discord.Net.Interactions/Extensions/RestExtensions.cs @@ -0,0 +1,61 @@ +using Discord.Interactions; +using System; + +namespace Discord.Rest +{ + public static class RestExtensions + { + /// + /// Respond to an interaction with a . + /// + /// Type of the implementation. + /// The interaction to respond to. + /// The request options for this request. + /// Serialized payload to be used to create a HTTP response. + public static string RespondWithModal(this RestInteraction interaction, string customId, RequestOptions options = null, Action modifyModal = null) + where T : class, IModal + { + if (!ModalUtils.TryGet(out var modalInfo)) + throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); + + var modal = modalInfo.ToModal(customId, modifyModal); + return interaction.RespondWithModal(modal, options); + } + + /// + /// Respond to an interaction with an . + /// + /// Type of the implementation. + /// The interaction to respond to. + /// The instance to get field values from. + /// The request options for this request. + /// Delegate that can be used to modify the modal. + /// Serialized payload to be used to create a HTTP response. + public static string RespondWithModal(this RestInteraction interaction, string customId, T modal, RequestOptions options = null, Action modifyModal = null) + where T : class, IModal + { + if (!ModalUtils.TryGet(out var modalInfo)) + throw new ArgumentException($"{typeof(T).FullName} isn't referenced by any registered Modal Interaction Command and doesn't have a cached {typeof(ModalInfo)}"); + + var builder = new ModalBuilder(modal.Title, customId); + + foreach (var input in modalInfo.Components) + switch (input) + { + case TextInputComponentInfo textComponent: + { + builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, + textComponent.MaxLength, textComponent.IsRequired, textComponent.Getter(modal) as string); + } + break; + default: + throw new InvalidOperationException($"{input.GetType().FullName} isn't a valid component info class"); + } + + if (modifyModal is not null) + modifyModal(builder); + + return interaction.RespondWithModal(builder.Build(), options); + } + } +} diff --git a/src/Discord.Net.Interactions/Extensions/WebSocketExtensions.cs b/src/Discord.Net.Interactions/Extensions/WebSocketExtensions.cs new file mode 100644 index 0000000..388efcb --- /dev/null +++ b/src/Discord.Net.Interactions/Extensions/WebSocketExtensions.cs @@ -0,0 +1,53 @@ +using Discord.Rest; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.WebSocket +{ + internal static class WebSocketExtensions + { + /// + /// Get the name of the executed command and its parents in hierarchical order. + /// + /// + /// + /// The name of the executed command and its parents in hierarchical order. + /// + public static IList GetCommandKeywords(this IApplicationCommandInteractionData data) + { + var keywords = new List { data.Name }; + + var child = data.Options?.ElementAtOrDefault(0); + + while (child?.Type == ApplicationCommandOptionType.SubCommandGroup || child?.Type == ApplicationCommandOptionType.SubCommand) + { + keywords.Add(child.Name); + child = child.Options?.ElementAtOrDefault(0); + } + + return keywords; + } + + /// + /// Get the name of the executed command and its parents in hierarchical order. + /// + /// + /// + /// The name of the executed command and its parents in hierarchical order. + /// + public static IList GetCommandKeywords(this IAutocompleteInteractionData data) + { + var keywords = new List { data.CommandName }; + + var group = data.Options?.FirstOrDefault(x => x.Type == ApplicationCommandOptionType.SubCommandGroup); + if (group is not null) + keywords.Add(group.Name); + + var subcommand = data.Options?.FirstOrDefault(x => x.Type == ApplicationCommandOptionType.SubCommand); + if (subcommand is not null) + keywords.Add(subcommand.Name); + + return keywords; + } + } +} diff --git a/src/Discord.Net.Interactions/IInteractionModuleBase.cs b/src/Discord.Net.Interactions/IInteractionModuleBase.cs new file mode 100644 index 0000000..33aba04 --- /dev/null +++ b/src/Discord.Net.Interactions/IInteractionModuleBase.cs @@ -0,0 +1,54 @@ +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a generic interaction module base. + /// + public interface IInteractionModuleBase + { + /// + /// Sets the context of this module. + /// + /// + void SetContext(IInteractionContext context); + + /// + /// Method body to be executed asynchronously before executing an application command. + /// + /// Command information related to the Discord Application Command. + Task BeforeExecuteAsync(ICommandInfo command); + + /// + /// Method body to be executed before executing an application command. + /// + /// Command information related to the Discord Application Command. + void BeforeExecute(ICommandInfo command); + + /// + /// Method body to be executed asynchronously after an application command execution. + /// + /// Command information related to the Discord Application Command. + Task AfterExecuteAsync(ICommandInfo command); + + /// + /// Method body to be executed after an application command execution. + /// + /// Command information related to the Discord Application Command. + void AfterExecute(ICommandInfo command); + + /// + /// Method body to be executed when is called. + /// + /// Command Service instance that built this module. + /// Info class of this module. + void OnModuleBuilding(InteractionService commandService, ModuleInfo module); + + /// + /// Method body to be executed after the automated module creation is completed and before is called. + /// + /// Builder class of this module. + /// Command Service instance that is building this method. + void Construct(Builders.ModuleBuilder builder, InteractionService commandService); + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/AutocompleteCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/AutocompleteCommandInfo.cs new file mode 100644 index 0000000..ae6c8b4 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/AutocompleteCommandInfo.cs @@ -0,0 +1,85 @@ +using Discord.Interactions.Builders; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for handling Autocomplete Interaction events. + /// + public sealed class AutocompleteCommandInfo : CommandInfo + { + /// + /// Gets the name of the target parameter. + /// + public string ParameterName { get; } + + /// + /// Gets the name of the target command. + /// + public string CommandName { get; } + + /// + public override IReadOnlyList Parameters { get; } + + /// + public override bool SupportsWildCards => false; + + internal AutocompleteCommandInfo(AutocompleteCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + ParameterName = builder.ParameterName; + CommandName = builder.CommandName; + } + + /// + public override Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IAutocompleteInteraction) + return Task.FromResult((IResult)ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Autocomplete Interaction")); + + return base.ExecuteAsync(context, services); + } + + protected override Task ParseArgumentsAsync(IInteractionContext context, IServiceProvider services) + => Task.FromResult(ParseResult.FromSuccess(Array.Empty()) as IResult); + + /// + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) => + CommandService._autocompleteCommandExecutedEvent.InvokeAsync(this, context, result); + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Autocomplete Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Autocomplete Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + + internal IList GetCommandKeywords() + { + var keywords = new List() { ParameterName, CommandName }; + + if (!IgnoreGroupNames) + { + var currentParent = Module; + + while (currentParent != null) + { + if (!string.IsNullOrEmpty(currentParent.SlashGroupName)) + keywords.Add(currentParent.SlashGroupName); + + currentParent = currentParent.Parent; + } + } + + keywords.Reverse(); + + return keywords; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs new file mode 100644 index 0000000..475d140 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/CommandInfo.cs @@ -0,0 +1,275 @@ +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a cached method execution delegate. + /// + /// Execution context that will be injected into the module class. + /// Method arguments array. + /// Service collection for initializing the module. + /// Command info class of the executed method. + /// + /// A task representing the execution operation. + /// + public delegate Task ExecuteCallback(IInteractionContext context, object[] args, IServiceProvider serviceProvider, ICommandInfo commandInfo); + + /// + /// The base information class for commands. + /// + /// The type of that is used by this command type. + public abstract class CommandInfo : ICommandInfo where TParameter : class, IParameterInfo + { + private readonly ExecuteCallback _action; + private readonly ILookup _groupedPreconditions; + + internal IReadOnlyDictionary _parameterDictionary { get; } + + /// + public ModuleInfo Module { get; } + + /// + public InteractionService CommandService { get; } + + /// + public string Name { get; } + + /// + public string MethodName { get; } + + /// + public virtual bool IgnoreGroupNames { get; } + + /// + public abstract bool SupportsWildCards { get; } + + /// + public bool IsTopLevelCommand { get; } + + /// + public RunMode RunMode { get; } + + /// + public IReadOnlyCollection Attributes { get; } + + /// + public IReadOnlyCollection Preconditions { get; } + + /// + public abstract IReadOnlyList Parameters { get; } + + public bool TreatNameAsRegex { get; } + + internal CommandInfo(Builders.ICommandBuilder builder, ModuleInfo module, InteractionService commandService) + { + CommandService = commandService; + Module = module; + + Name = builder.Name; + MethodName = builder.MethodName; + IgnoreGroupNames = builder.IgnoreGroupNames; + IsTopLevelCommand = IgnoreGroupNames || CheckTopLevel(Module); + RunMode = builder.RunMode != RunMode.Default ? builder.RunMode : commandService._runMode; + Attributes = builder.Attributes.ToImmutableArray(); + Preconditions = builder.Preconditions.ToImmutableArray(); + TreatNameAsRegex = builder.TreatNameAsRegex && SupportsWildCards; + + _action = builder.Callback; + _groupedPreconditions = builder.Preconditions.ToLookup(x => x.Group, x => x, StringComparer.Ordinal); + _parameterDictionary = Parameters?.ToDictionary(x => x.Name, x => x).ToImmutableDictionary(); + } + + /// + public virtual Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + switch (RunMode) + { + case RunMode.Sync: + return ExecuteInternalAsync(context, services); + case RunMode.Async: + _ = Task.Run(async () => + { + await ExecuteInternalAsync(context, services).ConfigureAwait(false); + }); + break; + default: + throw new InvalidOperationException($"RunMode {RunMode} is not supported."); + } + + return Task.FromResult((IResult)ExecuteResult.FromSuccess()); + } + + protected abstract Task ParseArgumentsAsync(IInteractionContext context, IServiceProvider services); + + private async Task ExecuteInternalAsync(IInteractionContext context, IServiceProvider services) + { + await CommandService._cmdLogger.DebugAsync($"Executing {GetLogString(context)}").ConfigureAwait(false); + + using var scope = services?.CreateScope(); + + if (CommandService._autoServiceScopes) + services = scope?.ServiceProvider ?? EmptyServiceProvider.Instance; + + try + { + var preconditionResult = await CheckPreconditionsAsync(context, services).ConfigureAwait(false); + if (!preconditionResult.IsSuccess) + return await InvokeEventAndReturn(context, preconditionResult).ConfigureAwait(false); + + var argsResult = await ParseArgumentsAsync(context, services).ConfigureAwait(false); + + if (!argsResult.IsSuccess) + return await InvokeEventAndReturn(context, argsResult).ConfigureAwait(false); + + if (argsResult is not ParseResult parseResult) + return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Complex command parsing failed for an unknown reason."); + + var args = parseResult.Args; + + var index = 0; + foreach (var parameter in Parameters) + { + var result = await parameter.CheckPreconditionsAsync(context, args[index++], services).ConfigureAwait(false); + if (!result.IsSuccess) + return await InvokeEventAndReturn(context, result).ConfigureAwait(false); + } + + var task = _action(context, args, services, this); + + if (task is Task resultTask) + { + var result = await resultTask.ConfigureAwait(false); + await InvokeModuleEvent(context, result).ConfigureAwait(false); + if (result is RuntimeResult or ExecuteResult) + return result; + } + else + { + await task.ConfigureAwait(false); + return await InvokeEventAndReturn(context, ExecuteResult.FromSuccess()).ConfigureAwait(false); + } + + return await InvokeEventAndReturn(context, ExecuteResult.FromError(InteractionCommandError.Unsuccessful, "Command execution failed for an unknown reason")).ConfigureAwait(false); + } + catch (Exception ex) + { + var originalEx = ex; + while (ex is TargetInvocationException) + ex = ex.InnerException; + + var interactionException = new InteractionException(this, context, ex); + await Module.CommandService._cmdLogger.ErrorAsync(interactionException).ConfigureAwait(false); + + var result = ExecuteResult.FromError(ex); + await InvokeModuleEvent(context, result).ConfigureAwait(false); + + if (Module.CommandService._throwOnError) + { + if (ex == originalEx) + throw; + else + ExceptionDispatchInfo.Capture(ex).Throw(); + } + + return result; + } + finally + { + await CommandService._cmdLogger.VerboseAsync($"Executed {GetLogString(context)}").ConfigureAwait(false); + } + } + + protected abstract Task InvokeModuleEvent(IInteractionContext context, IResult result); + protected abstract string GetLogString(IInteractionContext context); + + /// + public async Task CheckPreconditionsAsync(IInteractionContext context, IServiceProvider services) + { + async Task CheckGroups(ILookup preconditions, string type) + { + foreach (IGrouping preconditionGroup in preconditions) + { + if (preconditionGroup.Key == null) + { + foreach (PreconditionAttribute precondition in preconditionGroup) + { + var result = await precondition.CheckRequirementsAsync(context, this, services).ConfigureAwait(false); + if (!result.IsSuccess) + return result; + } + } + else + { + var results = new List(); + foreach (PreconditionAttribute precondition in preconditionGroup) + results.Add(await precondition.CheckRequirementsAsync(context, this, services).ConfigureAwait(false)); + + if (!results.Any(p => p.IsSuccess)) + return PreconditionGroupResult.FromError($"{type} precondition group {preconditionGroup.Key} failed.", results); + } + } + return PreconditionGroupResult.FromSuccess(); + } + + var moduleResult = await CheckGroups(Module.GroupedPreconditions, "Module").ConfigureAwait(false); + if (!moduleResult.IsSuccess) + return moduleResult; + + var commandResult = await CheckGroups(_groupedPreconditions, "Command").ConfigureAwait(false); + return !commandResult.IsSuccess ? commandResult : PreconditionResult.FromSuccess(); + } + + protected async Task InvokeEventAndReturn(IInteractionContext context, T result) where T : IResult + { + await InvokeModuleEvent(context, result).ConfigureAwait(false); + return result; + } + + private static bool CheckTopLevel(ModuleInfo parent) + { + var currentParent = parent; + + while (currentParent != null) + { + if (currentParent.IsSlashGroup) + return false; + + currentParent = currentParent.Parent; + } + return true; + } + + // ICommandInfo + + /// + IReadOnlyCollection ICommandInfo.Parameters => Parameters; + + /// + public override string ToString() + { + List builder = new(); + + var currentParent = Module; + + while (currentParent != null) + { + if (currentParent.IsSlashGroup) + builder.Add(currentParent.SlashGroupName); + + currentParent = currentParent.Parent; + } + builder.Reverse(); + builder.Add(Name); + + return string.Join(" ", builder); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs new file mode 100644 index 0000000..37a467c --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ComponentCommandInfo.cs @@ -0,0 +1,81 @@ +using Discord.Interactions.Builders; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for handling Component Interaction events. + /// + public class ComponentCommandInfo : CommandInfo + { + /// + public override IReadOnlyList Parameters { get; } + + /// + public override bool SupportsWildCards => true; + + internal ComponentCommandInfo(ComponentCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + } + + /// + public override Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IComponentInteraction) + return Task.FromResult((IResult)ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Message Component Interaction")); + + return base.ExecuteAsync(context, services); + } + + protected override async Task ParseArgumentsAsync(IInteractionContext context, IServiceProvider services) + { + var captures = (context as IRouteMatchContainer)?.SegmentMatches?.ToList(); + var captureCount = captures?.Count() ?? 0; + + try + { + var data = (context.Interaction as IComponentInteraction).Data; + var args = new object[Parameters.Count]; + + for (var i = 0; i < Parameters.Count; i++) + { + var parameter = Parameters[i]; + var isCapture = i < captureCount; + + if (isCapture ^ parameter.IsRouteSegmentParameter) + return await InvokeEventAndReturn(context, ExecuteResult.FromError(InteractionCommandError.BadArgs, "Argument type and parameter type didn't match (Wild Card capture/Component value)")).ConfigureAwait(false); + + var readResult = isCapture ? await parameter.TypeReader.ReadAsync(context, captures[i].Value, services).ConfigureAwait(false) : + await parameter.TypeConverter.ReadAsync(context, data, services).ConfigureAwait(false); + + if (!readResult.IsSuccess) + return await InvokeEventAndReturn(context, readResult).ConfigureAwait(false); + + args[i] = readResult.Value; + } + + return ParseResult.FromSuccess(args); + } + catch (Exception ex) + { + return await InvokeEventAndReturn(context, ExecuteResult.FromError(ex)).ConfigureAwait(false); + } + } + + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) + => CommandService._componentCommandExecutedEvent.InvokeAsync(this, context, result); + + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Component Interaction: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Component Interaction: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ContextCommands/ContextCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/ContextCommandInfo.cs new file mode 100644 index 0000000..1e4b819 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/ContextCommandInfo.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base information class for attribute based context command handlers. + /// + public abstract class ContextCommandInfo : CommandInfo, IApplicationCommandInfo + { + /// + public ApplicationCommandType CommandType { get; } + + /// + public bool DefaultPermission { get; } + + /// + public bool IsEnabledInDm { get; } + + /// + public bool IsNsfw { get; } + + /// + public GuildPermission? DefaultMemberPermissions { get; } + + /// + public IReadOnlyCollection ContextTypes { get; } + + /// + public IReadOnlyCollection IntegrationTypes { get; } + + /// + public override IReadOnlyList Parameters { get; } + + /// + public override bool SupportsWildCards => false; + + /// + public override bool IgnoreGroupNames => true; + + internal ContextCommandInfo(Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + : base(builder, module, commandService) + { + CommandType = builder.CommandType; +#pragma warning disable CS0618 // Type or member is obsolete + DefaultPermission = builder.DefaultPermission; +#pragma warning restore CS0618 // Type or member is obsolete + IsNsfw = builder.IsNsfw; + IsEnabledInDm = builder.IsEnabledInDm; + DefaultMemberPermissions = builder.DefaultMemberPermissions; + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + ContextTypes = builder.ContextTypes?.ToImmutableArray(); + IntegrationTypes = builder.IntegrationTypes?.ToImmutableArray(); + } + + internal static ContextCommandInfo Create(Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + { + return builder.CommandType switch + { + ApplicationCommandType.User => new UserCommandInfo(builder, module, commandService), + ApplicationCommandType.Message => new MessageCommandInfo(builder, module, commandService), + _ => throw new InvalidOperationException("This command type is not a supported Context Command"), + }; + } + + /// + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) + => CommandService._contextCommandExecutedEvent.InvokeAsync(this, context, result); + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ContextCommands/MessageCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/MessageCommandInfo.cs new file mode 100644 index 0000000..d545f7a --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/MessageCommandInfo.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for command type . + /// + public class MessageCommandInfo : ContextCommandInfo + { + internal MessageCommandInfo(Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + : base(builder, module, commandService) { } + + /// + public override Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IMessageCommandInteraction) + return Task.FromResult((IResult)ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Message Command Interation")); + + return base.ExecuteAsync(context, services); + } + + protected override Task ParseArgumentsAsync(IInteractionContext context, IServiceProvider services) + { + try + { + object[] args = new object[1] { (context.Interaction as IMessageCommandInteraction).Data.Message }; + + return Task.FromResult(ParseResult.FromSuccess(args) as IResult); + } + catch (Exception ex) + { + return Task.FromResult(ParseResult.FromError(ex) as IResult); + } + } + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Message Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Message Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ContextCommands/UserCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/UserCommandInfo.cs new file mode 100644 index 0000000..9f28a5c --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ContextCommands/UserCommandInfo.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for command type . + /// + public class UserCommandInfo : ContextCommandInfo + { + internal UserCommandInfo(Builders.ContextCommandBuilder builder, ModuleInfo module, InteractionService commandService) + : base(builder, module, commandService) { } + + /// + public override Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IUserCommandInteraction userCommand) + return Task.FromResult((IResult)ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Message Command Interation")); + + return base.ExecuteAsync(context, services); + } + + protected override Task ParseArgumentsAsync(IInteractionContext context, IServiceProvider services) + { + try + { + object[] args = new object[1] { (context.Interaction as IUserCommandInteraction).Data.User }; + + return Task.FromResult(ParseResult.FromSuccess(args) as IResult); + } + catch (Exception ex) + { + return Task.FromResult(ParseResult.FromError(ex) as IResult); + } + } + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"User Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"User Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs new file mode 100644 index 0000000..280d599 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/ModalCommandInfo.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for handling Modal Interaction events. + /// + public class ModalCommandInfo : CommandInfo + { + /// + /// Gets the class for this commands parameter. + /// + public ModalInfo Modal { get; } + + /// + public override bool SupportsWildCards => true; + + /// + public override IReadOnlyList Parameters { get; } + + internal ModalCommandInfo(Builders.ModalCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + Modal = Parameters.Last().Modal; + } + + /// + public override Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not IModalInteraction modalInteraction) + return Task.FromResult((IResult)ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Modal Interaction.")); + + return base.ExecuteAsync(context, services); + } + + protected override async Task ParseArgumentsAsync(IInteractionContext context, IServiceProvider services) + { + var captures = (context as IRouteMatchContainer)?.SegmentMatches?.ToList(); + var captureCount = captures?.Count() ?? 0; + + try + { + var args = new object[Parameters.Count]; + + for (var i = 0; i < Parameters.Count; i++) + { + var parameter = Parameters.ElementAt(i); + + if (i < captureCount) + { + var readResult = await parameter.TypeReader.ReadAsync(context, captures[i].Value, services).ConfigureAwait(false); + if (!readResult.IsSuccess) + return await InvokeEventAndReturn(context, readResult).ConfigureAwait(false); + + args[i] = readResult.Value; + } + else + { + var modalResult = await Modal.CreateModalAsync(context, services, Module.CommandService._exitOnMissingModalField).ConfigureAwait(false); + if (!modalResult.IsSuccess) + return await InvokeEventAndReturn(context, modalResult).ConfigureAwait(false); + + if (modalResult is not TypeConverterResult converterResult) + return await InvokeEventAndReturn(context, ExecuteResult.FromError(InteractionCommandError.BadArgs, "Command parameter parsing failed for an unknown reason.")); + + args[i] = converterResult.Value; + } + } + + return ParseResult.FromSuccess(args); + } + catch (Exception ex) + { + return await InvokeEventAndReturn(context, ExecuteResult.FromError(ex)).ConfigureAwait(false); + } + } + + /// + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) + => CommandService._modalCommandExecutedEvent.InvokeAsync(this, context, result); + + /// + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Modal Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Modal Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs b/src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs new file mode 100644 index 0000000..63935d9 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Commands/SlashCommandInfo.cs @@ -0,0 +1,170 @@ +using Discord.Rest; +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the info class of an attribute based method for command type . + /// + public class SlashCommandInfo : CommandInfo, IApplicationCommandInfo + { + internal IReadOnlyDictionary _flattenedParameterDictionary { get; } + + /// + /// Gets the command description that will be displayed on Discord. + /// + public string Description { get; } + + /// + public ApplicationCommandType CommandType { get; } = ApplicationCommandType.Slash; + + /// + public bool DefaultPermission { get; } + + /// + public bool IsEnabledInDm { get; } + + /// + public bool IsNsfw { get; } + + /// + public GuildPermission? DefaultMemberPermissions { get; } + + /// + public override IReadOnlyList Parameters { get; } + + /// + public override bool SupportsWildCards => false; + + /// + /// Gets the flattened collection of command parameters and complex parameter fields. + /// + public IReadOnlyList FlattenedParameters { get; } + + /// + public IReadOnlyCollection ContextTypes { get; } + + /// + public IReadOnlyCollection IntegrationTypes { get; } + + internal SlashCommandInfo(Builders.SlashCommandBuilder builder, ModuleInfo module, InteractionService commandService) : base(builder, module, commandService) + { + Description = builder.Description; +#pragma warning disable CS0618 // Type or member is obsolete + DefaultPermission = builder.DefaultPermission; +#pragma warning restore CS0618 // Type or member is obsolete + IsEnabledInDm = builder.IsEnabledInDm; + IsNsfw = builder.IsNsfw; + DefaultMemberPermissions = builder.DefaultMemberPermissions; + Parameters = builder.Parameters.Select(x => x.Build(this)).ToImmutableArray(); + FlattenedParameters = FlattenParameters(Parameters).ToImmutableArray(); + ContextTypes = builder.ContextTypes?.ToImmutableArray(); + IntegrationTypes = builder.IntegrationTypes?.ToImmutableArray(); + + for (var i = 0; i < FlattenedParameters.Count - 1; i++) + if (!FlattenedParameters.ElementAt(i).IsRequired && FlattenedParameters.ElementAt(i + 1).IsRequired) + throw new InvalidOperationException("Optional parameters must appear after all required parameters, ComplexParameters with optional parameters must be located at the end."); + + _flattenedParameterDictionary = FlattenedParameters?.ToDictionary(x => x.Name, x => x).ToImmutableDictionary(); + } + + /// + public override Task ExecuteAsync(IInteractionContext context, IServiceProvider services) + { + if (context.Interaction is not ISlashCommandInteraction) + return Task.FromResult((IResult)ExecuteResult.FromError(InteractionCommandError.ParseFailed, $"Provided {nameof(IInteractionContext)} doesn't belong to a Slash Command Interaction")); + + return base.ExecuteAsync(context, services); + } + + protected override async Task ParseArgumentsAsync(IInteractionContext context, IServiceProvider services) + { + List GetOptions() + { + var options = (context.Interaction as ISlashCommandInteraction).Data.Options; + + while (options != null && options.Any(x => x.Type == ApplicationCommandOptionType.SubCommand || x.Type == ApplicationCommandOptionType.SubCommandGroup)) + options = options.ElementAt(0)?.Options; + + return options.ToList(); + } + + var options = GetOptions(); + var args = new object[Parameters.Count]; + for (var i = 0; i < Parameters.Count; i++) + { + var parameter = Parameters[i]; + var result = await ParseArgumentAsync(parameter, context, options, services).ConfigureAwait(false); + + if (!result.IsSuccess) + return ParseResult.FromError(result); + + if (result is not TypeConverterResult converterResult) + return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Complex command parsing failed for an unknown reason."); + + args[i] = converterResult.Value; + } + return ParseResult.FromSuccess(args); + } + + private async ValueTask ParseArgumentAsync(SlashCommandParameterInfo parameterInfo, IInteractionContext context, List argList, + IServiceProvider services) + { + if (parameterInfo.IsComplexParameter) + { + var ctorArgs = new object[parameterInfo.ComplexParameterFields.Count]; + + for (var i = 0; i < ctorArgs.Length; i++) + { + var result = await ParseArgumentAsync(parameterInfo.ComplexParameterFields.ElementAt(i), context, argList, services).ConfigureAwait(false); + + if (!result.IsSuccess) + return result; + + if (result is not TypeConverterResult converterResult) + return ExecuteResult.FromError(InteractionCommandError.BadArgs, "Complex command parsing failed for an unknown reason."); + + ctorArgs[i] = converterResult.Value; + } + + return TypeConverterResult.FromSuccess(parameterInfo._complexParameterInitializer(ctorArgs)); + } + + var arg = argList?.Find(x => string.Equals(x.Name, parameterInfo.Name, StringComparison.OrdinalIgnoreCase)); + + if (arg == default) + return parameterInfo.IsRequired ? ExecuteResult.FromError(InteractionCommandError.BadArgs, "Command was invoked with too few parameters") : + TypeConverterResult.FromSuccess(parameterInfo.DefaultValue); + + var typeConverter = parameterInfo.TypeConverter; + var readResult = await typeConverter.ReadAsync(context, arg, services).ConfigureAwait(false); + return readResult; + } + + protected override Task InvokeModuleEvent(IInteractionContext context, IResult result) + => CommandService._slashCommandExecutedEvent.InvokeAsync(this, context, result); + + protected override string GetLogString(IInteractionContext context) + { + if (context.Guild != null) + return $"Slash Command: \"{base.ToString()}\" for {context.User} in {context.Guild}/{context.Channel}"; + else + return $"Slash Command: \"{base.ToString()}\" for {context.User} in {context.Channel}"; + } + + private static IEnumerable FlattenParameters(IEnumerable parameters) + { + foreach (var parameter in parameters) + if (!parameter.IsComplexParameter) + yield return parameter; + else + foreach (var complexParameterField in parameter.ComplexParameterFields) + yield return complexParameterField; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/IApplicationCommandInfo.cs b/src/Discord.Net.Interactions/Info/IApplicationCommandInfo.cs new file mode 100644 index 0000000..972c43d --- /dev/null +++ b/src/Discord.Net.Interactions/Info/IApplicationCommandInfo.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions +{ + /// + /// Represents a command that can be registered to Discord. + /// + public interface IApplicationCommandInfo + { + /// + /// Gets the name of this command. + /// + string Name { get; } + + /// + /// Gets the type of this command. + /// + ApplicationCommandType CommandType { get; } + + /// + /// Gets the DefaultPermission of this command. + /// + [Obsolete($"To be deprecated soon, use {nameof(IsEnabledInDm)} and {nameof(DefaultMemberPermissions)} instead.")] + bool DefaultPermission { get; } + + /// + /// Gets whether this command can be used in DMs. + /// + public bool IsEnabledInDm { get; } + + /// + /// Gets whether this command can is age restricted. + /// + public bool IsNsfw { get; } + + /// + /// Gets the default permissions needed for executing this command. + /// + public GuildPermission? DefaultMemberPermissions { get; } + + /// + /// Gets the context types this command can be executed in. + /// + public IReadOnlyCollection ContextTypes { get; } + + /// + /// Gets the install methods for this command. + /// + public IReadOnlyCollection IntegrationTypes { get; } + } +} diff --git a/src/Discord.Net.Interactions/Info/ICommandInfo.cs b/src/Discord.Net.Interactions/Info/ICommandInfo.cs new file mode 100644 index 0000000..869707e --- /dev/null +++ b/src/Discord.Net.Interactions/Info/ICommandInfo.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represent a command information object that can be executed. + /// + public interface ICommandInfo + { + /// + /// Gets the name of the command. + /// + string Name { get; } + + /// + /// Gets the name of the command handler method. + /// + string MethodName { get; } + + /// + /// Gets if this command will be registered and executed as a standalone command, unaffected by the s of + /// of the commands parents. + /// + bool IgnoreGroupNames { get; } + + /// + /// Gets whether this command supports wild card patterns. + /// + bool SupportsWildCards { get; } + + /// + /// Gets if this command is a top level command and none of its parents have a . + /// + bool IsTopLevelCommand { get; } + + /// + /// Gets the module that the method belongs to. + /// + ModuleInfo Module { get; } + + /// + /// Gets the the underlying command service. + /// + InteractionService CommandService { get; } + + /// + /// Get the run mode this command gets executed with. + /// + RunMode RunMode { get; } + + /// + /// Gets a collection of the attributes of this command. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Gets a collection of the preconditions of this command. + /// + IReadOnlyCollection Preconditions { get; } + + /// + /// Gets a collection of the parameters of this command. + /// + IReadOnlyCollection Parameters { get; } + + bool TreatNameAsRegex { get; } + + /// + /// Executes the command with the provided context. + /// + /// The execution context. + /// Dependencies that will be used to create the module instance. + /// + /// A task representing the execution process. The task result contains the execution result. + /// + Task ExecuteAsync(IInteractionContext context, IServiceProvider services); + + /// + /// Check if an execution context meets the command precondition requirements. + /// + Task CheckPreconditionsAsync(IInteractionContext context, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Info/IParameterInfo.cs b/src/Discord.Net.Interactions/Info/IParameterInfo.cs new file mode 100644 index 0000000..654d623 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/IParameterInfo.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a parameter. + /// + public interface IParameterInfo + { + /// + /// Gets the command that this parameter belongs to. + /// + ICommandInfo Command { get; } + + /// + /// Gets the name of this parameter. + /// + string Name { get; } + + /// + /// Gets the type of this parameter. + /// + Type ParameterType { get; } + + /// + /// Gets whether this parameter is required. + /// + bool IsRequired { get; } + + /// + /// Gets whether this parameter is marked with a keyword. + /// + bool IsParameterArray { get; } + + /// + /// Gets the default value of this parameter if the parameter is optional. + /// + object DefaultValue { get; } + + /// + /// Gets a list of the attributes this parameter has. + /// + IReadOnlyCollection Attributes { get; } + + /// + /// Gets a list of the preconditions this parameter has. + /// + IReadOnlyCollection Preconditions { get; } + + /// + /// Check if an execution context meets the parameter precondition requirements. + /// + Task CheckPreconditionsAsync(IInteractionContext context, object value, IServiceProvider services); + } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs new file mode 100644 index 0000000..23a0db8 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/InputComponentInfo.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; + +namespace Discord.Interactions +{ + /// + /// Represents the base info class for input components. + /// + public abstract class InputComponentInfo + { + private Lazy> _getter; + internal Func Getter => _getter.Value; + + + /// + /// Gets the parent modal of this component. + /// + public ModalInfo Modal { get; } + + /// + /// Gets the custom id of this component. + /// + public string CustomId { get; } + + /// + /// Gets the label of this component. + /// + public string Label { get; } + + /// + /// Gets whether or not this component requires a user input. + /// + public bool IsRequired { get; } + + /// + /// Gets the type of this component. + /// + public ComponentType ComponentType { get; } + + /// + /// Gets the reference type of this component. + /// + public Type Type { get; } + + /// + /// Gets the property linked to this component. + /// + public PropertyInfo PropertyInfo { get; } + + /// + /// Gets the assigned to this component. + /// + public ComponentTypeConverter TypeConverter { get; } + + /// + /// Gets the default value of this component. + /// + public object DefaultValue { get; } + + /// + /// Gets a collection of the attributes of this command. + /// + public IReadOnlyCollection Attributes { get; } + + protected InputComponentInfo(Builders.IInputComponentBuilder builder, ModalInfo modal) + { + Modal = modal; + CustomId = builder.CustomId; + Label = builder.Label; + IsRequired = builder.IsRequired; + ComponentType = builder.ComponentType; + Type = builder.Type; + PropertyInfo = builder.PropertyInfo; + TypeConverter = builder.TypeConverter; + DefaultValue = builder.DefaultValue; + Attributes = builder.Attributes.ToImmutableArray(); + + _getter = new(() => ReflectionUtils.CreateLambdaPropertyGetter(Modal.Type, PropertyInfo)); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs b/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs new file mode 100644 index 0000000..613549f --- /dev/null +++ b/src/Discord.Net.Interactions/Info/InputComponents/TextInputComponentInfo.cs @@ -0,0 +1,42 @@ +namespace Discord.Interactions +{ + /// + /// Represents the class for type. + /// + public class TextInputComponentInfo : InputComponentInfo + { + /// + /// Gets the style of the text input. + /// + public TextInputStyle Style { get; } + + /// + /// Gets the placeholder of the text input. + /// + public string Placeholder { get; } + + /// + /// Gets the minimum length of the text input. + /// + public int MinLength { get; } + + /// + /// Gets the maximum length of the text input. + /// + public int MaxLength { get; } + + /// + /// Gets the initial value to be displayed by this input. + /// + public string InitialValue { get; } + + internal TextInputComponentInfo(Builders.TextInputComponentBuilder builder, ModalInfo modal) : base(builder, modal) + { + Style = builder.Style; + Placeholder = builder.Placeholder; + MinLength = builder.MinLength; + MaxLength = builder.MaxLength; + InitialValue = builder.InitialValue; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/ModalInfo.cs b/src/Discord.Net.Interactions/Info/ModalInfo.cs new file mode 100644 index 0000000..bef789a --- /dev/null +++ b/src/Discord.Net.Interactions/Info/ModalInfo.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a cached object initialization delegate. + /// + /// Property arguments array. + /// + /// Returns the constructed object. + /// + public delegate IModal ModalInitializer(object[] args); + + /// + /// Represents the info class of an form. + /// + public class ModalInfo + { + internal readonly InteractionService _interactionService; + internal readonly ModalInitializer _initializer; + + /// + /// Gets the title of this modal. + /// + public string Title { get; } + + /// + /// Gets the implementation used to initialize this object. + /// + public Type Type { get; } + + /// + /// Gets a collection of the components of this modal. + /// + public IReadOnlyCollection Components { get; } + + /// + /// Gets a collection of the text components of this modal. + /// + public IReadOnlyCollection TextComponents { get; } + + internal ModalInfo(Builders.ModalBuilder builder) + { + Title = builder.Title; + Type = builder.Type; + Components = builder.Components.Select(x => x switch + { + Builders.TextInputComponentBuilder textComponent => textComponent.Build(this), + _ => throw new InvalidOperationException($"{x.GetType().FullName} isn't a supported modal input component builder type.") + }).ToImmutableArray(); + + TextComponents = Components.OfType().ToImmutableArray(); + + _interactionService = builder._interactionService; + _initializer = builder.ModalInitializer; + } + + /// + /// Creates an and fills it with provided message components. + /// + /// that will be injected into the modal. + /// + /// A filled with the provided components. + /// + [Obsolete("This method is no longer supported with the introduction of Component TypeConverters, please use the CreateModalAsync method.")] + public IModal CreateModal(IModalInteraction modalInteraction, bool throwOnMissingField = false) + { + var args = new object[Components.Count]; + var components = modalInteraction.Data.Components.ToList(); + + for (var i = 0; i < Components.Count; i++) + { + var input = Components.ElementAt(i); + var component = components.Find(x => x.CustomId == input.CustomId); + + if (component is null) + { + if (!throwOnMissingField) + args[i] = input.DefaultValue; + else + throw new InvalidOperationException($"Modal interaction is missing the required field: {input.CustomId}"); + } + else + args[i] = component.Value; + } + + return _initializer(args); + } + + /// + /// Creates an and fills it with provided message components. + /// + /// Context of the that will be injected into the modal. + /// Services to be passed onto the s of the modal fields. + /// Whether or not this method should exit on encountering a missing modal field. + /// + /// A if a type conversion has failed, else a . + /// + public async Task CreateModalAsync(IInteractionContext context, IServiceProvider services = null, bool throwOnMissingField = false) + { + if (context.Interaction is not IModalInteraction modalInteraction) + return TypeConverterResult.FromError(InteractionCommandError.Unsuccessful, "Provided context doesn't belong to a Modal Interaction."); + + services ??= EmptyServiceProvider.Instance; + + var args = new object[Components.Count]; + var components = modalInteraction.Data.Components.ToList(); + + for (var i = 0; i < Components.Count; i++) + { + var input = Components.ElementAt(i); + var component = components.Find(x => x.CustomId == input.CustomId); + + if (component is null) + { + if (!throwOnMissingField) + args[i] = input.DefaultValue; + else + return TypeConverterResult.FromError(InteractionCommandError.BadArgs, $"Modal interaction is missing the required field: {input.CustomId}"); + } + else + { + var readResult = await input.TypeConverter.ReadAsync(context, component, services).ConfigureAwait(false); + + if (!readResult.IsSuccess) + return readResult; + + args[i] = readResult.Value; + } + } + + return TypeConverterResult.FromSuccess(_initializer(args)); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/ModuleInfo.cs b/src/Discord.Net.Interactions/Info/ModuleInfo.cs new file mode 100644 index 0000000..bec1c02 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/ModuleInfo.cs @@ -0,0 +1,280 @@ +using Discord.Interactions.Builders; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions +{ + /// + /// Contains the information of a Interactions Module. + /// + public class ModuleInfo + { + internal ILookup GroupedPreconditions { get; } + + /// + /// Gets the underlying command service. + /// + public InteractionService CommandService { get; } + + /// + /// Gets the name of this module class. + /// + public string Name { get; } + + /// + /// Gets the group name of this module, if the module is marked with a . + /// + public string SlashGroupName { get; } + + /// + /// Gets if this module is marked with a . + /// + public bool IsSlashGroup => !string.IsNullOrEmpty(SlashGroupName); + + /// + /// Gets the description of this module if is . + /// + public string Description { get; } + + /// + /// Gets the default Permission of this module. + /// + [Obsolete($"To be deprecated soon, use {nameof(IsEnabledInDm)} and {nameof(DefaultMemberPermissions)} instead.")] + public bool DefaultPermission { get; } + + /// + /// Gets whether this command can be used in DMs. + /// + public bool IsEnabledInDm { get; } + + /// + /// Gets whether this command is age restricted. + /// + public bool IsNsfw { get; } + + /// + /// Gets the default permissions needed for executing this command. + /// + public GuildPermission? DefaultMemberPermissions { get; } + + /// + /// Gets the collection of Sub Modules of this module. + /// + public IReadOnlyList SubModules { get; } + + /// + /// Gets the Slash Commands that are declared in this module. + /// + public IReadOnlyList SlashCommands { get; } + + /// + /// Gets the Context Commands that are declared in this module. + /// + public IReadOnlyList ContextCommands { get; } + + /// + /// Gets the Component Commands that are declared in this module. + /// + public IReadOnlyCollection ComponentCommands { get; } + + /// + /// Gets the Autocomplete Commands that are declared in this module. + /// + public IReadOnlyCollection AutocompleteCommands { get; } + + public IReadOnlyCollection ModalCommands { get; } + + /// + /// Gets the declaring type of this module, if is . + /// + public ModuleInfo Parent { get; } + + /// + /// Gets if this module is declared by another . + /// + public bool IsSubModule => Parent != null; + + /// + /// Gets a collection of the attributes of this module. + /// + public IReadOnlyCollection Attributes { get; } + + /// + /// Gets a collection of the preconditions of this module. + /// + public IReadOnlyCollection Preconditions { get; } + + /// + /// Gets if this module has a valid and has no parent with a . + /// + public bool IsTopLevelGroup { get; } + + /// + /// Gets if this module will not be registered by + /// or methods. + /// + public bool DontAutoRegister { get; } + + /// + /// Gets the context types commands in this module can be executed in. + /// + public IReadOnlyCollection ContextTypes { get; } + + /// + /// Gets the install method for commands in this module. + /// + public IReadOnlyCollection IntegrationTypes { get; } + + internal ModuleInfo(ModuleBuilder builder, InteractionService commandService, IServiceProvider services, ModuleInfo parent = null) + { + CommandService = commandService; + + Name = builder.Name; + SlashGroupName = builder.SlashGroupName; + Description = builder.Description; + Parent = parent; +#pragma warning disable CS0618 // Type or member is obsolete + DefaultPermission = builder.DefaultPermission; +#pragma warning restore CS0618 // Type or member is obsolete + IsNsfw = builder.IsNsfw; +#pragma warning disable CS0618 // Type or member is obsolete + IsEnabledInDm = builder.IsEnabledInDm; +#pragma warning restore CS0618 // Type or member is obsolete + DefaultMemberPermissions = BuildDefaultMemberPermissions(builder); + SlashCommands = BuildSlashCommands(builder).ToImmutableArray(); + ContextCommands = BuildContextCommands(builder).ToImmutableArray(); + ComponentCommands = BuildComponentCommands(builder).ToImmutableArray(); + AutocompleteCommands = BuildAutocompleteCommands(builder).ToImmutableArray(); + ModalCommands = BuildModalCommands(builder).ToImmutableArray(); + SubModules = BuildSubModules(builder, commandService, services).ToImmutableArray(); + Attributes = BuildAttributes(builder).ToImmutableArray(); + Preconditions = BuildPreconditions(builder).ToImmutableArray(); + IsTopLevelGroup = IsSlashGroup && CheckTopLevel(parent); + DontAutoRegister = builder.DontAutoRegister; + ContextTypes = builder.ContextTypes?.ToImmutableArray(); + IntegrationTypes = builder.IntegrationTypes?.ToImmutableArray(); + + GroupedPreconditions = Preconditions.ToLookup(x => x.Group, x => x, StringComparer.Ordinal); + } + + private IEnumerable BuildSubModules(ModuleBuilder builder, InteractionService commandService, IServiceProvider services) + { + var result = new List(); + + foreach (Builders.ModuleBuilder moduleBuilder in builder.SubModules) + result.Add(moduleBuilder.Build(commandService, services, this)); + + return result; + } + + private IEnumerable BuildSlashCommands(ModuleBuilder builder) + { + var result = new List(); + + foreach (Builders.SlashCommandBuilder commandBuilder in builder.SlashCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildContextCommands(ModuleBuilder builder) + { + var result = new List(); + + foreach (Builders.ContextCommandBuilder commandBuilder in builder.ContextCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildComponentCommands(ModuleBuilder builder) + { + var result = new List(); + + foreach (var interactionBuilder in builder.ComponentCommands) + result.Add(interactionBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildAutocompleteCommands(ModuleBuilder builder) + { + var result = new List(); + + foreach (var commandBuilder in builder.AutocompleteCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildModalCommands(ModuleBuilder builder) + { + var result = new List(); + + foreach (var commandBuilder in builder.ModalCommands) + result.Add(commandBuilder.Build(this, CommandService)); + + return result; + } + + private IEnumerable BuildAttributes(ModuleBuilder builder) + { + var result = new List(); + var currentParent = builder; + + while (currentParent != null) + { + result.AddRange(currentParent.Attributes); + currentParent = currentParent.Parent; + } + + return result; + } + + private static IEnumerable BuildPreconditions(ModuleBuilder builder) + { + var preconditions = new List(); + + var parent = builder; + + while (parent != null) + { + preconditions.AddRange(parent.Preconditions); + parent = parent.Parent; + } + + return preconditions; + } + + private static bool CheckTopLevel(ModuleInfo parent) + { + var currentParent = parent; + + while (currentParent != null) + { + if (currentParent.IsSlashGroup) + return false; + + currentParent = currentParent.Parent; + } + return true; + } + + private static GuildPermission? BuildDefaultMemberPermissions(ModuleBuilder builder) + { + var permissions = builder.DefaultMemberPermissions; + + var parent = builder.Parent; + + while (parent != null) + { + permissions = (permissions ?? 0) | (parent.DefaultMemberPermissions ?? 0).SanitizeGuildPermissions(); + parent = parent.Parent; + } + + return permissions; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Parameters/CommandParameterInfo.cs b/src/Discord.Net.Interactions/Info/Parameters/CommandParameterInfo.cs new file mode 100644 index 0000000..f1e76ce --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Parameters/CommandParameterInfo.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents the base parameter info class for commands. + /// + public class CommandParameterInfo : IParameterInfo + { + /// + public ICommandInfo Command { get; } + + /// + public string Name { get; } + + /// + public Type ParameterType { get; } + + /// + public bool IsRequired { get; } + + /// + public bool IsParameterArray { get; } + + /// + public object DefaultValue { get; } + + /// + public IReadOnlyCollection Attributes { get; } + + /// + public IReadOnlyCollection Preconditions { get; } + + internal CommandParameterInfo(Builders.IParameterBuilder builder, ICommandInfo command) + { + Command = command; + Name = builder.Name; + ParameterType = builder.ParameterType; + IsRequired = builder.IsRequired; + IsParameterArray = builder.IsParameterArray; + DefaultValue = builder.DefaultValue; + Attributes = builder.Attributes.ToImmutableArray(); + Preconditions = builder.Preconditions.ToImmutableArray(); + } + + /// + public async Task CheckPreconditionsAsync(IInteractionContext context, object value, IServiceProvider services) + { + foreach (var precondition in Preconditions) + { + var result = await precondition.CheckRequirementsAsync(context, this, value, services).ConfigureAwait(false); + if (!result.IsSuccess) + return result; + } + + return PreconditionResult.FromSuccess(); + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Parameters/ComponentCommandParameterInfo.cs b/src/Discord.Net.Interactions/Info/Parameters/ComponentCommandParameterInfo.cs new file mode 100644 index 0000000..36b75dd --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Parameters/ComponentCommandParameterInfo.cs @@ -0,0 +1,34 @@ +using Discord.Interactions.Builders; + +namespace Discord.Interactions +{ + /// + /// Represents the parameter info class for commands. + /// + public class ComponentCommandParameterInfo : CommandParameterInfo + { + /// + /// Gets the that will be used to convert a message component value into + /// , if is false. + /// + public ComponentTypeConverter TypeConverter { get; } + + /// + /// Gets the that will be used to convert a CustomId segment value into + /// , if is . + /// + public TypeReader TypeReader { get; } + + /// + /// Gets whether this parameter is a CustomId segment or a component value parameter. + /// + public bool IsRouteSegmentParameter { get; } + + internal ComponentCommandParameterInfo(ComponentCommandParameterBuilder builder, ICommandInfo command) : base(builder, command) + { + TypeConverter = builder.TypeConverter; + TypeReader = builder.TypeReader; + IsRouteSegmentParameter = builder.IsRouteSegmentParameter; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs b/src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs new file mode 100644 index 0000000..cafb0b7 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Parameters/ModalCommandParameterInfo.cs @@ -0,0 +1,35 @@ +using Discord.Interactions.Builders; + +namespace Discord.Interactions +{ + /// + /// Represents the base parameter info class for modals. + /// + public class ModalCommandParameterInfo : CommandParameterInfo + { + /// + /// Gets the class for this parameter if is true. + /// + public ModalInfo Modal { get; private set; } + + /// + /// Gets whether this parameter is an + /// + public bool IsModalParameter { get; } + + /// + /// Gets the assigned to this parameter, if is . + /// + public TypeReader TypeReader { get; } + + /// + public new ModalCommandInfo Command => base.Command as ModalCommandInfo; + + internal ModalCommandParameterInfo(ModalCommandParameterBuilder builder, ICommandInfo command) : base(builder, command) + { + Modal = builder.Modal; + IsModalParameter = builder.IsModalParameter; + TypeReader = builder.TypeReader; + } + } +} diff --git a/src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs b/src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs new file mode 100644 index 0000000..e5faa92 --- /dev/null +++ b/src/Discord.Net.Interactions/Info/Parameters/SlashCommandParameterInfo.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions +{ + /// + /// Represents a cached argument constructor delegate. + /// + /// Method arguments array. + /// + /// Returns the constructed object. + /// + public delegate object ComplexParameterInitializer(object[] args); + + /// + /// Represents the parameter info class for commands. + /// + public class SlashCommandParameterInfo : CommandParameterInfo + { + internal readonly ComplexParameterInitializer _complexParameterInitializer; + + /// + public new SlashCommandInfo Command => base.Command as SlashCommandInfo; + + /// + /// Gets the description of the Slash Command Parameter. + /// + public string Description { get; } + + /// + /// Gets the minimum value permitted for a number type parameter. + /// + public double? MinValue { get; } + + /// + /// Gets the maximum value permitted for a number type parameter. + /// + public double? MaxValue { get; } + + /// + /// Gets the minimum length allowed for a string type parameter. + /// + public int? MinLength { get; } + + /// + /// Gets the maximum length allowed for a string type parameter. + /// + public int? MaxLength { get; } + + /// + /// Gets the that will be used to convert the incoming into + /// . + /// + public TypeConverter TypeConverter { get; } + + /// + /// Gets the that's linked to this parameter. + /// + public IAutocompleteHandler AutocompleteHandler { get; } + + /// + /// Gets whether this parameter is configured for Autocomplete Interactions. + /// + public bool IsAutocomplete { get; } + + /// + /// Gets whether this type should be treated as a complex parameter. + /// + public bool IsComplexParameter { get; } + + /// + /// Gets the Discord option type this parameter represents. If the parameter is not a complex parameter. + /// + public ApplicationCommandOptionType? DiscordOptionType => TypeConverter?.GetDiscordType(); + + /// + /// Gets the parameter choices of this Slash Application Command parameter. + /// + public IReadOnlyCollection Choices { get; } + + /// + /// Gets the allowed channel types for this option. + /// + public IReadOnlyCollection ChannelTypes { get; } + + /// + /// Gets the constructor parameters of this parameter, if is . + /// + public IReadOnlyCollection ComplexParameterFields { get; } + + internal SlashCommandParameterInfo(Builders.SlashCommandParameterBuilder builder, SlashCommandInfo command) : base(builder, command) + { + TypeConverter = builder.TypeConverter; + AutocompleteHandler = builder.AutocompleteHandler; + Description = builder.Description; + MaxValue = builder.MaxValue; + MinValue = builder.MinValue; + MinLength = builder.MinLength; + MaxLength = builder.MaxLength; + IsComplexParameter = builder.IsComplexParameter; + IsAutocomplete = builder.Autocomplete; + Choices = builder.Choices.ToImmutableArray(); + ChannelTypes = builder.ChannelTypes.ToImmutableArray(); + ComplexParameterFields = builder.ComplexParameterFields?.Select(x => x.Build(command)).ToImmutableArray(); + + _complexParameterInitializer = builder.ComplexParameterInitializer; + } + } +} diff --git a/src/Discord.Net.Interactions/InteractionCommandError.cs b/src/Discord.Net.Interactions/InteractionCommandError.cs new file mode 100644 index 0000000..7000e9a --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionCommandError.cs @@ -0,0 +1,43 @@ +namespace Discord.Interactions +{ + /// + /// Defines the type of error a command can throw. + /// + public enum InteractionCommandError + { + /// + /// Thrown when the command is unknown. + /// + UnknownCommand, + + /// + /// Thrown when the Slash Command parameter fails to be converted by a TypeReader. + /// + ConvertFailed, + + /// + /// Thrown when the input text has too few or too many arguments. + /// + BadArgs, + + /// + /// Thrown when an exception occurs mid-command execution. + /// + Exception, + + /// + /// Thrown when the command is not successfully executed on runtime. + /// + Unsuccessful, + + /// + /// Thrown when the command fails to meet a 's conditions. + /// + UnmetPrecondition, + + /// + /// Thrown when the command context cannot be parsed by the . + /// + ParseFailed + } +} diff --git a/src/Discord.Net.Interactions/InteractionContext.cs b/src/Discord.Net.Interactions/InteractionContext.cs new file mode 100644 index 0000000..2c1dba4 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionContext.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + public class InteractionContext : IInteractionContext, IRouteMatchContainer + { + /// + public IDiscordClient Client { get; } + /// + public IGuild Guild { get; } + /// + public IMessageChannel Channel { get; } + /// + public IUser User { get; } + /// + public IDiscordInteraction Interaction { get; } + /// + public IReadOnlyCollection SegmentMatches { get; private set; } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + /// the command originated from. + public InteractionContext(IDiscordClient client, IDiscordInteraction interaction, IMessageChannel channel = null) + { + Client = client; + Interaction = interaction; + Channel = channel; + Guild = (interaction.User as IGuildUser)?.Guild; + User = interaction.User; + Interaction = interaction; + } + + /// + public void SetSegmentMatches(IEnumerable segmentMatches) => SegmentMatches = segmentMatches.ToImmutableArray(); + + //IRouteMatchContainer + /// + IEnumerable IRouteMatchContainer.SegmentMatches => SegmentMatches; + } +} diff --git a/src/Discord.Net.Interactions/InteractionException.cs b/src/Discord.Net.Interactions/InteractionException.cs new file mode 100644 index 0000000..c1d4c3f --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionException.cs @@ -0,0 +1,17 @@ +using System; + +namespace Discord.Interactions +{ + public class InteractionException : Exception + { + public ICommandInfo CommandInfo { get; } + public IInteractionContext InteractionContext { get; } + + public InteractionException(ICommandInfo commandInfo, IInteractionContext context, Exception exception) + : base($"Error occurred executing {commandInfo}.", exception) + { + CommandInfo = commandInfo; + InteractionContext = context; + } + } +} diff --git a/src/Discord.Net.Interactions/InteractionModuleBase.cs b/src/Discord.Net.Interactions/InteractionModuleBase.cs new file mode 100644 index 0000000..a5e56e9 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionModuleBase.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Provides a base class for a command module to inherit from. + /// + /// Type of interaction context to be injected into the module. + public abstract class InteractionModuleBase : IInteractionModuleBase where T : class, IInteractionContext + { + /// + /// Gets the underlying context of the command. + /// + public T Context { get; private set; } + + /// + public virtual void AfterExecute(ICommandInfo command) { } + + /// + public virtual void BeforeExecute(ICommandInfo command) { } + + /// + public virtual Task BeforeExecuteAsync(ICommandInfo command) => Task.CompletedTask; + + /// + public virtual Task AfterExecuteAsync(ICommandInfo command) => Task.CompletedTask; + + /// + public virtual void OnModuleBuilding(InteractionService commandService, ModuleInfo module) { } + + /// + public virtual void Construct(Builders.ModuleBuilder builder, InteractionService commandService) { } + + internal void SetContext(IInteractionContext context) + { + var newValue = context as T; + Context = newValue ?? throw new InvalidOperationException($"Invalid context type. Expected {typeof(T).Name}, got {context.GetType().Name}."); + } + + /// + protected virtual Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + => Context.Interaction.DeferAsync(ephemeral, options); + + /// + protected virtual Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent components = null, Embed embed = null) + => Context.Interaction.RespondAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task RespondWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.RespondWithFileAsync(fileStream, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task RespondWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.RespondWithFileAsync(filePath, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task RespondWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.RespondWithFileAsync(attachment, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task RespondWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.RespondWithFilesAsync(attachments, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent components = null, Embed embed = null) + => Context.Interaction.FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.FollowupWithFileAsync(fileStream, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.FollowupWithFileAsync(filePath, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.FollowupWithFileAsync(attachment, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => Context.Interaction.FollowupWithFilesAsync(attachments, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + protected virtual Task ReplyAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, + AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => Context.Channel.SendMessageAsync(text, false, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags); + + /// + protected virtual Task GetOriginalResponseAsync(RequestOptions options = null) + => Context.Interaction.GetOriginalResponseAsync(options); + + /// + protected virtual Task ModifyOriginalResponseAsync(Action func, RequestOptions options = null) + => Context.Interaction.ModifyOriginalResponseAsync(func, options); + + /// + protected virtual async Task DeleteOriginalResponseAsync() + { + var response = await Context.Interaction.GetOriginalResponseAsync().ConfigureAwait(false); + await response.DeleteAsync().ConfigureAwait(false); + } + + /// + protected virtual Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + => Context.Interaction.RespondWithModalAsync(modal, options); + + /// + protected virtual Task RespondWithModalAsync(string customId, TModal modal, RequestOptions options = null, Action modifyModal = null) where TModal : class, IModal + => Context.Interaction.RespondWithModalAsync(customId, modal, options, modifyModal); + + /// + protected virtual Task RespondWithModalAsync(string customId, RequestOptions options = null, Action modifyModal = null) where TModal : class, IModal + => Context.Interaction.RespondWithModalAsync(customId, options, modifyModal); + + /// + protected virtual Task RespondWithPremiumRequiredAsync(RequestOptions options = null) + => Context.Interaction.RespondWithPremiumRequiredAsync(options); + + //IInteractionModuleBase + + /// + void IInteractionModuleBase.SetContext(IInteractionContext context) => SetContext(context); + } + + /// + /// Provides a base class for a command module to inherit from. + /// + public abstract class InteractionModuleBase : InteractionModuleBase { } +} diff --git a/src/Discord.Net.Interactions/InteractionService.cs b/src/Discord.Net.Interactions/InteractionService.cs new file mode 100644 index 0000000..3089aa5 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionService.cs @@ -0,0 +1,1353 @@ +using Discord.Interactions.Builders; +using Discord.Logging; +using Discord.Rest; +using Discord.WebSocket; +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Provides the framework for building and registering Discord Application Commands. + /// + public class InteractionService : IDisposable + { + /// + /// Occurs when a Slash Command related information is received. + /// + public event Func Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } } + internal readonly AsyncEvent> _logEvent = new(); + + /// + /// Occurs when any type of interaction is executed. + /// + public event Func InteractionExecuted + { + add + { + SlashCommandExecuted += value; + ContextCommandExecuted += value; + ComponentCommandExecuted += value; + AutocompleteCommandExecuted += value; + ModalCommandExecuted += value; + } + remove + { + SlashCommandExecuted -= value; + ContextCommandExecuted -= value; + ComponentCommandExecuted -= value; + AutocompleteCommandExecuted -= value; + ModalCommandExecuted -= value; + } + } + + /// + /// Occurs when a Slash Command is executed. + /// + public event Func SlashCommandExecuted { add { _slashCommandExecutedEvent.Add(value); } remove { _slashCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _slashCommandExecutedEvent = new(); + + /// + /// Occurs when a Context Command is executed. + /// + public event Func ContextCommandExecuted { add { _contextCommandExecutedEvent.Add(value); } remove { _contextCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _contextCommandExecutedEvent = new(); + + /// + /// Occurs when a Message Component command is executed. + /// + public event Func ComponentCommandExecuted { add { _componentCommandExecutedEvent.Add(value); } remove { _componentCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _componentCommandExecutedEvent = new(); + + /// + /// Occurs when a Autocomplete command is executed. + /// + public event Func AutocompleteCommandExecuted { add { _autocompleteCommandExecutedEvent.Add(value); } remove { _autocompleteCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _autocompleteCommandExecutedEvent = new(); + + /// + /// Occurs when a AutocompleteHandler is executed. + /// + public event Func AutocompleteHandlerExecuted { add { _autocompleteHandlerExecutedEvent.Add(value); } remove { _autocompleteHandlerExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _autocompleteHandlerExecutedEvent = new(); + + /// + /// Occurs when a Modal command is executed. + /// + public event Func ModalCommandExecuted { add { _modalCommandExecutedEvent.Add(value); } remove { _modalCommandExecutedEvent.Remove(value); } } + internal readonly AsyncEvent> _modalCommandExecutedEvent = new(); + + /// + /// Get the used by this Interaction Service instance to localize strings. + /// + public ILocalizationManager LocalizationManager { get; set; } + + private readonly ConcurrentDictionary _typedModuleDefs; + private readonly CommandMap _slashCommandMap; + private readonly ConcurrentDictionary> _contextCommandMaps; + private readonly CommandMap _componentCommandMap; + private readonly CommandMap _autocompleteCommandMap; + private readonly CommandMap _modalCommandMap; + private readonly HashSet _moduleDefs; + private readonly TypeMap _typeConverterMap; + private readonly TypeMap _compTypeConverterMap; + private readonly TypeMap _typeReaderMap; + private readonly ConcurrentDictionary _autocompleteHandlers = new(); + private readonly ConcurrentDictionary _modalInfos = new(); + private readonly SemaphoreSlim _lock; + internal readonly Logger _cmdLogger; + internal readonly LogManager _logManager; + internal readonly Func _getRestClient; + + internal readonly bool _throwOnError, _useCompiledLambda, _enableAutocompleteHandlers, _autoServiceScopes, _exitOnMissingModalField; + internal readonly string _wildCardExp; + internal readonly RunMode _runMode; + internal readonly RestResponseCallback _restResponseCallback; + + /// + /// Rest client to be used to register application commands. + /// + public DiscordRestClient RestClient { get => _getRestClient(); } + + /// + /// Represents all modules loaded within . + /// + public IReadOnlyList Modules => _moduleDefs.ToList(); + + /// + /// Represents all Slash Commands loaded within . + /// + public IReadOnlyList SlashCommands => _moduleDefs.SelectMany(x => x.SlashCommands).ToList(); + + /// + /// Represents all Context Commands loaded within . + /// + public IReadOnlyList ContextCommands => _moduleDefs.SelectMany(x => x.ContextCommands).ToList(); + + /// + /// Represents all Component Commands loaded within . + /// + public IReadOnlyCollection ComponentCommands => _moduleDefs.SelectMany(x => x.ComponentCommands).ToList(); + + /// + /// Represents all Modal Commands loaded within . + /// + public IReadOnlyCollection ModalCommands => _moduleDefs.SelectMany(x => x.ModalCommands).ToList(); + + /// + /// Gets a collection of the cached classes that are referenced in registered s. + /// + public IReadOnlyCollection Modals => ModalUtils.Modals; + + /// + /// Initialize a with provided configurations. + /// + /// The discord client. + /// The configuration class. + public InteractionService(DiscordRestClient discord, InteractionServiceConfig config = null) + : this(() => discord, config ?? new InteractionServiceConfig()) { } + + /// + /// Initialize a with provided configurations. + /// + /// The discord client provider. + /// The configuration class. + public InteractionService(IRestClientProvider discordProvider, InteractionServiceConfig config = null) + : this(() => discordProvider.RestClient, config ?? new InteractionServiceConfig()) { } + + private InteractionService(Func getRestClient, InteractionServiceConfig config = null) + { + config ??= new InteractionServiceConfig(); + + _lock = new SemaphoreSlim(1, 1); + _typedModuleDefs = new ConcurrentDictionary(); + _moduleDefs = new HashSet(); + + _logManager = new LogManager(config.LogLevel); + _logManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + _cmdLogger = _logManager.CreateLogger("App Commands"); + + _slashCommandMap = new CommandMap(this); + _contextCommandMaps = new ConcurrentDictionary>(); + _componentCommandMap = new CommandMap(this, config.InteractionCustomIdDelimiters); + _autocompleteCommandMap = new CommandMap(this); + _modalCommandMap = new CommandMap(this, config.InteractionCustomIdDelimiters); + + _getRestClient = getRestClient; + + _runMode = config.DefaultRunMode; + if (_runMode == RunMode.Default) + throw new InvalidOperationException($"RunMode cannot be set to {RunMode.Default}"); + + _throwOnError = config.ThrowOnError; + _wildCardExp = config.WildCardExpression; + _useCompiledLambda = config.UseCompiledLambda; + _exitOnMissingModalField = config.ExitOnMissingModalField; + _enableAutocompleteHandlers = config.EnableAutocompleteHandlers; + _autoServiceScopes = config.AutoServiceScopes; + _restResponseCallback = config.RestResponseCallback; + LocalizationManager = config.LocalizationManager; + + _typeConverterMap = new TypeMap(this, new ConcurrentDictionary + { + [typeof(TimeSpan)] = new TimeSpanConverter() + }, new ConcurrentDictionary + { + [typeof(IChannel)] = typeof(DefaultChannelConverter<>), + [typeof(IRole)] = typeof(DefaultRoleConverter<>), + [typeof(IAttachment)] = typeof(DefaultAttachmentConverter<>), + [typeof(IUser)] = typeof(DefaultUserConverter<>), + [typeof(IMentionable)] = typeof(DefaultMentionableConverter<>), + [typeof(IConvertible)] = typeof(DefaultValueConverter<>), + [typeof(Enum)] = typeof(EnumConverter<>), + [typeof(Nullable<>)] = typeof(NullableConverter<>) + }); + + _compTypeConverterMap = new TypeMap(this, new ConcurrentDictionary(), + new ConcurrentDictionary + { + [typeof(Array)] = typeof(DefaultArrayComponentConverter<>), + [typeof(IConvertible)] = typeof(DefaultValueComponentConverter<>), + [typeof(Nullable<>)] = typeof(NullableComponentConverter<>) + }); + + _typeReaderMap = new TypeMap(this, new ConcurrentDictionary(), + new ConcurrentDictionary + { + [typeof(IChannel)] = typeof(DefaultChannelReader<>), + [typeof(IRole)] = typeof(DefaultRoleReader<>), + [typeof(IUser)] = typeof(DefaultUserReader<>), + [typeof(IMessage)] = typeof(DefaultMessageReader<>), + [typeof(IConvertible)] = typeof(DefaultValueReader<>), + [typeof(Enum)] = typeof(EnumReader<>), + [typeof(Nullable<>)] = typeof(NullableReader<>) + }); + } + + /// + /// Create and loads a using a builder factory. + /// + /// Name of the module. + /// The for your dependency injection solution if using one; otherwise, pass . + /// Module builder factory. + /// + /// A task representing the operation for adding modules. The task result contains the built module instance. + /// + public async Task CreateModuleAsync(string name, IServiceProvider services, Action buildFunc) + { + services ??= EmptyServiceProvider.Instance; + + await _lock.WaitAsync().ConfigureAwait(false); + try + { + var builder = new ModuleBuilder(this, name); + buildFunc(builder); + + var moduleInfo = builder.Build(this, services); + LoadModuleInternal(moduleInfo); + + return moduleInfo; + } + finally + { + _lock.Release(); + } + } + + /// + /// Discover and load command modules from an . + /// + /// the command modules are defined in. + /// The for your dependency injection solution if using one; otherwise, pass . + /// + /// A task representing the operation for adding modules. The task result contains a collection of the modules added. + /// + public async Task> AddModulesAsync(Assembly assembly, IServiceProvider services) + { + services ??= EmptyServiceProvider.Instance; + + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + var types = await ModuleClassBuilder.SearchAsync(assembly, this); + var moduleDefs = await ModuleClassBuilder.BuildAsync(types, this, services); + + foreach (var info in moduleDefs) + { + _typedModuleDefs[info.Key] = info.Value; + LoadModuleInternal(info.Value); + } + return moduleDefs.Values; + } + finally + { + _lock.Release(); + } + } + + /// + /// Add a command module from a . + /// + /// Type of the module. + /// The for your dependency injection solution if using one; otherwise, pass . + /// + /// A task representing the operation for adding the module. The task result contains the built module. + /// + /// + /// Thrown if this module has already been added. + /// + /// + /// Thrown when the is not a valid module definition. + /// + public Task AddModuleAsync(IServiceProvider services) where T : class => + AddModuleAsync(typeof(T), services); + + /// + /// Add a command module from a . + /// + /// Type of the module. + /// The for your dependency injection solution if using one; otherwise, pass . + /// + /// A task representing the operation for adding the module. The task result contains the built module. + /// + /// + /// Thrown if this module has already been added. + /// + /// + /// Thrown when the is not a valid module definition. + /// + public async Task AddModuleAsync(Type type, IServiceProvider services) + { + if (!typeof(IInteractionModuleBase).IsAssignableFrom(type)) + throw new ArgumentException("Type parameter must be a type of Slash Module", nameof(type)); + + services ??= EmptyServiceProvider.Instance; + + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + var typeInfo = type.GetTypeInfo(); + + if (_typedModuleDefs.ContainsKey(typeInfo)) + throw new ArgumentException("Module definition for this type already exists."); + + var moduleDef = (await ModuleClassBuilder.BuildAsync(new List { typeInfo }, this, services).ConfigureAwait(false)).FirstOrDefault(); + + if (moduleDef.Value == default) + throw new InvalidOperationException($"Could not build the module {typeInfo.FullName}, did you pass an invalid type?"); + + if (!_typedModuleDefs.TryAdd(type, moduleDef.Value)) + throw new ArgumentException("Module definition for this type already exists."); + + _typedModuleDefs[moduleDef.Key] = moduleDef.Value; + LoadModuleInternal(moduleDef.Value); + + return moduleDef.Value; + } + finally + { + _lock.Release(); + } + } + + /// + /// Register Application Commands from and to a guild. + /// + /// Id of the target guild. + /// If , this operation will not delete the commands that are missing from . + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> RegisterCommandsToGuildAsync(ulong guildId, bool deleteMissing = true) + { + EnsureClientReady(); + + var topLevelModules = _moduleDefs.Where(x => !x.IsSubModule); + var props = topLevelModules.SelectMany(x => x.ToApplicationCommandProps()).ToList(); + + if (!deleteMissing) + { + + var existing = await RestClient.GetGuildApplicationCommands(guildId, true).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGuildCommands(props.ToArray(), guildId).ConfigureAwait(false); + } + + /// + /// Register Application Commands from and to Discord on in global scope. + /// + /// If , this operation will not delete the commands that are missing from . + /// + /// A task representing the command registration process. The task result contains the active global application commands of bot. + /// + public async Task> RegisterCommandsGloballyAsync(bool deleteMissing = true) + { + EnsureClientReady(); + + var topLevelModules = _moduleDefs.Where(x => !x.IsSubModule); + var props = topLevelModules.SelectMany(x => x.ToApplicationCommandProps()).ToList(); + + if (!deleteMissing) + { + var existing = await RestClient.GetGlobalApplicationCommands(true).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGlobalCommands(props.ToArray()).ConfigureAwait(false); + } + + /// + /// Register Application Commands from to a guild. + /// + /// + /// Commands will be registered as standalone commands, if you want the to take effect, + /// use . Registering a commands without group names might cause the command traversal to fail. + /// + /// The target guild. + /// If , this operation will not delete the commands that are missing from . + /// Commands to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public Task> AddCommandsToGuildAsync(IGuild guild, bool deleteMissing = false, params ICommandInfo[] commands) + { + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + return AddCommandsToGuildAsync(guild.Id, deleteMissing, commands); + } + + /// + /// Register Application Commands from to a guild. + /// + /// + /// Commands will be registered as standalone commands, if you want the to take effect, + /// use . Registering a commands without group names might cause the command traversal to fail. + /// + /// The target guild ID. + /// If , this operation will not delete the commands that are missing from . + /// Commands to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddCommandsToGuildAsync(ulong guildId, bool deleteMissing = false, params ICommandInfo[] commands) + { + EnsureClientReady(); + + var props = new List(); + + foreach (var command in commands) + { + switch (command) + { + case SlashCommandInfo slashCommand: + props.Add(slashCommand.ToApplicationCommandProps()); + break; + case ContextCommandInfo contextCommand: + props.Add(contextCommand.ToApplicationCommandProps()); + break; + default: + throw new InvalidOperationException($"Command type {command.GetType().FullName} isn't supported yet"); + } + } + + if (!deleteMissing) + { + var existing = await RestClient.GetGuildApplicationCommands(guildId, true).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGuildCommands(props.ToArray(), guildId).ConfigureAwait(false); + } + + /// + /// Register Application Commands from modules provided in to a guild. + /// + /// The target guild. + /// If , this operation will not delete the commands that are missing from . + /// Modules to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public Task> AddModulesToGuildAsync(IGuild guild, bool deleteMissing = false, params ModuleInfo[] modules) + { + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + return AddModulesToGuildAsync(guild.Id, deleteMissing, modules); + } + + /// + /// Register Application Commands from modules provided in to a guild. + /// + /// The target guild ID. + /// If , this operation will not delete the commands that are missing from . + /// Modules to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddModulesToGuildAsync(ulong guildId, bool deleteMissing = false, params ModuleInfo[] modules) + { + EnsureClientReady(); + + var props = modules.SelectMany(x => x.ToApplicationCommandProps(true)).Distinct().ToList(); + + if (!deleteMissing) + { + var existing = await RestClient.GetGuildApplicationCommands(guildId, true).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGuildCommands(props.ToArray(), guildId).ConfigureAwait(false); + } + + /// + /// Register Application Commands from modules provided in as global commands. + /// + /// If , this operation will not delete the commands that are missing from . + /// Modules to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddModulesGloballyAsync(bool deleteMissing = false, params ModuleInfo[] modules) + { + EnsureClientReady(); + + var props = modules.SelectMany(x => x.ToApplicationCommandProps(true)).Distinct().ToList(); + + if (!deleteMissing) + { + var existing = await RestClient.GetGlobalApplicationCommands(true).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGlobalCommands(props.ToArray()).ConfigureAwait(false); + } + + /// + /// Register Application Commands from as global commands. + /// + /// + /// Commands will be registered as standalone commands, if you want the to take effect, + /// use . Registering a commands without group names might cause the command traversal to fail. + /// + /// If , this operation will not delete the commands that are missing from . + /// Commands to be registered to Discord. + /// + /// A task representing the command registration process. The task result contains the active application commands of the target guild. + /// + public async Task> AddCommandsGloballyAsync(bool deleteMissing = false, params IApplicationCommandInfo[] commands) + { + EnsureClientReady(); + + var props = new List(); + + foreach (var command in commands) + { + switch (command) + { + case SlashCommandInfo slashCommand: + props.Add(slashCommand.ToApplicationCommandProps()); + break; + case ContextCommandInfo contextCommand: + props.Add(contextCommand.ToApplicationCommandProps()); + break; + default: + throw new InvalidOperationException($"Command type {command.GetType().FullName} isn't supported yet"); + } + } + + if (!deleteMissing) + { + var existing = await RestClient.GetGlobalApplicationCommands(true).ConfigureAwait(false); + var missing = existing.Where(x => !props.Any(y => y.Name.IsSpecified && y.Name.Value == x.Name)); + props.AddRange(missing.Select(x => x.ToApplicationCommandProps())); + } + + return await RestClient.BulkOverwriteGlobalCommands(props.ToArray()).ConfigureAwait(false); + } + + private void LoadModuleInternal(ModuleInfo module) + { + _moduleDefs.Add(module); + + foreach (var command in module.SlashCommands) + _slashCommandMap.AddCommand(command, command.IgnoreGroupNames); + + foreach (var command in module.ContextCommands) + _contextCommandMaps.GetOrAdd(command.CommandType, new CommandMap(this)).AddCommand(command, command.IgnoreGroupNames); + + foreach (var interaction in module.ComponentCommands) + _componentCommandMap.AddCommand(interaction, interaction.IgnoreGroupNames); + + foreach (var command in module.AutocompleteCommands) + _autocompleteCommandMap.AddCommand(command.GetCommandKeywords(), command); + + foreach (var command in module.ModalCommands) + _modalCommandMap.AddCommand(command, command.IgnoreGroupNames); + + foreach (var subModule in module.SubModules) + LoadModuleInternal(subModule); + } + + /// + /// Remove a command module. + /// + /// The of the module. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the module is successfully removed. + /// + public Task RemoveModuleAsync() => + RemoveModuleAsync(typeof(T)); + + /// + /// Remove a command module. + /// + /// The of the module. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the module is successfully removed. + /// + public async Task RemoveModuleAsync(Type type) + { + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + if (!_typedModuleDefs.TryRemove(type, out var module)) + return false; + + return RemoveModuleInternal(module); + } + finally + { + _lock.Release(); + } + } + + /// + /// Remove a command module. + /// + /// The to be removed from the service. + /// + /// A task that represents the asynchronous removal operation. The task result contains a value that + /// indicates whether the is successfully removed. + /// + public async Task RemoveModuleAsync(ModuleInfo module) + { + await _lock.WaitAsync().ConfigureAwait(false); + + try + { + var typeModulePair = _typedModuleDefs.FirstOrDefault(x => x.Value.Equals(module)); + + if (!typeModulePair.Equals(default(KeyValuePair))) + _typedModuleDefs.TryRemove(typeModulePair.Key, out var _); + + return RemoveModuleInternal(module); + } + finally + { + _lock.Release(); + } + } + + /// + /// Unregister Application Commands from modules provided in from a guild. + /// + /// The target guild. + /// Modules to be deregistered from Discord. + /// + /// A task representing the command de-registration process. The task result contains the active application commands of the target guild. + /// + public Task> RemoveModulesFromGuildAsync(IGuild guild, params ModuleInfo[] modules) + { + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + return RemoveModulesFromGuildAsync(guild.Id, modules); + } + + /// + /// Unregister Application Commands from modules provided in from a guild. + /// + /// The target guild ID. + /// Modules to be deregistered from Discord. + /// + /// A task representing the command de-registration process. The task result contains the active application commands of the target guild. + /// + public async Task> RemoveModulesFromGuildAsync(ulong guildId, params ModuleInfo[] modules) + { + EnsureClientReady(); + + var exclude = modules.SelectMany(x => x.ToApplicationCommandProps(true)).ToList(); + var existing = await RestClient.GetGuildApplicationCommands(guildId).ConfigureAwait(false); + + var props = existing.Where(x => !exclude.Any(y => y.Name.IsSpecified && x.Name == y.Name.Value)).Select(x => x.ToApplicationCommandProps()); + + return await RestClient.BulkOverwriteGuildCommands(props.ToArray(), guildId).ConfigureAwait(false); + } + + private bool RemoveModuleInternal(ModuleInfo moduleInfo) + { + if (!_moduleDefs.Remove(moduleInfo)) + return false; + + foreach (var command in moduleInfo.SlashCommands) + { + _slashCommandMap.RemoveCommand(command); + } + + return true; + } + + /// + /// Search the registered slash commands using a . + /// + /// Interaction entity to perform the search with. + /// + /// The search result. When successful, result contains the found . + /// + public SearchResult SearchSlashCommand(ISlashCommandInteraction slashCommandInteraction) + => _slashCommandMap.GetCommand(slashCommandInteraction.Data.GetCommandKeywords()); + + /// + /// Search the registered slash commands using a . + /// + /// Interaction entity to perform the search with. + /// + /// The search result. When successful, result contains the found . + /// + public SearchResult SearchComponentCommand(IComponentInteraction componentInteraction) + => _componentCommandMap.GetCommand(componentInteraction.Data.CustomId); + + /// + /// Search the registered slash commands using a . + /// + /// Interaction entity to perform the search with. + /// + /// The search result. When successful, result contains the found . + /// + public SearchResult SearchUserCommand(IUserCommandInteraction userCommandInteraction) + => _contextCommandMaps[ApplicationCommandType.User].GetCommand(userCommandInteraction.Data.Name); + + /// + /// Search the registered slash commands using a . + /// + /// Interaction entity to perform the search with. + /// + /// The search result. When successful, result contains the found . + /// + public SearchResult SearchMessageCommand(IMessageCommandInteraction messageCommandInteraction) + => _contextCommandMaps[ApplicationCommandType.Message].GetCommand(messageCommandInteraction.Data.Name); + + /// + /// Search the registered slash commands using a . + /// + /// Interaction entity to perform the search with. + /// + /// The search result. When successful, result contains the found . + /// + public SearchResult SearchAutocompleteCommand(IAutocompleteInteraction autocompleteInteraction) + { + var keywords = autocompleteInteraction.Data.GetCommandKeywords(); + keywords.Add(autocompleteInteraction.Data.Current.Name); + return _autocompleteCommandMap.GetCommand(keywords); + } + + /// + /// Execute a Command from a given . + /// + /// Name context of the command. + /// The service to be used in the command's dependency injection. + /// + /// A task representing the command execution process. The task result contains the result of the execution. + /// + public async Task ExecuteCommandAsync(IInteractionContext context, IServiceProvider services) + { + var interaction = context.Interaction; + + return interaction switch + { + ISlashCommandInteraction slashCommand => await ExecuteSlashCommandAsync(context, slashCommand, services).ConfigureAwait(false), + IComponentInteraction messageComponent => await ExecuteComponentCommandAsync(context, messageComponent.Data.CustomId, services).ConfigureAwait(false), + IUserCommandInteraction userCommand => await ExecuteContextCommandAsync(context, userCommand.Data.Name, ApplicationCommandType.User, services).ConfigureAwait(false), + IMessageCommandInteraction messageCommand => await ExecuteContextCommandAsync(context, messageCommand.Data.Name, ApplicationCommandType.Message, services).ConfigureAwait(false), + IAutocompleteInteraction autocomplete => await ExecuteAutocompleteAsync(context, autocomplete, services).ConfigureAwait(false), + IModalInteraction modalCommand => await ExecuteModalCommandAsync(context, modalCommand.Data.CustomId, services).ConfigureAwait(false), + _ => throw new InvalidOperationException($"{interaction.Type} interaction type cannot be executed by the Interaction service"), + }; + } + + private async Task ExecuteSlashCommandAsync(IInteractionContext context, ISlashCommandInteraction interaction, IServiceProvider services) + { + var keywords = interaction.Data.GetCommandKeywords(); + + var result = _slashCommandMap.GetCommand(keywords); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown slash command, skipping execution ({string.Join(" ", keywords).ToUpper()})"); + + await _slashCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + return await result.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + private async Task ExecuteContextCommandAsync(IInteractionContext context, string input, ApplicationCommandType commandType, IServiceProvider services) + { + if (!_contextCommandMaps.TryGetValue(commandType, out var map)) + return SearchResult.FromError(input, InteractionCommandError.UnknownCommand, $"No {commandType} command found."); + + var result = map.GetCommand(input); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown context command, skipping execution ({result.Text.ToUpper()})"); + + await _contextCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + return await result.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + private async Task ExecuteComponentCommandAsync(IInteractionContext context, string input, IServiceProvider services) + { + var result = _componentCommandMap.GetCommand(input); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown custom interaction id, skipping execution ({input.ToUpper()})"); + + await _componentCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + + SetMatchesIfApplicable(context, result); + + return await result.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + private async Task ExecuteAutocompleteAsync(IInteractionContext context, IAutocompleteInteraction interaction, IServiceProvider services) + { + var keywords = interaction.Data.GetCommandKeywords(); + + if (_enableAutocompleteHandlers) + { + var autocompleteHandlerResult = _slashCommandMap.GetCommand(keywords); + + if (autocompleteHandlerResult.IsSuccess) + { + if (autocompleteHandlerResult.Command._flattenedParameterDictionary.TryGetValue(interaction.Data.Current.Name, out var parameter) && parameter?.AutocompleteHandler is not null) + return await parameter.AutocompleteHandler.ExecuteAsync(context, interaction, parameter, services).ConfigureAwait(false); + } + } + + keywords.Add(interaction.Data.Current.Name); + + var commandResult = _autocompleteCommandMap.GetCommand(keywords); + + if (!commandResult.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown command name, skipping autocomplete process ({interaction.Data.CommandName.ToUpper()})"); + + await _autocompleteCommandExecutedEvent.InvokeAsync(null, context, commandResult).ConfigureAwait(false); + return commandResult; + } + + return await commandResult.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + private async Task ExecuteModalCommandAsync(IInteractionContext context, string input, IServiceProvider services) + { + var result = _modalCommandMap.GetCommand(input); + + if (!result.IsSuccess) + { + await _cmdLogger.DebugAsync($"Unknown custom interaction id, skipping execution ({input.ToUpper()})"); + + await _componentCommandExecutedEvent.InvokeAsync(null, context, result).ConfigureAwait(false); + return result; + } + + SetMatchesIfApplicable(context, result); + + return await result.Command.ExecuteAsync(context, services).ConfigureAwait(false); + } + + private static void SetMatchesIfApplicable(IInteractionContext context, SearchResult searchResult) + where T : class, ICommandInfo + { + if (!searchResult.Command.SupportsWildCards || context is not IRouteMatchContainer matchContainer) + return; + + if (searchResult.RegexCaptureGroups?.Length > 0) + { + var matches = new RouteSegmentMatch[searchResult.RegexCaptureGroups.Length]; + for (var i = 0; i < searchResult.RegexCaptureGroups.Length; i++) + matches[i] = new RouteSegmentMatch(searchResult.RegexCaptureGroups[i]); + + matchContainer.SetSegmentMatches(matches); + } + else + matchContainer.SetSegmentMatches(Array.Empty()); + } + + internal TypeConverter GetTypeConverter(Type type, IServiceProvider services = null) + => _typeConverterMap.Get(type, services); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddTypeConverter(TypeConverter converter) => + _typeConverterMap.AddConcrete(converter); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddTypeConverter(Type type, TypeConverter converter) => + _typeConverterMap.AddConcrete(type, converter); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + + public void AddGenericTypeConverter(Type converterType) => + _typeConverterMap.AddGeneric(converterType); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericTypeConverter(Type targetType, Type converterType) => + _typeConverterMap.AddGeneric(targetType, converterType); + + internal ComponentTypeConverter GetComponentTypeConverter(Type type, IServiceProvider services = null) => + _compTypeConverterMap.Get(type, services); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddComponentTypeConverter(ComponentTypeConverter converter) => + AddComponentTypeConverter(typeof(T), converter); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddComponentTypeConverter(Type type, ComponentTypeConverter converter) => + _compTypeConverterMap.AddConcrete(type, converter); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericComponentTypeConverter(Type converterType) => + AddGenericComponentTypeConverter(typeof(T), converterType); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericComponentTypeConverter(Type targetType, Type converterType) => + _compTypeConverterMap.AddGeneric(targetType, converterType); + + internal TypeReader GetTypeReader(Type type, IServiceProvider services = null) => + _typeReaderMap.Get(type, services); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddTypeReader(TypeReader reader) => + AddTypeReader(typeof(T), reader); + + /// + /// Add a concrete type . + /// + /// Primary target of the . + /// The instance. + public void AddTypeReader(Type type, TypeReader reader) => + _typeReaderMap.AddConcrete(type, reader); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericTypeReader(Type readerType) => + AddGenericTypeReader(typeof(T), readerType); + + /// + /// Add a generic type . + /// + /// Generic Type constraint of the of the . + /// Type of the . + public void AddGenericTypeReader(Type targetType, Type readerType) => + _typeReaderMap.AddGeneric(targetType, readerType); + + /// + /// Removes a type reader for the type . + /// + /// The type to remove the readers from. + /// The reader if the resulting remove operation was successful. + /// if the remove operation was successful; otherwise . + public bool TryRemoveTypeReader(out TypeReader reader) + => TryRemoveTypeReader(typeof(T), out reader); + + /// + /// Removes a type reader for the given type. + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the reader from. + /// The reader if the resulting remove operation was successful. + /// if the remove operation was successful; otherwise . + public bool TryRemoveTypeReader(Type type, out TypeReader reader) + => _typeReaderMap.TryRemoveConcrete(type, out reader); + + /// + /// Removes a generic type reader from the type . + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the readers from. + /// The removed readers type. + /// if the remove operation was successful; otherwise . + public bool TryRemoveGenericTypeReader(out Type readerType) + => TryRemoveGenericTypeReader(typeof(T), out readerType); + + /// + /// Removes a generic type reader from the given type. + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// The type to remove the reader from. + /// The readers type if the remove operation was successful. + /// if the remove operation was successful; otherwise . + public bool TryRemoveGenericTypeReader(Type type, out Type readerType) + => _typeReaderMap.TryRemoveGeneric(type, out readerType); + + /// + /// Serialize an object using a into a to be placed in a Component CustomId. + /// + /// + /// Removing a from the will not dereference the from the loaded module/command instances. + /// You need to reload the modules for the changes to take effect. + /// + /// Type of the object to be serialized. + /// Object to be serialized. + /// Services that will be passed on to the . + /// + /// A task representing the conversion process. The task result contains the result of the conversion. + /// + public Task SerializeValueAsync(T obj, IServiceProvider services) => + _typeReaderMap.Get(typeof(T), services).SerializeAsync(obj, services); + + /// + /// Serialize and format multiple objects into a Custom Id string. + /// + /// A composite format string. + /// >Services that will be passed on to the s. + /// Objects to be serialized. + /// + /// A task representing the conversion process. The task result contains the result of the conversion. + /// + public async Task GenerateCustomIdStringAsync(string format, IServiceProvider services, params object[] args) + { + var serializedValues = new string[args.Length]; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + var typeReader = _typeReaderMap.Get(arg.GetType(), null); + var result = await typeReader.SerializeAsync(arg, services).ConfigureAwait(false); + serializedValues[i] = result; + } + + return string.Format(format, serializedValues); + } + + /// + /// Loads and caches an for the provided . + /// + /// Type of to be loaded. + /// + /// The built instance. + /// + /// + public ModalInfo AddModalInfo() where T : class, IModal + { + var type = typeof(T); + + if (_modalInfos.ContainsKey(type)) + throw new InvalidOperationException($"Modal type {type.FullName} already exists."); + + return ModalUtils.GetOrAdd(type, this); + } + + internal IAutocompleteHandler GetAutocompleteHandler(Type autocompleteHandlerType, IServiceProvider services = null) + { + services ??= EmptyServiceProvider.Instance; + + if (!_enableAutocompleteHandlers) + throw new InvalidOperationException($"{nameof(IAutocompleteHandler)}s are not enabled. To use this feature set {nameof(InteractionServiceConfig.EnableAutocompleteHandlers)} to TRUE"); + + if (_autocompleteHandlers.TryGetValue(autocompleteHandlerType, out var autocompleteHandler)) + return autocompleteHandler; + else + { + autocompleteHandler = ReflectionUtils.CreateObject(autocompleteHandlerType.GetTypeInfo(), this, services); + _autocompleteHandlers[autocompleteHandlerType] = autocompleteHandler; + return autocompleteHandler; + } + } + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// Module representing the top level Slash Command. + /// Target guild. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public Task ModifySlashCommandPermissionsAsync(ModuleInfo module, IGuild guild, + params ApplicationCommandPermission[] permissions) + { + if (module is null) + throw new ArgumentNullException(nameof(module)); + + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + return ModifySlashCommandPermissionsAsync(module, guild.Id, permissions); + } + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// Module representing the top level Slash Command. + /// Target guild ID. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public async Task ModifySlashCommandPermissionsAsync(ModuleInfo module, ulong guildId, + params ApplicationCommandPermission[] permissions) + { + if (module is null) + throw new ArgumentNullException(nameof(module)); + + if (!module.IsSlashGroup) + throw new InvalidOperationException($"This module does not have a {nameof(GroupAttribute)} and does not represent an Application Command"); + + if (!module.IsTopLevelGroup) + throw new InvalidOperationException("This module is not a top level application command. You cannot change its permissions"); + + var commands = await RestClient.GetGuildApplicationCommands(guildId).ConfigureAwait(false); + var appCommand = commands.First(x => x.Name == module.SlashGroupName); + + return await appCommand.ModifyCommandPermissions(permissions).ConfigureAwait(false); + } + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// The Slash Command. + /// Target guild. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public Task ModifySlashCommandPermissionsAsync(SlashCommandInfo command, IGuild guild, + params ApplicationCommandPermission[] permissions) + { + if (command is null) + throw new ArgumentNullException(nameof(command)); + + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + return ModifyApplicationCommandPermissionsAsync(command, guild.Id, permissions); + } + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// The Slash Command. + /// Target guild ID. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public Task ModifySlashCommandPermissionsAsync(SlashCommandInfo command, ulong guildId, + params ApplicationCommandPermission[] permissions) + => ModifyApplicationCommandPermissionsAsync(command, guildId, permissions); + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// The Context Command. + /// Target guild. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public Task ModifyContextCommandPermissionsAsync(ContextCommandInfo command, IGuild guild, + params ApplicationCommandPermission[] permissions) + { + if (command is null) + throw new ArgumentNullException(nameof(command)); + + if (guild is null) + throw new ArgumentNullException(nameof(guild)); + + return ModifyApplicationCommandPermissionsAsync(command, guild.Id, permissions); + } + + /// + /// Modify the command permissions of the matching Discord Slash Command. + /// + /// The Context Command. + /// Target guild ID. + /// New permission values. + /// + /// The active command permissions after the modification. + /// + public Task ModifyContextCommandPermissionsAsync(ContextCommandInfo command, ulong guildId, + params ApplicationCommandPermission[] permissions) + => ModifyApplicationCommandPermissionsAsync(command, guildId, permissions); + + private async Task ModifyApplicationCommandPermissionsAsync(T command, ulong guildId, + params ApplicationCommandPermission[] permissions) where T : class, IApplicationCommandInfo, ICommandInfo + { + if (command is null) + throw new ArgumentNullException(nameof(command)); + + if (!command.IsTopLevelCommand) + throw new InvalidOperationException("This command is not a top level application command. You cannot change its permissions"); + + var commands = await RestClient.GetGuildApplicationCommands(guildId).ConfigureAwait(false); + var appCommand = commands.First(x => x.Name == (command as IApplicationCommandInfo).Name); + + return await appCommand.ModifyCommandPermissions(permissions).ConfigureAwait(false); + } + + /// + /// Gets a . + /// + /// Declaring module type of this command, must be a type of . + /// Method name of the handler, use of is recommended. + /// + /// instance for this command. + /// + /// Module or Slash Command couldn't be found. + public SlashCommandInfo GetSlashCommandInfo(string methodName) where TModule : class + { + var module = GetModuleInfo(); + + return module.SlashCommands.First(x => x.MethodName == methodName); + } + + /// + /// Gets a . + /// + /// Declaring module type of this command, must be a type of . + /// Method name of the handler, use of is recommended. + /// + /// instance for this command. + /// + /// Module or Context Command couldn't be found. + public ContextCommandInfo GetContextCommandInfo(string methodName) where TModule : class + { + var module = GetModuleInfo(); + + return module.ContextCommands.First(x => x.MethodName == methodName); + } + + /// + /// Gets a . + /// + /// Declaring module type of this command, must be a type of . + /// Method name of the handler, use of is recommended. + /// + /// instance for this command. + /// + /// Module or Component Command couldn't be found. + public ComponentCommandInfo GetComponentCommandInfo(string methodName) where TModule : class + { + var module = GetModuleInfo(); + + return module.ComponentCommands.First(x => x.MethodName == methodName); + } + + /// + /// Gets a built . + /// + /// Type of the module, must be a type of . + /// + /// instance for this module. + /// + public ModuleInfo GetModuleInfo() where TModule : class + { + if (!typeof(IInteractionModuleBase).IsAssignableFrom(typeof(TModule))) + throw new ArgumentException("Type parameter must be a type of Slash Module", nameof(TModule)); + + var module = _typedModuleDefs[typeof(TModule)]; + + if (module is null) + throw new InvalidOperationException($"{typeof(TModule).FullName} is not loaded to the Slash Command Service"); + + return module; + } + + /// + public void Dispose() + { + _lock.Dispose(); + } + + private void EnsureClientReady() + { + if (RestClient?.CurrentUser is null || RestClient?.CurrentUser?.Id == 0) + throw new InvalidOperationException($"Provided client is not ready to execute this operation, invoke this operation after a `Client Ready` event"); + } + } +} diff --git a/src/Discord.Net.Interactions/InteractionServiceConfig.cs b/src/Discord.Net.Interactions/InteractionServiceConfig.cs new file mode 100644 index 0000000..12706c2 --- /dev/null +++ b/src/Discord.Net.Interactions/InteractionServiceConfig.cs @@ -0,0 +1,83 @@ +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a configuration class for . + /// + public class InteractionServiceConfig + { + /// + /// Gets or sets the minimum log level severity that will be sent to the event. + /// + public LogSeverity LogLevel { get; set; } = LogSeverity.Info; + + /// + /// Gets or sets the default commands should have, if one is not specified on the + /// Command attribute or builder. + /// + public RunMode DefaultRunMode { get; set; } = RunMode.Async; + + /// + /// Gets or sets whether commands should push exceptions up to the caller. + /// + public bool ThrowOnError { get; set; } = true; + + /// + /// Gets or sets the delimiters that will be used to separate group names and the method name when a Message Component Interaction is received. + /// + public char[] InteractionCustomIdDelimiters { get; set; } + + /// + /// Gets or sets the string expression that will be treated as a wild card. + /// + public string WildCardExpression { get; set; } = "*"; + + /// + /// Gets or sets the option to use compiled lambda expressions to create module instances and execute commands. This method improves performance at the cost of memory. + /// + /// + /// For performance reasons, if you frequently use s with the service, it is highly recommended that you enable compiled lambdas. + /// + public bool UseCompiledLambda { get; set; } = false; + + /// + /// Gets or sets the option allowing you to use s. + /// + /// + /// Since s are prioritized over s, if s are not used, this should be + /// disabled to decrease the lookup time. + /// + public bool EnableAutocompleteHandlers { get; set; } = true; + + /// + /// Gets or sets whether new service scopes should be automatically created when resolving module dependencies on every command execution. + /// + public bool AutoServiceScopes { get; set; } = true; + + /// + /// Gets or sets delegate to be used by the when responding to a Rest based interaction. + /// + public RestResponseCallback RestResponseCallback { get; set; } = (ctx, str) => Task.CompletedTask; + + /// + /// Gets or sets whether a command execution should exit when a modal command encounters a missing modal component value. + /// + public bool ExitOnMissingModalField { get; set; } = false; + + /// + /// Localization provider to be used when registering application commands. + /// + public ILocalizationManager LocalizationManager { get; set; } + } + + /// + /// Represents a cached delegate for creating interaction responses to webhook based Discord Interactions. + /// + /// Execution context that will be injected into the module class. + /// Body of the interaction response. + /// + /// A task representing the response operation. + /// + public delegate Task RestResponseCallback(IInteractionContext context, string responseBody); +} diff --git a/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs new file mode 100644 index 0000000..9af518e --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationManagers/ILocalizationManager.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Represents a localization provider for Discord Application Commands. + /// + public interface ILocalizationManager + { + /// + /// Get every the resource name for every available locale. + /// + /// Location of the resource. + /// Type of the resource. + /// + /// A dictionary containing every available locale and the resource name. + /// + IDictionary GetAllNames(IList key, LocalizationTarget destinationType); + + /// + /// Get every the resource description for every available locale. + /// + /// Location of the resource. + /// Type of the resource. + /// + /// A dictionary containing every available locale and the resource name. + /// + IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType); + } +} diff --git a/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs new file mode 100644 index 0000000..010fb3b --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationManagers/JsonLocalizationManager.cs @@ -0,0 +1,72 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// The default localization provider for Json resource files. + /// + public sealed class JsonLocalizationManager : ILocalizationManager + { + private const string NameIdentifier = "name"; + private const string DescriptionIdentifier = "description"; + private const string SpaceToken = "~"; + + private readonly string _basePath; + private readonly string _fileName; + private readonly Regex _localeParserRegex = new Regex(@"\w+.(?\w{2}(?:-\w{2})?).json", RegexOptions.Compiled | RegexOptions.Singleline); + + /// + /// Initializes a new instance of the class. + /// + /// Base path of the Json file. + /// Name of the Json file. + public JsonLocalizationManager(string basePath, string fileName) + { + _basePath = basePath; + _fileName = fileName; + } + + /// + public IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType) => + GetValues(key, DescriptionIdentifier); + + /// + public IDictionary GetAllNames(IList key, LocalizationTarget destinationType) => + GetValues(key, NameIdentifier); + + private string[] GetAllFiles() => + Directory.GetFiles(_basePath, $"{_fileName}.*.json", SearchOption.TopDirectoryOnly); + + private IDictionary GetValues(IList key, string identifier) + { + var result = new Dictionary(); + var files = GetAllFiles(); + + foreach (var file in files) + { + var match = _localeParserRegex.Match(Path.GetFileName(file)); + if (!match.Success) + continue; + + var locale = match.Groups["locale"].Value; + + using var sr = new StreamReader(file); + using var jr = new JsonTextReader(sr); + var obj = JObject.Load(jr); + var token = string.Join(".", key.Select(x => $"['{x}']")) + $".{identifier}"; + var value = (string)obj.SelectToken(token); + if (value is not null) + result[locale] = value; + } + + return result; + } + } +} diff --git a/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs b/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs new file mode 100644 index 0000000..a110602 --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationManagers/ResxLocalizationManager.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace Discord.Interactions +{ + /// + /// The default localization provider for Resx files. + /// + public sealed class ResxLocalizationManager : ILocalizationManager + { + private const string NameIdentifier = "name"; + private const string DescriptionIdentifier = "description"; + + private readonly ResourceManager _resourceManager; + private readonly IEnumerable _supportedLocales; + + /// + /// Initializes a new instance of the class. + /// + /// Name of the base resource. + /// The main assembly for the resources. + /// Cultures the should search for. + public ResxLocalizationManager(string baseResource, Assembly assembly, params CultureInfo[] supportedLocales) + { + _supportedLocales = supportedLocales; + _resourceManager = new ResourceManager(baseResource, assembly); + } + + /// + public IDictionary GetAllDescriptions(IList key, LocalizationTarget destinationType) => + GetValues(key, DescriptionIdentifier); + + /// + public IDictionary GetAllNames(IList key, LocalizationTarget destinationType) => + GetValues(key, NameIdentifier); + + private IDictionary GetValues(IList key, string identifier) + { + var entryKey = (string.Join(".", key) + "." + identifier); + + var result = new Dictionary(); + + foreach (var locale in _supportedLocales) + { + var value = _resourceManager.GetString(entryKey, locale); + if (value is not null) + result[locale.Name] = value; + } + + return result; + } + } +} diff --git a/src/Discord.Net.Interactions/LocalizationTarget.cs b/src/Discord.Net.Interactions/LocalizationTarget.cs new file mode 100644 index 0000000..cf54d33 --- /dev/null +++ b/src/Discord.Net.Interactions/LocalizationTarget.cs @@ -0,0 +1,25 @@ +namespace Discord.Interactions +{ + /// + /// Resource targets for localization. + /// + public enum LocalizationTarget + { + /// + /// Target is a tagged with a . + /// + Group, + /// + /// Target is an application command method. + /// + Command, + /// + /// Target is a Slash Command parameter. + /// + Parameter, + /// + /// Target is a Slash Command parameter choice. + /// + Choice + } +} diff --git a/src/Discord.Net.Interactions/Map/CommandMap.cs b/src/Discord.Net.Interactions/Map/CommandMap.cs new file mode 100644 index 0000000..17cde1c --- /dev/null +++ b/src/Discord.Net.Interactions/Map/CommandMap.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.Interactions +{ + internal class CommandMap where T : class, ICommandInfo + { + private readonly char[] _seperators; + + private readonly CommandMapNode _root; + private readonly InteractionService _commandService; + + public IReadOnlyCollection Seperators => _seperators; + + public CommandMap(InteractionService commandService, char[] seperators = null) + { + _seperators = seperators ?? Array.Empty(); + + _commandService = commandService; + _root = new CommandMapNode(null, _commandService._wildCardExp); + } + + public void AddCommand(T command, bool ignoreGroupNames = false) + { + if (ignoreGroupNames) + AddCommandToRoot(command); + else + AddCommand(command); + } + + public void AddCommandToRoot(T command) + { + string[] key = new string[] { command.Name }; + _root.AddCommand(key, 0, command); + } + + public void AddCommand(IList input, T command) + { + _root.AddCommand(input, 0, command); + } + + public void RemoveCommand(T command) + { + var key = CommandHierarchy.GetCommandPath(command); + + _root.RemoveCommand(key, 0); + } + + public SearchResult GetCommand(string input) + { + if (_seperators.Any()) + return GetCommand(input.Split(_seperators)); + else + return GetCommand(new string[] { input }); + } + + public SearchResult GetCommand(IList input) => + _root.GetCommand(input, 0); + + private void AddCommand(T command) + { + var key = CommandHierarchy.GetCommandPath(command); + + _root.AddCommand(key, 0, command); + } + } +} diff --git a/src/Discord.Net.Interactions/Map/CommandMapNode.cs b/src/Discord.Net.Interactions/Map/CommandMapNode.cs new file mode 100644 index 0000000..79278ea --- /dev/null +++ b/src/Discord.Net.Interactions/Map/CommandMapNode.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace Discord.Interactions +{ + internal class CommandMapNode where T : class, ICommandInfo + { + private readonly string _wildCardStr = "*"; + private readonly ConcurrentDictionary> _nodes; + private readonly ConcurrentDictionary _commands; + private readonly ConcurrentDictionary _wildCardCommands; + + public IReadOnlyDictionary> Nodes => _nodes; + public IReadOnlyDictionary Commands => _commands; + public IReadOnlyDictionary WildCardCommands => _wildCardCommands; + public string Name { get; } + + public CommandMapNode(string name, string wildCardExp = null) + { + Name = name; + _nodes = new ConcurrentDictionary>(); + _commands = new ConcurrentDictionary(); + _wildCardCommands = new ConcurrentDictionary(); + + if (!string.IsNullOrEmpty(wildCardExp)) + _wildCardStr = wildCardExp; + } + + public void AddCommand(IList keywords, int index, T commandInfo) + { + if (keywords.Count == index + 1) + { + if (commandInfo.SupportsWildCards && RegexUtils.TryBuildRegexPattern(commandInfo, _wildCardStr, out var patternStr)) + { + var regex = new Regex(patternStr, RegexOptions.Singleline | RegexOptions.Compiled); + + if (!_wildCardCommands.TryAdd(regex, commandInfo)) + throw new InvalidOperationException($"A {typeof(T).FullName} already exists with the same name: {string.Join(" ", keywords)}"); + } + else + { + if (!_commands.TryAdd(keywords[index], commandInfo)) + throw new InvalidOperationException($"A {typeof(T).FullName} already exists with the same name: {string.Join(" ", keywords)}"); + } + } + else + { + var node = _nodes.GetOrAdd(keywords[index], (key) => new CommandMapNode(key, _wildCardStr)); + node.AddCommand(keywords, ++index, commandInfo); + } + } + + public bool RemoveCommand(IList keywords, int index) + { + if (keywords.Count == index + 1) + return _commands.TryRemove(keywords[index], out var _); + else + { + if (!_nodes.TryGetValue(keywords[index], out var node)) + throw new InvalidOperationException($"No descendant node was found with the name {keywords[index]}"); + + return node.RemoveCommand(keywords, ++index); + } + } + + public SearchResult GetCommand(IList keywords, int index) + { + string name = string.Join(" ", keywords); + + if (keywords.Count == index + 1) + { + if (_commands.TryGetValue(keywords[index], out var cmd)) + return SearchResult.FromSuccess(name, cmd); + else + { + foreach (var cmdPair in _wildCardCommands) + { + var match = cmdPair.Key.Match(keywords[index]); + if (match.Success) + { + var args = new string[match.Groups.Count - 1]; + + for (var i = 1; i < match.Groups.Count; i++) + args[i - 1] = match.Groups[i].Value; + + return SearchResult.FromSuccess(name, cmdPair.Value, args.ToArray()); + } + } + } + } + else + { + if (_nodes.TryGetValue(keywords[index], out var node)) + return node.GetCommand(keywords, ++index); + } + + return SearchResult.FromError(name, InteractionCommandError.UnknownCommand, $"No {typeof(T).FullName} found for {name}"); + } + + public SearchResult GetCommand(string text, int index, char[] seperators) + { + var keywords = text.Split(seperators); + return GetCommand(keywords, index); + } + } +} diff --git a/src/Discord.Net.Interactions/Map/TypeMap.cs b/src/Discord.Net.Interactions/Map/TypeMap.cs new file mode 100644 index 0000000..520ed72 --- /dev/null +++ b/src/Discord.Net.Interactions/Map/TypeMap.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Discord.Interactions +{ + internal class TypeMap + where TConverter : class, ITypeConverter + { + private readonly ConcurrentDictionary _concretes; + private readonly ConcurrentDictionary _generics; + private readonly InteractionService _interactionService; + + public TypeMap(InteractionService interactionService, IDictionary concretes = null, IDictionary generics = null) + { + _interactionService = interactionService; + _concretes = concretes is not null ? new(concretes) : new(); + _generics = generics is not null ? new(generics) : new(); + } + + internal TConverter Get(Type type, IServiceProvider services = null) + { + if (_concretes.TryGetValue(type, out var specific)) + return specific; + + if (_generics.Any(x => x.Key.IsAssignableFrom(type) + || x.Key.IsGenericTypeDefinition && type.IsGenericType && x.Key.GetGenericTypeDefinition() == type.GetGenericTypeDefinition())) + { + services ??= EmptyServiceProvider.Instance; + + var converterType = GetMostSpecific(type); + var converter = ReflectionUtils.CreateObject(converterType.MakeGenericType(type).GetTypeInfo(), _interactionService, services); + _concretes[type] = converter; + return converter; + } + + if (_concretes.Any(x => x.Value.CanConvertTo(type))) + return _concretes.First(x => x.Value.CanConvertTo(type)).Value; + + throw new ArgumentException($"No type {typeof(TConverter).Name} is defined for this {type.FullName}", nameof(type)); + } + + public void AddConcrete(TConverter converter) => + AddConcrete(typeof(TTarget), converter); + + public void AddConcrete(Type type, TConverter converter) + { + if (!converter.CanConvertTo(type)) + throw new ArgumentException($"This {converter.GetType().FullName} cannot read {type.FullName} and cannot be registered as its {nameof(TypeConverter)}"); + + _concretes[type] = converter; + } + + public void AddGeneric(Type converterType) => + AddGeneric(typeof(TTarget), converterType); + + public void AddGeneric(Type targetType, Type converterType) + { + if (!converterType.IsGenericTypeDefinition) + throw new ArgumentException($"{converterType.FullName} is not generic."); + + var genericArguments = converterType.GetGenericArguments(); + + if (genericArguments.Length > 1) + throw new InvalidOperationException($"Valid generic {converterType.FullName}s cannot have more than 1 generic type parameter"); + + var constraints = genericArguments.SelectMany(x => x.GetGenericParameterConstraints()); + + if (!constraints.Any(x => x.IsAssignableFrom(targetType))) + throw new InvalidOperationException($"This generic class does not support type {targetType.FullName}"); + + _generics[targetType] = converterType; + } + + public bool TryRemoveConcrete(out TConverter converter) + => TryRemoveConcrete(typeof(TTarget), out converter); + + public bool TryRemoveConcrete(Type type, out TConverter converter) + => _concretes.TryRemove(type, out converter); + + public bool TryRemoveGeneric(out Type converterType) + => TryRemoveGeneric(typeof(TTarget), out converterType); + + public bool TryRemoveGeneric(Type targetType, out Type converterType) + => _generics.TryRemove(targetType, out converterType); + + private Type GetMostSpecific(Type type) + { + if (_generics.TryGetValue(type, out var matching)) + return matching; + + if (type.IsGenericType && _generics.TryGetValue(type.GetGenericTypeDefinition(), out var genericDefinition)) + return genericDefinition; + + var typeInterfaces = type.GetInterfaces(); + var candidates = _generics.Where(x => x.Key.IsAssignableFrom(type)) + .OrderByDescending(x => typeInterfaces.Count(y => y.IsAssignableFrom(x.Key))); + + return candidates.First().Value; + } + } +} diff --git a/src/Discord.Net.Interactions/Program.cs b/src/Discord.Net.Interactions/Program.cs deleted file mode 100644 index 3751555..0000000 --- a/src/Discord.Net.Interactions/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/src/Discord.Net.Interactions/RestInteractionModuleBase.cs b/src/Discord.Net.Interactions/RestInteractionModuleBase.cs new file mode 100644 index 0000000..26e527f --- /dev/null +++ b/src/Discord.Net.Interactions/RestInteractionModuleBase.cs @@ -0,0 +1,103 @@ +using Discord.Rest; +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Provides a base class for a Rest based command module to inherit from. + /// + /// Type of interaction context to be injected into the module. + public abstract class RestInteractionModuleBase : InteractionModuleBase + where T : class, IInteractionContext + { + /// + /// Gets or sets the underlying Interaction Service. + /// + public InteractionService InteractionService { get; set; } + + /// + /// Defer a Rest based Discord Interaction using the delegate. + /// + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The request options for this response. + /// + /// A Task representing the operation of creating the interaction response. + /// + /// Thrown if the interaction isn't a type of . + protected override Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + => HandleInteractionAsync(x => x.Defer(ephemeral, options)); + + /// + /// Respond to a Rest based Discord Interaction using the delegate. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A Task representing the operation of creating the interaction response. + /// + /// Thrown if the interaction isn't a type of . + protected override Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, RequestOptions options = null, MessageComponent components = null, Embed embed = null) + => HandleInteractionAsync(x => x.Respond(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options)); + + /// + /// Responds to the interaction with a modal. + /// + /// The modal to respond with. + /// The request options for this request. + /// + /// A Task representing the operation of creating the interaction response. + /// + /// Thrown if the interaction isn't a type of . + protected override Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + => HandleInteractionAsync(x => x.RespondWithModal(modal, options)); + + /// + /// Responds to the interaction with a modal. + /// + /// The type of the modal. + /// The custom ID of the modal. + /// The modal to respond with. + /// The request options for this request. + /// Delegate that can be used to modify the modal. + /// + /// A Task representing the operation of creating the interaction response. + /// + /// Thrown if the interaction isn't a type of . + protected override async Task RespondWithModalAsync(string customId, TModal modal, RequestOptions options = null, Action modifyModal = null) + => await HandleInteractionAsync(x => x.RespondWithModal(customId, modal, options, modifyModal)); + + /// + /// Responds to the interaction with a modal. + /// + /// + /// The custom ID of the modal. + /// The request options for this request. + /// Delegate that can be used to modify the modal. + /// + /// A Task representing the operation of creating the interaction response. + /// + /// Thrown if the interaction isn't a type of . + protected override Task RespondWithModalAsync(string customId, RequestOptions options = null, Action modifyModal = null) + => HandleInteractionAsync(x => x.RespondWithModal(customId, options, modifyModal)); + + private Task HandleInteractionAsync(Func action) + { + if (Context.Interaction is not RestInteraction restInteraction) + throw new InvalidOperationException($"Interaction must be a type of {nameof(RestInteraction)} in order to execute this method."); + + var payload = action(restInteraction); + + if (Context is IRestInteractionContext restContext && restContext.InteractionResponseCallback != null) + return restContext.InteractionResponseCallback.Invoke(payload); + else + return InteractionService._restResponseCallback(Context, payload); + } + } +} diff --git a/src/Discord.Net.Interactions/Results/AutocompletionResult.cs b/src/Discord.Net.Interactions/Results/AutocompletionResult.cs new file mode 100644 index 0000000..e5f65ce --- /dev/null +++ b/src/Discord.Net.Interactions/Results/AutocompletionResult.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Contains the information of a Autocomplete Interaction result. + /// + public struct AutocompletionResult : IResult + { + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => Error is null; + + /// + /// Get the collection of Autocomplete suggestions to be displayed to the user. + /// + public IReadOnlyCollection Suggestions { get; } + + private AutocompletionResult(IEnumerable suggestions, InteractionCommandError? error, string reason) + { + Suggestions = suggestions?.ToImmutableArray(); + Error = error; + ErrorReason = reason; + } + + /// + /// Initializes a new with no error and without any indicating the command service shouldn't + /// return any suggestions. + /// + /// + /// A that does not contain any errors. + /// + public static AutocompletionResult FromSuccess() => + new AutocompletionResult(null, null, null); + + /// + /// Initializes a new with no error. + /// + /// Autocomplete suggestions to be displayed to the user + /// + /// A that does not contain any errors. + /// + public static AutocompletionResult FromSuccess(IEnumerable suggestions) => + new AutocompletionResult(suggestions, null, null); + + /// + /// Initializes a new with a specified result; this may or may not be an + /// successful execution depending on the and + /// specified. + /// + /// The result to inherit from. + /// + /// A that inherits the error type and reason. + /// + public static AutocompletionResult FromError(IResult result) => + new AutocompletionResult(null, result.Error, result.ErrorReason); + + /// + /// Initializes a new with a specified exception, indicating an unsuccessful + /// execution. + /// + /// The exception that caused the autocomplete process to fail. + /// + /// A that contains the exception that caused the unsuccessful execution, along + /// with a of type as well as the exception message as the + /// reason. + /// + public static AutocompletionResult FromError(Exception exception) => + new AutocompletionResult(null, InteractionCommandError.Exception, exception.Message); + + /// + /// Initializes a new with a specified and its + /// reason, indicating an unsuccessful execution. + /// + /// The type of error. + /// The reason behind the error. + /// + /// A that contains a and reason. + /// + public static AutocompletionResult FromError(InteractionCommandError error, string reason) => + new AutocompletionResult(null, error, reason); + + /// + /// Gets a string that indicates the autocompletion result. + /// + /// + /// Success if is ; otherwise ": + /// ". + /// + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/Results/ExecuteResult.cs b/src/Discord.Net.Interactions/Results/ExecuteResult.cs new file mode 100644 index 0000000..9148647 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/ExecuteResult.cs @@ -0,0 +1,86 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Contains information of the command's overall execution result. + /// + public struct ExecuteResult : IResult + { + /// + /// Gets the exception that may have occurred during the command execution. + /// + public Exception Exception { get; } + + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private ExecuteResult(Exception exception, InteractionCommandError? commandError, string errorReason) + { + Exception = exception; + Error = commandError; + ErrorReason = errorReason; + } + + /// + /// Initializes a new with no error, indicating a successful execution. + /// + /// + /// A that does not contain any errors. + /// + public static ExecuteResult FromSuccess() => + new ExecuteResult(null, null, null); + + /// + /// Initializes a new with a specified and its + /// reason, indicating an unsuccessful execution. + /// + /// The type of error. + /// The reason behind the error. + /// + /// A that contains a and reason. + /// + public static ExecuteResult FromError(InteractionCommandError commandError, string reason) => + new ExecuteResult(null, commandError, reason); + + /// + /// Initializes a new with a specified exception, indicating an unsuccessful + /// execution. + /// + /// The exception that caused the command execution to fail. + /// + /// A that contains the exception that caused the unsuccessful execution, along + /// with a of type Exception as well as the exception message as the + /// reason. + /// + public static ExecuteResult FromError(Exception exception) => + new ExecuteResult(exception, InteractionCommandError.Exception, exception.Message); + + /// + /// Initializes a new with a specified result; this may or may not be an + /// successful execution depending on the and + /// specified. + /// + /// The result to inherit from. + /// + /// A that inherits the error type and reason. + /// + public static ExecuteResult FromError(IResult result) => + new ExecuteResult(null, result.Error, result.ErrorReason); + + /// + /// Gets a string that indicates the execution result. + /// + /// + /// Success if is ; otherwise ": + /// ". + /// + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/Results/IResult.cs b/src/Discord.Net.Interactions/Results/IResult.cs new file mode 100644 index 0000000..1b6089c --- /dev/null +++ b/src/Discord.Net.Interactions/Results/IResult.cs @@ -0,0 +1,33 @@ +namespace Discord.Interactions +{ + /// + /// Contains information of the result related to a command. + /// + public interface IResult + { + /// + /// Gets the error type that may have occurred during the operation. + /// + /// + /// A indicating the type of error that may have occurred during the operation; + /// if the operation was successful. + /// + InteractionCommandError? Error { get; } + + /// + /// Gets the reason for the error. + /// + /// + /// A string containing the error reason. + /// + string ErrorReason { get; } + + /// + /// Indicates whether the operation was successful or not. + /// + /// + /// if the result is positive; otherwise . + /// + bool IsSuccess { get; } + } +} diff --git a/src/Discord.Net.Interactions/Results/ParseResult.cs b/src/Discord.Net.Interactions/Results/ParseResult.cs new file mode 100644 index 0000000..ac99439 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/ParseResult.cs @@ -0,0 +1,36 @@ +using System; + +namespace Discord.Interactions +{ + public struct ParseResult : IResult + { + public object[] Args { get; } + + public InteractionCommandError? Error { get; } + + public string ErrorReason { get; } + + public bool IsSuccess => !Error.HasValue; + + private ParseResult(object[] args, InteractionCommandError? error, string reason) + { + Args = args; + Error = error; + ErrorReason = reason; + } + + public static ParseResult FromSuccess(object[] args) => + new ParseResult(args, null, null); + + public static ParseResult FromError(Exception exception) => + new ParseResult(null, InteractionCommandError.Exception, exception.Message); + + public static ParseResult FromError(InteractionCommandError error, string reason) => + new ParseResult(null, error, reason); + + public static ParseResult FromError(IResult result) => + new ParseResult(null, result.Error, result.ErrorReason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/Results/PreconditionGroupResult.cs b/src/Discord.Net.Interactions/Results/PreconditionGroupResult.cs new file mode 100644 index 0000000..8ee03c8 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/PreconditionGroupResult.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Represents a result type for grouped command preconditions. + /// + public class PreconditionGroupResult : PreconditionResult + { + /// + /// Gets the results of the preconditions of this group. + /// + public IReadOnlyCollection Results { get; } + + private PreconditionGroupResult(InteractionCommandError? error, string reason, IEnumerable results) : base(error, reason) + { + Results = results?.ToImmutableArray(); + } + + /// + /// Returns a with no errors. + /// + public static new PreconditionGroupResult FromSuccess() => + new PreconditionGroupResult(null, null, null); + + /// + /// Returns a with and the . + /// + /// The exception that caused the precondition check to fail. + public static new PreconditionGroupResult FromError(Exception exception) => + new PreconditionGroupResult(InteractionCommandError.Exception, exception.Message, null); + + /// + /// Returns a with the specified type. + /// + /// The result of failure. + public static new PreconditionGroupResult FromError(IResult result) => + new PreconditionGroupResult(result.Error, result.ErrorReason, null); + + /// + /// Returns a with and the + /// specified reason. + /// + /// The reason of failure. + /// Precondition results of this group + public static PreconditionGroupResult FromError(string reason, IEnumerable results) => + new PreconditionGroupResult(InteractionCommandError.UnmetPrecondition, reason, results); + } +} diff --git a/src/Discord.Net.Interactions/Results/PreconditionResult.cs b/src/Discord.Net.Interactions/Results/PreconditionResult.cs new file mode 100644 index 0000000..52d327e --- /dev/null +++ b/src/Discord.Net.Interactions/Results/PreconditionResult.cs @@ -0,0 +1,59 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Represents a result type for command preconditions. + /// + public class PreconditionResult : IResult + { + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => Error == null; + + /// + /// Initializes a new class with the command type + /// and reason. + /// + /// The type of failure. + /// The reason of failure. + protected PreconditionResult(InteractionCommandError? error, string reason) + { + Error = error; + ErrorReason = reason; + } + + /// + /// Returns a with no errors. + /// + public static PreconditionResult FromSuccess() => + new PreconditionResult(null, null); + + /// + /// Returns a with and the . + /// + /// The exception that caused the precondition check to fail. + public static PreconditionResult FromError(Exception exception) => + new PreconditionResult(InteractionCommandError.Exception, exception.Message); + + /// + /// Returns a with the specified type. + /// + /// The result of failure. + public static PreconditionResult FromError(IResult result) => + new PreconditionResult(result.Error, result.ErrorReason); + + /// + /// Returns a with and the + /// specified reason. + /// + /// The reason of failure. + public static PreconditionResult FromError(string reason) => + new PreconditionResult(InteractionCommandError.UnmetPrecondition, reason); + } +} diff --git a/src/Discord.Net.Interactions/Results/RuntimeResult.cs b/src/Discord.Net.Interactions/Results/RuntimeResult.cs new file mode 100644 index 0000000..cf2d456 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/RuntimeResult.cs @@ -0,0 +1,37 @@ +namespace Discord.Interactions +{ + /// + /// Represents the base class for creating command result containers. + /// + public abstract class RuntimeResult : IResult + { + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + /// + /// Initializes a new class with the type of error and reason. + /// + /// The type of failure, or if none. + /// The reason of failure. + protected RuntimeResult(InteractionCommandError? error, string reason) + { + Error = error; + ErrorReason = reason; + } + + /// + /// Gets a string that indicates the runtime result. + /// + /// + /// Success if is ; otherwise ": + /// ". + /// + public override string ToString() => ErrorReason ?? (IsSuccess ? "Successful" : "Unsuccessful"); + } +} diff --git a/src/Discord.Net.Interactions/Results/SearchResult.cs b/src/Discord.Net.Interactions/Results/SearchResult.cs new file mode 100644 index 0000000..9b06a54 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/SearchResult.cs @@ -0,0 +1,93 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Contains information of a command search. + /// + /// Type of the target command type. + public struct SearchResult : IResult where T : class, ICommandInfo + { + /// + /// Gets the input text of the command search. + /// + public string Text { get; } + + /// + /// Gets the found command, if the search was successful. + /// + public T Command { get; } + + /// + /// Gets the Regex groups captured by the wild card pattern. + /// + public string[] RegexCaptureGroups { get; } + + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private SearchResult(string text, T commandInfo, string[] captureGroups, InteractionCommandError? error, string reason) + { + Text = text; + Error = error; + RegexCaptureGroups = captureGroups; + Command = commandInfo; + ErrorReason = reason; + } + + /// + /// Initializes a new with no error, indicating a successful execution. + /// + /// + /// A that does not contain any errors. + /// + public static SearchResult FromSuccess(string text, T commandInfo, string[] wildCardMatch = null) => + new SearchResult(text, commandInfo, wildCardMatch, null, null); + + /// + /// Initializes a new with a specified and its + /// reason, indicating an unsuccessful execution. + /// + /// The type of error. + /// The reason behind the error. + /// + /// A that contains a and reason. + /// + public static SearchResult FromError(string text, InteractionCommandError error, string reason) => + new SearchResult(text, null, null, error, reason); + + /// + /// Initializes a new with a specified exception, indicating an unsuccessful + /// execution. + /// + /// The exception that caused the command execution to fail. + /// + /// A that contains the exception that caused the unsuccessful execution, along + /// with a of type Exception as well as the exception message as the + /// reason. + /// + public static SearchResult FromError(Exception ex) => + new SearchResult(null, null, null, InteractionCommandError.Exception, ex.Message); + + /// + /// Initializes a new with a specified result; this may or may not be an + /// successful depending on the and + /// specified. + /// + /// The result to inherit from. + /// + /// A that inherits the error type and reason. + /// + public static SearchResult FromError(IResult result) => + new SearchResult(null, null, null, result.Error, result.ErrorReason); + + /// + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/Results/TypeConverterResult.cs b/src/Discord.Net.Interactions/Results/TypeConverterResult.cs new file mode 100644 index 0000000..bef8f30 --- /dev/null +++ b/src/Discord.Net.Interactions/Results/TypeConverterResult.cs @@ -0,0 +1,61 @@ +using System; + +namespace Discord.Interactions +{ + /// + /// Represents a result type for . + /// + public struct TypeConverterResult : IResult + { + /// + /// Gets the result of the conversion if the operation was successful. + /// + public object Value { get; } + + /// + public InteractionCommandError? Error { get; } + + /// + public string ErrorReason { get; } + + /// + public bool IsSuccess => !Error.HasValue; + + private TypeConverterResult(object value, InteractionCommandError? error, string reason) + { + Value = value; + Error = error; + ErrorReason = reason; + } + + /// + /// Returns a with no errors. + /// + public static TypeConverterResult FromSuccess(object value) => + new TypeConverterResult(value, null, null); + + /// + /// Returns a with and the . + /// + /// The exception that caused the type conversion to fail. + public static TypeConverterResult FromError(Exception exception) => + new TypeConverterResult(null, InteractionCommandError.Exception, exception.Message); + + /// + /// Returns a with the specified error and the reason. + /// + /// The type of error. + /// The reason of failure. + public static TypeConverterResult FromError(InteractionCommandError error, string reason) => + new TypeConverterResult(null, error, reason); + + /// + /// Returns a with the specified type. + /// + /// The result of failure. + public static TypeConverterResult FromError(IResult result) => + new TypeConverterResult(null, result.Error, result.ErrorReason); + + public override string ToString() => IsSuccess ? "Success" : $"{Error}: {ErrorReason}"; + } +} diff --git a/src/Discord.Net.Interactions/RunMode.cs b/src/Discord.Net.Interactions/RunMode.cs new file mode 100644 index 0000000..1577da8 --- /dev/null +++ b/src/Discord.Net.Interactions/RunMode.cs @@ -0,0 +1,22 @@ +namespace Discord.Interactions +{ + /// + /// Specifies the behavior of the command execution workflow. + /// + /// + public enum RunMode + { + /// + /// Executes the command on the same thread as gateway one. + /// + Sync, + /// + /// Executes the command on a different thread from the gateway one. + /// + Async, + /// + /// The default behaviour set in . + /// + Default + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/ComponentTypeConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/ComponentTypeConverter.cs new file mode 100644 index 0000000..cb5ff7e --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/ComponentTypeConverter.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base class for creating Component TypeConverters. uses TypeConverters to interface with Slash Command parameters. + /// + public abstract class ComponentTypeConverter : ITypeConverter + { + /// + /// Will be used to search for alternative TypeConverters whenever the Command Service encounters an unknown parameter type. + /// + /// An object type. + /// + /// The boolean result. + /// + public abstract bool CanConvertTo(Type type); + + /// + /// Will be used to read the incoming payload before executing the method body. + /// + /// Command execution context. + /// Received option payload. + /// Service provider that will be used to initialize the command module. + /// + /// The result of the read process. + /// + public abstract Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services); + } + + /// + public abstract class ComponentTypeConverter : ComponentTypeConverter + { + /// + public sealed override bool CanConvertTo(Type type) => + typeof(T).IsAssignableFrom(type); + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/DefaultArrayComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/DefaultArrayComponentConverter.cs new file mode 100644 index 0000000..5dced96 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/DefaultArrayComponentConverter.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class DefaultArrayComponentConverter : ComponentTypeConverter + { + private readonly TypeReader _typeReader; + private readonly Type _underlyingType; + + public DefaultArrayComponentConverter(InteractionService interactionService) + { + var type = typeof(T); + + if (!type.IsArray) + throw new InvalidOperationException($"{nameof(DefaultArrayComponentConverter)} cannot be used to convert a non-array type."); + + _underlyingType = typeof(T).GetElementType(); + + _typeReader = true switch + { + _ when typeof(IUser).IsAssignableFrom(_underlyingType) + || typeof(IChannel).IsAssignableFrom(_underlyingType) + || typeof(IMentionable).IsAssignableFrom(_underlyingType) + || typeof(IRole).IsAssignableFrom(_underlyingType) => null, + _ => interactionService.GetTypeReader(_underlyingType) + }; + } + + public override async Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + var objs = new List(); + + if (_typeReader is not null && option.Values.Count > 0) + foreach (var value in option.Values) + { + var result = await _typeReader.ReadAsync(context, value, services).ConfigureAwait(false); + + if (!result.IsSuccess) + return result; + + objs.Add(result.Value); + } + else + { + var users = new Dictionary(); + + if (option.Users is not null) + foreach (var user in option.Users) + users[user.Id] = user; + + if (option.Members is not null) + foreach (var member in option.Members) + users[member.Id] = member; + + objs.AddRange(users.Values); + + if (option.Roles is not null) + objs.AddRange(option.Roles); + + if (option.Channels is not null) + objs.AddRange(option.Channels); + } + + var destination = Array.CreateInstance(_underlyingType, objs.Count); + + for (var i = 0; i < objs.Count; i++) + destination.SetValue(objs[i], i); + + return TypeConverterResult.FromSuccess(destination); + } + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/DefaultValueComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/DefaultValueComponentConverter.cs new file mode 100644 index 0000000..9ed82c6 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/DefaultValueComponentConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class DefaultValueComponentConverter : ComponentTypeConverter + where T : IConvertible + { + public override Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + { + try + { + return option.Type switch + { + ComponentType.SelectMenu => Task.FromResult(TypeConverterResult.FromSuccess(Convert.ChangeType(string.Join(",", option.Values), typeof(T)))), + ComponentType.TextInput => Task.FromResult(TypeConverterResult.FromSuccess(Convert.ChangeType(option.Value, typeof(T)))), + _ => Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"{option.Type} doesn't have a convertible value.")) + }; + } + catch (InvalidCastException castEx) + { + return Task.FromResult(TypeConverterResult.FromError(castEx)); + } + } + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/NullableComponentConverter.cs b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/NullableComponentConverter.cs new file mode 100644 index 0000000..ba6568a --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/ComponentInteractions/NullableComponentConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal class NullableComponentConverter : ComponentTypeConverter + { + private readonly ComponentTypeConverter _typeConverter; + + public NullableComponentConverter(InteractionService interactionService, IServiceProvider services) + { + var type = Nullable.GetUnderlyingType(typeof(T)); + + if (type is null) + throw new ArgumentException($"No type {nameof(TypeConverter)} is defined for this {type.FullName}", "type"); + + _typeConverter = interactionService.GetComponentTypeConverter(type, services); + } + + public override Task ReadAsync(IInteractionContext context, IComponentInteractionData option, IServiceProvider services) + => string.IsNullOrEmpty(option.Value) ? Task.FromResult(TypeConverterResult.FromSuccess(null)) : _typeConverter.ReadAsync(context, option, services); + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/DefaultEntityTypeConverter.cs b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/DefaultEntityTypeConverter.cs new file mode 100644 index 0000000..facf151 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/DefaultEntityTypeConverter.cs @@ -0,0 +1,95 @@ +using Discord.WebSocket; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal abstract class DefaultEntityTypeConverter : TypeConverter where T : class + { + public override Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + var value = option.Value as T; + + if (value is not null) + return Task.FromResult(TypeConverterResult.FromSuccess(option.Value as T)); + else + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Provided input cannot be read as {nameof(IChannel)}")); + } + } + + internal class DefaultAttachmentConverter : DefaultEntityTypeConverter where T : class, IAttachment + { + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.Attachment; + } + + internal class DefaultRoleConverter : DefaultEntityTypeConverter where T : class, IRole + { + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.Role; + } + + internal class DefaultUserConverter : DefaultEntityTypeConverter where T : class, IUser + { + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.User; + } + + internal class DefaultChannelConverter : DefaultEntityTypeConverter where T : class, IChannel + { + private readonly List _channelTypes; + + public DefaultChannelConverter() + { + var type = typeof(T); + + _channelTypes = true switch + { + _ when typeof(IStageChannel).IsAssignableFrom(type) + => new List { ChannelType.Stage }, + + _ when typeof(IVoiceChannel).IsAssignableFrom(type) + => new List { ChannelType.Voice }, + + _ when typeof(IDMChannel).IsAssignableFrom(type) + => new List { ChannelType.DM }, + + _ when typeof(IGroupChannel).IsAssignableFrom(type) + => new List { ChannelType.Group }, + + _ when typeof(ICategoryChannel).IsAssignableFrom(type) + => new List { ChannelType.Category }, + + _ when typeof(INewsChannel).IsAssignableFrom(type) + => new List { ChannelType.News }, + + _ when typeof(IThreadChannel).IsAssignableFrom(type) + => new List { ChannelType.PublicThread, ChannelType.PrivateThread, ChannelType.NewsThread }, + + _ when typeof(ITextChannel).IsAssignableFrom(type) + => new List { ChannelType.Text }, + + _ when typeof(IMediaChannel).IsAssignableFrom(type) + => new List { ChannelType.Media }, + + _ when typeof(IForumChannel).IsAssignableFrom(type) + => new List { ChannelType.Forum }, + + _ => null + }; + } + + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.Channel; + + public override void Write(ApplicationCommandOptionProperties properties, IParameterInfo parameter) + { + if (_channelTypes is not null) + properties.ChannelTypes = _channelTypes; + } + } + + internal class DefaultMentionableConverter : DefaultEntityTypeConverter where T : class, IMentionable + { + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.Mentionable; + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/DefaultValueConverter.cs b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/DefaultValueConverter.cs new file mode 100644 index 0000000..15f6164 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/DefaultValueConverter.cs @@ -0,0 +1,61 @@ +using Discord.WebSocket; +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal class DefaultValueConverter : TypeConverter where T : IConvertible + { + public override ApplicationCommandOptionType GetDiscordType() + { + switch (Type.GetTypeCode(typeof(T))) + { + case TypeCode.Boolean: + return ApplicationCommandOptionType.Boolean; + + case TypeCode.DateTime: + case TypeCode.SByte: + case TypeCode.Byte: + case TypeCode.Char: + case TypeCode.String: + case TypeCode.Single: + return ApplicationCommandOptionType.String; + + case TypeCode.Int16: + case TypeCode.Int32: + case TypeCode.Int64: + case TypeCode.UInt16: + case TypeCode.UInt32: + case TypeCode.UInt64: + return ApplicationCommandOptionType.Integer; + + case TypeCode.Decimal: + case TypeCode.Double: + return ApplicationCommandOptionType.Number; + + case TypeCode.DBNull: + default: + throw new InvalidOperationException($"Parameter Type {typeof(T).FullName} is not supported by Discord."); + } + } + public override Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + object value; + + if (option.Value is Optional optional) + value = optional.IsSpecified ? optional.Value : default(T); + else + value = option.Value; + + try + { + var converted = Convert.ChangeType(value, typeof(T)); + return Task.FromResult(TypeConverterResult.FromSuccess(converted)); + } + catch (InvalidCastException castEx) + { + return Task.FromResult(TypeConverterResult.FromError(castEx)); + } + } + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/EnumConverter.cs b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/EnumConverter.cs new file mode 100644 index 0000000..b95e859 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/EnumConverter.cs @@ -0,0 +1,55 @@ +using Discord.WebSocket; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class EnumConverter : TypeConverter where T : struct, Enum + { + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.String; + public override Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + if (Enum.TryParse((string)option.Value, out var result)) + return Task.FromResult(TypeConverterResult.FromSuccess(result)); + else + return Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Value {option.Value} cannot be converted to {nameof(T)}")); + } + + public override void Write(ApplicationCommandOptionProperties properties, IParameterInfo parameterInfo) + { + var names = Enum.GetNames(typeof(T)); + var members = names.SelectMany(x => typeof(T).GetMember(x)).Where(x => !x.IsDefined(typeof(HideAttribute), true)); + var localizationManager = parameterInfo.Command.Module.CommandService.LocalizationManager; + + if (members.Count() <= 25) + { + var choices = new List(); + + foreach (var member in members) + { + var displayValue = member.GetCustomAttribute()?.Name ?? member.Name; + choices.Add(new ApplicationCommandOptionChoiceProperties + { + Name = displayValue, + Value = member.Name, + NameLocalizations = localizationManager?.GetAllNames(parameterInfo.GetChoicePath(new ParameterChoice(displayValue.ToLower(), member.Name)), LocalizationTarget.Choice) ?? ImmutableDictionary.Empty + }); + } + properties.Choices = choices; + } + } + } + + /// + /// Enum values tagged with this attribute will not be displayed as a parameter choice + /// + /// + /// This attribute must be used along with the default + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false, Inherited = true)] + public sealed class HideAttribute : Attribute { } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/NullableConverter.cs b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/NullableConverter.cs new file mode 100644 index 0000000..d85b376 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/NullableConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal class NullableConverter : TypeConverter + { + private readonly TypeConverter _typeConverter; + + public NullableConverter(InteractionService interactionService, IServiceProvider services) + { + var nullableType = typeof(T); + var type = Nullable.GetUnderlyingType(nullableType); + + if (type is null) + throw new ArgumentException($"No type {nameof(TypeConverter)} is defined for this {nullableType.FullName}", nameof(type)); + + _typeConverter = interactionService.GetTypeConverter(type, services); + } + + public override ApplicationCommandOptionType GetDiscordType() + => _typeConverter.GetDiscordType(); + + public override Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + => _typeConverter.ReadAsync(context, option, services); + + public override void Write(ApplicationCommandOptionProperties properties, IParameterInfo parameter) + => _typeConverter.Write(properties, parameter); + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/TimeSpanConverter.cs b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/TimeSpanConverter.cs new file mode 100644 index 0000000..a299de0 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/TimeSpanConverter.cs @@ -0,0 +1,36 @@ +using Discord.WebSocket; +using System; +using System.Globalization; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class TimeSpanConverter : TypeConverter + { + public override ApplicationCommandOptionType GetDiscordType() => ApplicationCommandOptionType.String; + public override Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services) + { + return (TimeSpan.TryParseExact((option.Value as string).ToLowerInvariant(), Formats, CultureInfo.InvariantCulture, out var timeSpan)) + ? Task.FromResult(TypeConverterResult.FromSuccess(timeSpan)) + : Task.FromResult(TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, "Failed to parse TimeSpan")); + } + + private static readonly string[] Formats = { + "%d'd'%h'h'%m'm'%s's'", //4d3h2m1s + "%d'd'%h'h'%m'm'", //4d3h2m + "%d'd'%h'h'%s's'", //4d3h 1s + "%d'd'%h'h'", //4d3h + "%d'd'%m'm'%s's'", //4d 2m1s + "%d'd'%m'm'", //4d 2m + "%d'd'%s's'", //4d 1s + "%d'd'", //4d + "%h'h'%m'm'%s's'", // 3h2m1s + "%h'h'%m'm'", // 3h2m + "%h'h'%s's'", // 3h 1s + "%h'h'", // 3h + "%m'm'%s's'", // 2m1s + "%m'm'", // 2m + "%s's'", // 1s + }; + } +} diff --git a/src/Discord.Net.Interactions/TypeConverters/SlashCommands/TypeConverter.cs b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/TypeConverter.cs new file mode 100644 index 0000000..e095011 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeConverters/SlashCommands/TypeConverter.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base class for creating TypeConverters. uses TypeConverters to interface with Slash Command parameters. + /// + public abstract class TypeConverter : ITypeConverter + { + /// + /// Will be used to search for alternative TypeConverters whenever the Command Service encounters an unknown parameter type. + /// + /// + /// + public abstract bool CanConvertTo(Type type); + + /// + /// Will be used to get the Application Command Option type. + /// + /// The option type. + public abstract ApplicationCommandOptionType GetDiscordType(); + + /// + /// Will be used to read the incoming payload before executing the method body. + /// + /// Command execution context. + /// Received option payload. + /// Service provider that will be used to initialize the command module. + /// The result of the read process. + public abstract Task ReadAsync(IInteractionContext context, IApplicationCommandInteractionDataOption option, IServiceProvider services); + + /// + /// Will be used to manipulate the outgoing command option, before the command gets registered to Discord. + /// + public virtual void Write(ApplicationCommandOptionProperties properties, IParameterInfo parameter) { } + } + + /// + public abstract class TypeConverter : TypeConverter + { + /// + public sealed override bool CanConvertTo(Type type) => + typeof(T).IsAssignableFrom(type); + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/DefaultSnowflakeReader.cs b/src/Discord.Net.Interactions/TypeReaders/DefaultSnowflakeReader.cs new file mode 100644 index 0000000..e2ac1ef --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/DefaultSnowflakeReader.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal abstract class DefaultSnowflakeReader : TypeReader + where T : class, ISnowflakeEntity + { + protected abstract Task GetEntity(ulong id, IInteractionContext ctx); + + public override async Task ReadAsync(IInteractionContext context, string option, IServiceProvider services) + { + if (!ulong.TryParse(option, out var snowflake)) + return TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"{option} isn't a valid snowflake thus cannot be converted into {typeof(T).Name}"); + + var result = await GetEntity(snowflake, context).ConfigureAwait(false); + + return result is not null ? + TypeConverterResult.FromSuccess(result) : TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"{option} must be a valid {typeof(T).Name} snowflake to be parsed."); + } + + public override Task SerializeAsync(object obj, IServiceProvider services) => Task.FromResult((obj as ISnowflakeEntity)?.Id.ToString()); + } + + internal sealed class DefaultUserReader : DefaultSnowflakeReader + where T : class, IUser + { + protected override async Task GetEntity(ulong id, IInteractionContext ctx) => await ctx.Client.GetUserAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T; + } + + internal sealed class DefaultChannelReader : DefaultSnowflakeReader + where T : class, IChannel + { + protected override async Task GetEntity(ulong id, IInteractionContext ctx) => await ctx.Client.GetChannelAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T; + } + + internal sealed class DefaultRoleReader : DefaultSnowflakeReader + where T : class, IRole + { + protected override Task GetEntity(ulong id, IInteractionContext ctx) => Task.FromResult(ctx.Guild?.GetRole(id) as T); + } + + internal sealed class DefaultMessageReader : DefaultSnowflakeReader + where T : class, IMessage + { + protected override async Task GetEntity(ulong id, IInteractionContext ctx) => await ctx.Channel.GetMessageAsync(id, CacheMode.CacheOnly).ConfigureAwait(false) as T; + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/DefaultValueReader.cs b/src/Discord.Net.Interactions/TypeReaders/DefaultValueReader.cs new file mode 100644 index 0000000..e833382 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/DefaultValueReader.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class DefaultValueReader : TypeReader + where T : IConvertible + { + public override Task ReadAsync(IInteractionContext context, string option, IServiceProvider services) + { + try + { + var converted = Convert.ChangeType(option, typeof(T)); + return Task.FromResult(TypeConverterResult.FromSuccess(converted)); + } + catch (InvalidCastException castEx) + { + return Task.FromResult(TypeConverterResult.FromError(castEx)); + } + } + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/EnumReader.cs b/src/Discord.Net.Interactions/TypeReaders/EnumReader.cs new file mode 100644 index 0000000..df6f2ac --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/EnumReader.cs @@ -0,0 +1,25 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal sealed class EnumReader : TypeReader + where T : struct, Enum + { + public override Task ReadAsync(IInteractionContext context, string option, IServiceProvider services) + { + return Task.FromResult(Enum.TryParse(option, out var result) ? + TypeConverterResult.FromSuccess(result) : TypeConverterResult.FromError(InteractionCommandError.ConvertFailed, $"Value {option} cannot be converted to {nameof(T)}")); + } + + public override Task SerializeAsync(object obj, IServiceProvider services) + { + var name = Enum.GetName(typeof(T), obj); + + if (name is null) + throw new ArgumentException($"Enum name cannot be parsed from {obj}"); + + return Task.FromResult(name); + } + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/NullableReader.cs b/src/Discord.Net.Interactions/TypeReaders/NullableReader.cs new file mode 100644 index 0000000..ed88dc6 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/NullableReader.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal class NullableReader : TypeReader + { + private readonly TypeReader _typeReader; + + public NullableReader(InteractionService interactionService, IServiceProvider services) + { + var type = Nullable.GetUnderlyingType(typeof(T)); + + if (type is null) + throw new ArgumentException($"No type {nameof(TypeConverter)} is defined for this {type.FullName}", "type"); + + _typeReader = interactionService.GetTypeReader(type, services); + } + + public override Task ReadAsync(IInteractionContext context, string option, IServiceProvider services) + => string.IsNullOrEmpty(option) ? Task.FromResult(TypeConverterResult.FromSuccess(null)) : _typeReader.ReadAsync(context, option, services); + } +} diff --git a/src/Discord.Net.Interactions/TypeReaders/TypeReader.cs b/src/Discord.Net.Interactions/TypeReaders/TypeReader.cs new file mode 100644 index 0000000..e518e02 --- /dev/null +++ b/src/Discord.Net.Interactions/TypeReaders/TypeReader.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Base class for creating TypeConverters. uses TypeConverters to interface with Slash Command parameters. + /// + public abstract class TypeReader : ITypeConverter + { + /// + /// Will be used to search for alternative TypeReaders whenever the Command Service encounters an unknown parameter type. + /// + /// An object type. + /// + /// The boolean result. + /// + public abstract bool CanConvertTo(Type type); + + /// + /// Will be used to read the incoming payload before executing the method body. + /// + /// Command execution context. + /// Received option payload. + /// Service provider that will be used to initialize the command module. + /// The result of the read process. + public abstract Task ReadAsync(IInteractionContext context, string option, IServiceProvider services); + + /// + /// Will be used to serialize objects into strings. + /// + /// Object to be serialized. + /// + /// A task representing the conversion process. The result of the task contains the conversion result. + /// + public virtual Task SerializeAsync(object obj, IServiceProvider services) => Task.FromResult(obj.ToString()); + } + + /// + public abstract class TypeReader : TypeReader + { + /// + public sealed override bool CanConvertTo(Type type) => + typeof(T).IsAssignableFrom(type); + } +} diff --git a/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs new file mode 100644 index 0000000..8cee68f --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/ApplicationCommandRestUtil.cs @@ -0,0 +1,363 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Interactions +{ + internal static class ApplicationCommandRestUtil + { + #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, + Description = parameterInfo.Description, + Type = parameterInfo.DiscordOptionType.Value, + IsRequired = parameterInfo.IsRequired, + Choices = parameterInfo.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties + { + Name = x.Name, + Value = x.Value, + NameLocalizations = localizationManager?.GetAllNames(parameterInfo.GetChoicePath(x), LocalizationTarget.Choice) ?? ImmutableDictionary.Empty + })?.ToList(), + ChannelTypes = parameterInfo.ChannelTypes?.ToList(), + IsAutocomplete = parameterInfo.IsAutocomplete, + MaxValue = parameterInfo.MaxValue, + MinValue = parameterInfo.MinValue, + NameLocalizations = localizationManager?.GetAllNames(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary.Empty, + DescriptionLocalizations = localizationManager?.GetAllDescriptions(parameterPath, LocalizationTarget.Parameter) ?? ImmutableDictionary.Empty, + MinLength = parameterInfo.MinLength, + MaxLength = parameterInfo.MaxLength, + }; + + parameterInfo.TypeConverter.Write(props, parameterInfo); + + return props; + } + #endregion + + #region Commands + + 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, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = commandInfo.IsEnabledInDm, +#pragma warning restore CS0618 // Type or member is obsolete + IsNsfw = commandInfo.IsNsfw, + DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(), + IntegrationTypes = commandInfo.IntegrationTypes is not null + ? new HashSet(commandInfo.IntegrationTypes) + : null, + ContextTypes = commandInfo.ContextTypes is not null + ? new HashSet(commandInfo.ContextTypes) + : null, + }.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"); + + props.Options = commandInfo.FlattenedParameters.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional>.Unspecified; + + return props; + } + + 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(), + NameLocalizations = localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty, + DescriptionLocalizations = localizationManager?.GetAllDescriptions(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty, + }; + } + + public static ApplicationCommandProperties ToApplicationCommandProps(this ContextCommandInfo commandInfo) + { + var localizationManager = commandInfo.Module.CommandService.LocalizationManager; + var commandPath = commandInfo.GetCommandPath(); + + return commandInfo.CommandType switch + { + ApplicationCommandType.Message => new MessageCommandBuilder + { + Name = commandInfo.Name, + IsDefaultPermission = commandInfo.DefaultPermission, + DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(), +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = commandInfo.IsEnabledInDm, +#pragma warning restore CS0618 // Type or member is obsolete + IsNsfw = commandInfo.IsNsfw, + IntegrationTypes = commandInfo.IntegrationTypes is not null + ? new HashSet(commandInfo.IntegrationTypes) + : null, + ContextTypes = commandInfo.ContextTypes is not null + ? new HashSet(commandInfo.ContextTypes) + : null, + } + .WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty) + .Build(), + ApplicationCommandType.User => new UserCommandBuilder + { + Name = commandInfo.Name, + IsDefaultPermission = commandInfo.DefaultPermission, + DefaultMemberPermissions = ((commandInfo.DefaultMemberPermissions ?? 0) | (commandInfo.Module.DefaultMemberPermissions ?? 0)).SanitizeGuildPermissions(), + IsNsfw = commandInfo.IsNsfw, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = commandInfo.IsEnabledInDm, +#pragma warning restore CS0618 // Type or member is obsolete + IntegrationTypes = commandInfo.IntegrationTypes is not null + ? new HashSet(commandInfo.IntegrationTypes) + : null, + ContextTypes = commandInfo.ContextTypes is not null + ? new HashSet(commandInfo.ContextTypes) + : null, + } + .WithNameLocalizations(localizationManager?.GetAllNames(commandPath, LocalizationTarget.Command) ?? ImmutableDictionary.Empty) + .Build(), + _ => throw new InvalidOperationException($"{commandInfo.CommandType} isn't a supported command type.") + }; + } + #endregion + + #region Modules + + public static IReadOnlyCollection ToApplicationCommandProps(this ModuleInfo moduleInfo, bool ignoreDontRegister = false) + { + var args = new List(); + + moduleInfo.ParseModuleModel(args, ignoreDontRegister); + return args; + } + + private static void ParseModuleModel(this ModuleInfo moduleInfo, List args, bool ignoreDontRegister) + { + if (moduleInfo.DontAutoRegister && !ignoreDontRegister) + return; + + args.AddRange(moduleInfo.ContextCommands?.Select(x => x.ToApplicationCommandProps())); + + if (!moduleInfo.IsSlashGroup) + { + args.AddRange(moduleInfo.SlashCommands?.Select(x => x.ToApplicationCommandProps())); + + foreach (var submodule in moduleInfo.SubModules) + submodule.ParseModuleModel(args, ignoreDontRegister); + } + else + { + var options = new List(); + + foreach (var command in moduleInfo.SlashCommands) + { + if (command.IgnoreGroupNames) + args.Add(command.ToApplicationCommandProps()); + else + options.Add(command.ToApplicationCommandOptionProps()); + } + + 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, +#pragma warning disable CS0618 // Type or member is obsolete + IsDefaultPermission = moduleInfo.DefaultPermission, + IsDMEnabled = moduleInfo.IsEnabledInDm, +#pragma warning restore CS0618 // Type or member is obsolete + IsNsfw = moduleInfo.IsNsfw, + DefaultMemberPermissions = moduleInfo.DefaultMemberPermissions, + IntegrationTypes = moduleInfo.IntegrationTypes is not null + ? new HashSet(moduleInfo.IntegrationTypes) + : null, + ContextTypes = moduleInfo.ContextTypes is not null + ? new HashSet(moduleInfo.ContextTypes) + : null, + } + .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"); + + props.Options = options; + + args.Add(props); + } + } + + private static IReadOnlyCollection ParseSubModule(this ModuleInfo moduleInfo, List args, + bool ignoreDontRegister) + { + if (moduleInfo.DontAutoRegister && !ignoreDontRegister) + return Array.Empty(); + + args.AddRange(moduleInfo.ContextCommands?.Select(x => x.ToApplicationCommandProps())); + + var options = new List(); + options.AddRange(moduleInfo.SubModules?.SelectMany(x => x.ParseSubModule(args, ignoreDontRegister))); + + foreach (var command in moduleInfo.SlashCommands) + { + if (command.IgnoreGroupNames) + args.Add(command.ToApplicationCommandProps()); + else + options.Add(command.ToApplicationCommandOptionProps()); + } + + if (!moduleInfo.IsSlashGroup) + return options; + else + return new List() { new ApplicationCommandOptionProperties + { + Name = moduleInfo.SlashGroupName, + Description = moduleInfo.Description, + Type = ApplicationCommandOptionType.SubCommandGroup, + Options = options, + NameLocalizations = moduleInfo.CommandService.LocalizationManager?.GetAllNames(moduleInfo.GetModulePath(), LocalizationTarget.Group) + ?? ImmutableDictionary.Empty, + DescriptionLocalizations = moduleInfo.CommandService.LocalizationManager?.GetAllDescriptions(moduleInfo.GetModulePath(), LocalizationTarget.Group) + ?? ImmutableDictionary.Empty, + } }; + } + + #endregion + + public static ApplicationCommandProperties ToApplicationCommandProps(this IApplicationCommand command) + { + return command.Type switch + { + ApplicationCommandType.Slash => new SlashCommandProperties + { + Name = command.Name, + Description = command.Description, + IsDefaultPermission = command.IsDefaultPermission, + DefaultMemberPermissions = command.DefaultMemberPermissions.RawValue == 0 ? new Optional() : (GuildPermission)command.DefaultMemberPermissions.RawValue, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = command.IsEnabledInDm, +#pragma warning restore CS0618 // Type or member is obsolete + IsNsfw = command.IsNsfw, + Options = command.Options?.Select(x => x.ToApplicationCommandOptionProps())?.ToList() ?? Optional>.Unspecified, + NameLocalizations = command.NameLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + ContextTypes = command.ContextTypes is not null + ? new HashSet(command.ContextTypes) + : Optional>.Unspecified, + IntegrationTypes = command.IntegrationTypes is not null + ? new HashSet(command.IntegrationTypes) + : Optional>.Unspecified, + }, + ApplicationCommandType.User => new UserCommandProperties + { + Name = command.Name, + IsDefaultPermission = command.IsDefaultPermission, + DefaultMemberPermissions = command.DefaultMemberPermissions.RawValue == 0 ? new Optional() : (GuildPermission)command.DefaultMemberPermissions.RawValue, + IsNsfw = command.IsNsfw, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = command.IsEnabledInDm, +#pragma warning restore CS0618 // Type or member is obsolete + NameLocalizations = command.NameLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + ContextTypes = command.ContextTypes is not null + ? new HashSet(command.ContextTypes) + : Optional>.Unspecified, + IntegrationTypes = command.IntegrationTypes is not null + ? new HashSet(command.IntegrationTypes) + : Optional>.Unspecified, + }, + ApplicationCommandType.Message => new MessageCommandProperties + { + Name = command.Name, + IsDefaultPermission = command.IsDefaultPermission, + DefaultMemberPermissions = command.DefaultMemberPermissions.RawValue == 0 ? new Optional() : (GuildPermission)command.DefaultMemberPermissions.RawValue, + IsNsfw = command.IsNsfw, +#pragma warning disable CS0618 // Type or member is obsolete + IsDMEnabled = command.IsEnabledInDm, +#pragma warning restore CS0618 // Type or member is obsolete + NameLocalizations = command.NameLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + DescriptionLocalizations = command.DescriptionLocalizations?.ToImmutableDictionary() ?? ImmutableDictionary.Empty, + ContextTypes = command.ContextTypes is not null + ? new HashSet(command.ContextTypes) + : Optional>.Unspecified, + IntegrationTypes = command.IntegrationTypes is not null + ? new HashSet(command.IntegrationTypes) + : Optional>.Unspecified, + }, + _ => throw new InvalidOperationException($"Cannot create command properties for command type {command.Type}"), + }; + } + + public static ApplicationCommandOptionProperties ToApplicationCommandOptionProps(this IApplicationCommandOption commandOption) => + new ApplicationCommandOptionProperties + { + Name = commandOption.Name, + Description = commandOption.Description, + Type = commandOption.Type, + IsRequired = commandOption.IsRequired, + ChannelTypes = commandOption.ChannelTypes?.ToList(), + IsAutocomplete = commandOption.IsAutocomplete.GetValueOrDefault(), + MinValue = commandOption.MinValue, + MaxValue = commandOption.MaxValue, + Choices = commandOption.Choices?.Select(x => new ApplicationCommandOptionChoiceProperties + { + Name = x.Name, + Value = x.Value + }).ToList(), + Options = commandOption.Options?.Select(x => x.ToApplicationCommandOptionProps()).ToList(), + NameLocalizations = commandOption.NameLocalizations?.ToImmutableDictionary(), + DescriptionLocalizations = commandOption.DescriptionLocalizations?.ToImmutableDictionary(), + MaxLength = commandOption.MaxLength, + MinLength = commandOption.MinLength, + }; + + public static Modal ToModal(this ModalInfo modalInfo, string customId, Action modifyModal = null) + { + var builder = new ModalBuilder(modalInfo.Title, customId); + + foreach (var input in modalInfo.Components) + switch (input) + { + case TextInputComponentInfo textComponent: + builder.AddTextInput(textComponent.Label, textComponent.CustomId, textComponent.Style, textComponent.Placeholder, textComponent.IsRequired ? textComponent.MinLength : null, + textComponent.MaxLength, textComponent.IsRequired, textComponent.InitialValue); + break; + default: + throw new InvalidOperationException($"{input.GetType().FullName} isn't a valid component info class"); + } + + modifyModal?.Invoke(builder); + + return builder.Build(); + } + + public static GuildPermission? SanitizeGuildPermissions(this GuildPermission permissions) => + permissions == 0 ? null : permissions; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs b/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs new file mode 100644 index 0000000..da7ef22 --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/CommandHierarchy.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Interactions +{ + internal static class CommandHierarchy + { + public const char EscapeChar = '$'; + + public static IList GetModulePath(this ModuleInfo moduleInfo) + { + var result = new List(); + + var current = moduleInfo; + while (current is not null) + { + if (current.IsSlashGroup) + result.Insert(0, current.SlashGroupName); + + current = current.Parent; + } + + return result; + } + + public static IList GetCommandPath(this ICommandInfo commandInfo) + { + if (commandInfo.IgnoreGroupNames) + return new List { commandInfo.Name }; + + var path = commandInfo.Module.GetModulePath(); + path.Add(commandInfo.Name); + return path; + } + + public static IList GetParameterPath(this IParameterInfo parameterInfo) + { + var path = parameterInfo.Command.GetCommandPath(); + path.Add(parameterInfo.Name); + return path; + } + + public static IList GetChoicePath(this IParameterInfo parameterInfo, ParameterChoice choice) + { + var path = parameterInfo.GetParameterPath(); + path.Add(choice.Name); + return path; + } + + public static IList GetTypePath(Type type) => + new List { EscapeChar + type.FullName }; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/EmptyServiceProvider.cs b/src/Discord.Net.Interactions/Utilities/EmptyServiceProvider.cs new file mode 100644 index 0000000..172f11c --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/EmptyServiceProvider.cs @@ -0,0 +1,11 @@ +using System; + +namespace Discord.Interactions +{ + internal class EmptyServiceProvider : IServiceProvider + { + public static EmptyServiceProvider Instance => new EmptyServiceProvider(); + + public object GetService(Type serviceType) => null; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/ModalUtils.cs b/src/Discord.Net.Interactions/Utilities/ModalUtils.cs new file mode 100644 index 0000000..e2d028e --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/ModalUtils.cs @@ -0,0 +1,51 @@ +using Discord.Interactions.Builders; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace Discord.Interactions +{ + internal static class ModalUtils + { + private static readonly ConcurrentDictionary _modalInfos = new(); + + public static IReadOnlyCollection Modals => _modalInfos.Values.ToReadOnlyCollection(); + + public static ModalInfo GetOrAdd(Type type, InteractionService interactionService) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + return _modalInfos.GetOrAdd(type, ModuleClassBuilder.BuildModalInfo(type, interactionService)); + } + + public static ModalInfo GetOrAdd(InteractionService interactionService) where T : class, IModal + => GetOrAdd(typeof(T), interactionService); + + public static bool TryGet(Type type, out ModalInfo modalInfo) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + return _modalInfos.TryGetValue(type, out modalInfo); + } + + public static bool TryGet(out ModalInfo modalInfo) where T : class, IModal + => TryGet(typeof(T), out modalInfo); + + public static bool TryRemove(Type type, out ModalInfo modalInfo) + { + if (!typeof(IModal).IsAssignableFrom(type)) + throw new ArgumentException($"Must be an implementation of {nameof(IModal)}", nameof(type)); + + return _modalInfos.TryRemove(type, out modalInfo); + } + + public static bool TryRemove(out ModalInfo modalInfo) where T : class, IModal + => TryRemove(typeof(T), out modalInfo); + + public static void Clear() => _modalInfos.Clear(); + + public static int Count() => _modalInfos.Count; + } +} diff --git a/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs b/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs new file mode 100644 index 0000000..e642495 --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/ReflectionUtils.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + internal static class ReflectionUtils + { + private static readonly TypeInfo ObjectTypeInfo = typeof(object).GetTypeInfo(); + internal static T CreateObject(TypeInfo typeInfo, InteractionService commandService, IServiceProvider services = null) => + CreateBuilder(typeInfo, commandService)(services); + + internal static Func CreateBuilder(TypeInfo typeInfo, InteractionService commandService) + { + var constructor = GetConstructor(typeInfo); + var parameters = constructor.GetParameters(); + var properties = GetProperties(typeInfo); + + return (services) => + { + var args = new object[parameters.Length]; + for (int i = 0; i < parameters.Length; i++) + args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); + + var obj = InvokeConstructor(constructor, args, typeInfo); + foreach (var property in properties) + property.SetValue(obj, GetMember(commandService, services, property.PropertyType, typeInfo)); + return obj; + }; + } + + private static T InvokeConstructor(ConstructorInfo constructor, object[] args, TypeInfo ownerType) + { + try + { + return (T)constructor.Invoke(args); + } + catch (Exception ex) + { + throw new Exception($"Failed to create \"{ownerType.FullName}\".", ex); + } + } + private static ConstructorInfo GetConstructor(TypeInfo ownerType) + { + var constructors = ownerType.DeclaredConstructors.Where(x => !x.IsStatic).ToArray(); + if (constructors.Length == 0) + throw new InvalidOperationException($"No constructor found for \"{ownerType.FullName}\"."); + else if (constructors.Length > 1) + throw new InvalidOperationException($"Multiple constructors found for \"{ownerType.FullName}\"."); + return constructors[0]; + } + private static PropertyInfo[] GetProperties(TypeInfo ownerType) + { + var result = new List(); + while (ownerType != ObjectTypeInfo) + { + foreach (var prop in ownerType.DeclaredProperties) + { + if (prop.SetMethod?.IsStatic == false && prop.SetMethod?.IsPublic == true) + result.Add(prop); + } + ownerType = ownerType.BaseType.GetTypeInfo(); + } + return result.ToArray(); + } + private static object GetMember(InteractionService commandService, IServiceProvider services, Type memberType, TypeInfo ownerType) + { + if (memberType == typeof(InteractionService)) + return commandService; + if (memberType == typeof(IServiceProvider) || memberType == services.GetType()) + return services; + var service = services.GetService(memberType); + if (service != null) + return service; + throw new InvalidOperationException($"Failed to create \"{ownerType.FullName}\", dependency \"{memberType.Name}\" was not found."); + } + + internal static Func CreateMethodInvoker(MethodInfo methodInfo) + { + var parameters = methodInfo.GetParameters(); + var paramsExp = new Expression[parameters.Length]; + + var instanceExp = Expression.Parameter(typeof(T), "instance"); + var argsExp = Expression.Parameter(typeof(object[]), "args"); + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + + var indexExp = Expression.Constant(i); + var accessExp = Expression.ArrayIndex(argsExp, indexExp); + paramsExp[i] = Expression.Convert(accessExp, parameter.ParameterType); + } + + var callExp = Expression.Call(Expression.Convert(instanceExp, methodInfo.ReflectedType), methodInfo, paramsExp); + var finalExp = Expression.Convert(callExp, typeof(Task)); + var lambda = Expression.Lambda>(finalExp, instanceExp, argsExp).Compile(); + + return lambda; + } + + /// + /// Create a type initializer using compiled lambda expressions + /// + internal static Func CreateLambdaBuilder(TypeInfo typeInfo, InteractionService commandService) + { + var constructor = GetConstructor(typeInfo); + var parameters = constructor.GetParameters(); + var properties = GetProperties(typeInfo); + + var lambda = CreateLambdaMemberInit(typeInfo, constructor); + + return (services) => + { + var args = new object[parameters.Length]; + var props = new object[properties.Length]; + + for (int i = 0; i < parameters.Length; i++) + args[i] = GetMember(commandService, services, parameters[i].ParameterType, typeInfo); + + for (int i = 0; i < properties.Length; i++) + props[i] = GetMember(commandService, services, properties[i].PropertyType, typeInfo); + + var instance = lambda(args, props); + + return instance; + }; + } + + internal static Func CreateLambdaConstructorInvoker(TypeInfo typeInfo) + { + var constructor = GetConstructor(typeInfo); + var parameters = constructor.GetParameters(); + + var argsExp = Expression.Parameter(typeof(object[]), "args"); + + var parameterExps = new Expression[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var indexExp = Expression.Constant(i); + var accessExp = Expression.ArrayIndex(argsExp, indexExp); + parameterExps[i] = Expression.Convert(accessExp, parameters[i].ParameterType); + } + + var newExp = Expression.New(constructor, parameterExps); + + return Expression.Lambda>(newExp, argsExp).Compile(); + } + + /// + /// Create a compiled lambda property setter. + /// + internal static Action CreateLambdaPropertySetter(PropertyInfo propertyInfo) + { + var instanceParam = Expression.Parameter(typeof(T), "instance"); + var valueParam = Expression.Parameter(typeof(object), "value"); + + var prop = Expression.Property(instanceParam, propertyInfo); + var assign = Expression.Assign(prop, Expression.Convert(valueParam, propertyInfo.PropertyType)); + + return Expression.Lambda>(assign, instanceParam, valueParam).Compile(); + } + + internal static Func CreateLambdaPropertyGetter(PropertyInfo propertyInfo) + { + var instanceParam = Expression.Parameter(typeof(T), "instance"); + var prop = Expression.Property(instanceParam, propertyInfo); + return Expression.Lambda>(prop, instanceParam).Compile(); + } + + internal static Func CreateLambdaPropertyGetter(Type type, PropertyInfo propertyInfo) + { + var instanceParam = Expression.Parameter(typeof(T), "instance"); + var instanceAccess = Expression.Convert(instanceParam, type); + var prop = Expression.Property(instanceAccess, propertyInfo); + return Expression.Lambda>(prop, instanceParam).Compile(); + } + + internal static Func CreateLambdaMemberInit(TypeInfo typeInfo, ConstructorInfo constructor, Predicate propertySelect = null) + { + propertySelect ??= x => true; + + var parameters = constructor.GetParameters(); + var properties = GetProperties(typeInfo).Where(x => propertySelect(x)).ToArray(); + + var argsExp = Expression.Parameter(typeof(object[]), "args"); + var propsExp = Expression.Parameter(typeof(object[]), "props"); + + var parameterExps = new Expression[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var indexExp = Expression.Constant(i); + var accessExp = Expression.ArrayIndex(argsExp, indexExp); + parameterExps[i] = Expression.Convert(accessExp, parameters[i].ParameterType); + } + + var newExp = Expression.New(constructor, parameterExps); + + var memberExps = new MemberAssignment[properties.Length]; + + for (var i = 0; i < properties.Length; i++) + { + var indexEx = Expression.Constant(i); + var accessExp = Expression.Convert(Expression.ArrayIndex(propsExp, indexEx), properties[i].PropertyType); + memberExps[i] = Expression.Bind(properties[i], accessExp); + } + var memberInit = Expression.MemberInit(newExp, memberExps); + var lambda = Expression.Lambda>(memberInit, argsExp, propsExp).Compile(); + + return (args, props) => + { + var instance = lambda(args, props); + + return instance; + }; + } + } +} diff --git a/src/Discord.Net.Interactions/Utilities/RegexUtils.cs b/src/Discord.Net.Interactions/Utilities/RegexUtils.cs new file mode 100644 index 0000000..e028dd4 --- /dev/null +++ b/src/Discord.Net.Interactions/Utilities/RegexUtils.cs @@ -0,0 +1,177 @@ +using Discord.Interactions; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace System.Text.RegularExpressions +{ + internal static class RegexUtils + { + internal const byte Q = 5; // quantifier + internal const byte S = 4; // ordinary stopper + internal const byte Z = 3; // ScanBlank stopper + internal const byte X = 2; // whitespace + internal const byte E = 1; // should be escaped + + internal static readonly byte[] _category = new byte[] { + // 0 1 2 3 4 5 6 7 8 9 A B C D E F 0 1 2 3 4 5 6 7 8 9 A B C D E F + 0,0,0,0,0,0,0,0,0,X,X,0,X,X,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, + // ! " # $ % & ' ( ) * + , - . / 0 1 2 3 4 5 6 7 8 9 : ; < = > ? + X,0,0,Z,S,0,0,0,S,S,Q,Q,0,0,S,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,Q, + // @ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z [ \ ] ^ _ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,S,S,0,S,0, + // ' a b c d e f g h i j k l m n o p q r s t u v w x y z { | } ~ + 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,Q,S,0,0,0}; + + internal static string EscapeExcluding(string input, params char[] exclude) + { + if (exclude is null) + throw new ArgumentNullException("exclude"); + + for (int i = 0; i < input.Length; i++) + { + if (IsMetachar(input[i]) && !exclude.Contains(input[i])) + { + StringBuilder sb = new StringBuilder(); + char ch = input[i]; + int lastpos; + + sb.Append(input, 0, i); + do + { + sb.Append('\\'); + switch (ch) + { + case '\n': + ch = 'n'; + break; + case '\r': + ch = 'r'; + break; + case '\t': + ch = 't'; + break; + case '\f': + ch = 'f'; + break; + } + sb.Append(ch); + i++; + lastpos = i; + + while (i < input.Length) + { + ch = input[i]; + if (IsMetachar(ch) && !exclude.Contains(input[i])) + break; + + i++; + } + + sb.Append(input, lastpos, i - lastpos); + + } while (i < input.Length); + + return sb.ToString(); + } + } + + return input; + } + + internal static bool IsMetachar(char ch) + { + return (ch <= '|' && _category[ch] >= E); + } + + internal static int GetWildCardCount(string input, string wildCardExpression) + { + var escapedWildCard = Regex.Escape(wildCardExpression); + var match = Regex.Matches(input, $@"(?(T commandInfo, string wildCardStr, out string pattern) where T : class, ICommandInfo + { + if (commandInfo.TreatNameAsRegex) + { + pattern = commandInfo.Name; + return true; + } + + if (GetWildCardCount(commandInfo.Name, wildCardStr) == 0) + { + pattern = null; + return false; + } + + var escapedWildCard = Regex.Escape(wildCardStr); + var unquantifiedPattern = $@"(?[^{escapedWildCard}]?)"; + var quantifiedPattern = $@"(?[0-9]+)(?,[0-9]*)?(?[^{escapedWildCard}]?)"; + + string name = commandInfo.Name; + + var matchPairs = new SortedDictionary(); + + foreach (Match match in Regex.Matches(name, unquantifiedPattern)) + matchPairs.Add(match.Index, new(MatchType.Unquantified, match)); + + foreach (Match match in Regex.Matches(name, quantifiedPattern)) + matchPairs.Add(match.Index, new(MatchType.Quantified, match)); + + var sb = new StringBuilder(); + + var previousMatch = 0; + + foreach (var matchPair in matchPairs) + { + sb.Append(Regex.Escape(name.Substring(previousMatch, matchPair.Key - previousMatch))); + Match match = matchPair.Value.Match; + MatchType type = matchPair.Value.Type; + + previousMatch = matchPair.Key + match.Length; + + var delimiter = match.Groups["delimiter"].Value; + + switch (type) + { + case MatchType.Unquantified: + { + sb.Append(@$"([^\n\t{Regex.Escape(delimiter)}]+){Regex.Escape(delimiter)}"); + } + break; + case MatchType.Quantified: + { + var start = match.Groups["start"].Value; + var end = match.Groups["end"].Value; + + sb.Append($@"([^\n\t{Regex.Escape(delimiter)}]{{{start}{end}}}){Regex.Escape(delimiter)}"); + } + break; + } + } + + pattern = "\\A" + sb.ToString() + "\\Z"; + + return true; + } + + private enum MatchType + { + Quantified, + Unquantified + } + + private record MatchPair + { + public MatchType Type { get; } + public Match Match { get; } + + public MatchPair(MatchType type, Match match) + { + Type = type; + Match = match; + } + } + } +} diff --git a/src/Discord.Net.Providers.WS4Net/Discord.Net.Providers.WS4Net.csproj b/src/Discord.Net.Providers.WS4Net/Discord.Net.Providers.WS4Net.csproj new file mode 100644 index 0000000..e143340 --- /dev/null +++ b/src/Discord.Net.Providers.WS4Net/Discord.Net.Providers.WS4Net.csproj @@ -0,0 +1,15 @@ + + + + Discord.Net.Providers.WS4Net + Discord.Providers.WS4Net + An optional WebSocket client provider for Discord.Net using WebSocket4Net + netstandard2.0 + + + + + + + + diff --git a/src/Discord.Net.Providers.WS4Net/WS4NetClient.cs b/src/Discord.Net.Providers.WS4Net/WS4NetClient.cs new file mode 100644 index 0000000..50f19b7 --- /dev/null +++ b/src/Discord.Net.Providers.WS4Net/WS4NetClient.cs @@ -0,0 +1,178 @@ +using Discord.Net.WebSockets; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using WebSocket4Net; +using WS4NetSocket = WebSocket4Net.WebSocket; + +namespace Discord.Net.Providers.WS4Net +{ + internal class WS4NetClient : IWebSocketClient, IDisposable + { + public event Func BinaryMessage; + public event Func TextMessage; + public event Func Closed; + + private readonly SemaphoreSlim _lock; + private readonly Dictionary _headers; + private WS4NetSocket _client; + private CancellationTokenSource _disconnectCancelTokenSource; + private CancellationTokenSource _cancelTokenSource; + private CancellationToken _cancelToken, _parentToken; + private ManualResetEventSlim _waitUntilConnect; + private bool _isDisposed; + + public WS4NetClient() + { + _headers = new Dictionary(); + _lock = new SemaphoreSlim(1, 1); + _disconnectCancelTokenSource = new CancellationTokenSource(); + _cancelToken = CancellationToken.None; + _parentToken = CancellationToken.None; + _waitUntilConnect = new ManualResetEventSlim(); + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + DisconnectInternalAsync(isDisposing: true).GetAwaiter().GetResult(); + _lock?.Dispose(); + _cancelTokenSource?.Dispose(); + } + _isDisposed = true; + } + } + public void Dispose() + { + Dispose(true); + } + + public async Task ConnectAsync(string host) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(host).ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + private async Task ConnectInternalAsync(string host) + { + await DisconnectInternalAsync().ConfigureAwait(false); + + _disconnectCancelTokenSource?.Dispose(); + _cancelTokenSource?.Dispose(); + _client?.Dispose(); + + _disconnectCancelTokenSource = new CancellationTokenSource(); + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _disconnectCancelTokenSource.Token); + _cancelToken = _cancelTokenSource.Token; + + _client = new WS4NetSocket(host, "", customHeaderItems: _headers.ToList()) + { + EnableAutoSendPing = false, + NoDelay = true, + Proxy = null + }; + + _client.MessageReceived += OnTextMessage; + _client.DataReceived += OnBinaryMessage; + _client.Opened += OnConnected; + _client.Closed += OnClosed; + + _client.Open(); + _waitUntilConnect.Wait(_cancelToken); + } + + public async Task DisconnectAsync(int closeCode = 1000) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(closeCode: closeCode).ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + private Task DisconnectInternalAsync(int closeCode = 1000, bool isDisposing = false) + { + _disconnectCancelTokenSource.Cancel(); + if (_client == null) + return Task.Delay(0); + + if (_client.State == WebSocketState.Open) + { + try { _client.Close(closeCode, ""); } + catch { } + } + + _client.MessageReceived -= OnTextMessage; + _client.DataReceived -= OnBinaryMessage; + _client.Opened -= OnConnected; + _client.Closed -= OnClosed; + + try { _client.Dispose(); } + catch { } + _client = null; + + _waitUntilConnect.Reset(); + return Task.Delay(0); + } + + public void SetHeader(string key, string value) + { + _headers[key] = value; + } + public void SetCancelToken(CancellationToken cancelToken) + { + _cancelTokenSource?.Dispose(); + _parentToken = cancelToken; + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _disconnectCancelTokenSource.Token); + _cancelToken = _cancelTokenSource.Token; + } + + public async Task SendAsync(byte[] data, int index, int count, bool isText) + { + await _lock.WaitAsync(_cancelToken).ConfigureAwait(false); + try + { + if (isText) + _client.Send(Encoding.UTF8.GetString(data, index, count)); + else + _client.Send(data, index, count); + } + finally + { + _lock.Release(); + } + } + + private void OnTextMessage(object sender, MessageReceivedEventArgs e) + { + TextMessage(e.Message).GetAwaiter().GetResult(); + } + private void OnBinaryMessage(object sender, DataReceivedEventArgs e) + { + BinaryMessage(e.Data, 0, e.Data.Length).GetAwaiter().GetResult(); + } + private void OnConnected(object sender, object e) + { + _waitUntilConnect.Set(); + } + private void OnClosed(object sender, object e) + { + var ex = (e as SuperSocket.ClientEngine.ErrorEventArgs)?.Exception ?? new Exception("Unexpected close"); + Closed(ex).GetAwaiter().GetResult(); + } + } +} diff --git a/src/Discord.Net.Providers.WS4Net/WS4NetProvider.cs b/src/Discord.Net.Providers.WS4Net/WS4NetProvider.cs new file mode 100644 index 0000000..b56f3b4 --- /dev/null +++ b/src/Discord.Net.Providers.WS4Net/WS4NetProvider.cs @@ -0,0 +1,9 @@ +using Discord.Net.WebSockets; + +namespace Discord.Net.Providers.WS4Net +{ + public static class WS4NetProvider + { + public static readonly WebSocketProvider Instance = () => new WS4NetClient(); + } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/AutoModRuleInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/AutoModRuleInfoAuditLogModel.cs new file mode 100644 index 0000000..452ff07 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/AutoModRuleInfoAuditLogModel.cs @@ -0,0 +1,30 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class AutoModRuleInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("name")] + public string Name { get; set; } + + [JsonField("event_type")] + public AutoModEventType EventType { get; set; } + + [JsonField("trigger_type")] + public AutoModTriggerType TriggerType { get; set; } + + [JsonField("trigger_metadata")] + public TriggerMetadata TriggerMetadata { get; set; } + + [JsonField("actions")] + public AutoModAction[] Actions { get; set; } + + [JsonField("enabled")] + public bool Enabled { get; set; } + + [JsonField("exempt_roles")] + public ulong[] ExemptRoles { get; set; } + + [JsonField("exempt_channels")] + public ulong[] ExemptChannels { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/ChannelInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/ChannelInfoAuditLogModel.cs new file mode 100644 index 0000000..75f0471 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/ChannelInfoAuditLogModel.cs @@ -0,0 +1,57 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class ChannelInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("name")] + public string Name { get; set; } + + [JsonField("type")] + public ChannelType? Type { get; set; } + + [JsonField("permission_overwrites")] + public Overwrite[] Overwrites { get; set; } + + [JsonField("flags")] + public ChannelFlags? Flags { get; set; } + + [JsonField("default_thread_rate_limit_per_user")] + public int? DefaultThreadRateLimitPerUser { get; set; } + + [JsonField("default_auto_archive_duration")] + public ThreadArchiveDuration? DefaultArchiveDuration { get; set; } + + [JsonField("rate_limit_per_user")] + public int? RateLimitPerUser { get; set; } + + [JsonField("auto_archive_duration")] + public ThreadArchiveDuration? AutoArchiveDuration { get; set; } + + [JsonField("nsfw")] + public bool? IsNsfw { get; set; } + + [JsonField("topic")] + public string Topic { get; set; } + + // Forum channels + [JsonField("available_tags")] + public ForumTag[] AvailableTags { get; set; } + + [JsonField("default_reaction_emoji")] + public ForumReactionEmoji DefaultEmoji { get; set; } + + // Voice channels + + [JsonField("user_limit")] + public int? UserLimit { get; set; } + + [JsonField("rtc_region")] + public string Region { get; set; } + + [JsonField("video_quality_mode")] + public VideoQualityMode? VideoQualityMode { get; set; } + + [JsonField("bitrate")] + public int? Bitrate { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/GuildInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/GuildInfoAuditLogModel.cs new file mode 100644 index 0000000..1ecc30c --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/GuildInfoAuditLogModel.cs @@ -0,0 +1,82 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class GuildInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("name")] + public string Name { get; set; } + + [JsonField("afk_timeout")] + public int? AfkTimeout { get; set; } + + [JsonField("widget_enabled")] + public bool? IsEmbeddable { get; set; } + + [JsonField("default_message_notifications")] + public DefaultMessageNotifications? DefaultMessageNotifications { get; set; } + + [JsonField("mfa_level")] + public MfaLevel? MfaLevel { get; set; } + + [JsonField("verification_level")] + public VerificationLevel? VerificationLevel { get; set; } + + [JsonField("explicit_content_filter")] + public ExplicitContentFilterLevel? ExplicitContentFilterLevel { get; set; } + + [JsonField("icon_hash")] + public string IconHash { get; set; } + + [JsonField("discovery_splash")] + public string DiscoverySplash { get; set; } + + [JsonField("splash")] + public string Splash { get; set; } + + [JsonField("afk_channel_id")] + public ulong? AfkChannelId { get; set; } + + [JsonField("widget_channel_id")] + public ulong? EmbeddedChannelId { get; set; } + + [JsonField("system_channel_id")] + public ulong? SystemChannelId { get; set; } + + [JsonField("rules_channel_id")] + public ulong? RulesChannelId { get; set; } + + [JsonField("public_updates_channel_id")] + public ulong? PublicUpdatesChannelId { get; set; } + + [JsonField("owner_id")] + public ulong? OwnerId { get; set; } + + [JsonField("application_id")] + public ulong? ApplicationId { get; set; } + + [JsonField("region")] + public string RegionId { get; set; } + + [JsonField("banner")] + public string Banner { get; set; } + + [JsonField("vanity_url_code")] + public string VanityUrl { get; set; } + + [JsonField("system_channel_flags")] + public SystemChannelMessageDeny? SystemChannelFlags { get; set; } + + [JsonField("description")] + public string Description { get; set; } + + [JsonField("preferred_locale")] + public string PreferredLocale { get; set; } + + [JsonField("nsfw_level")] + public NsfwLevel? NsfwLevel { get; set; } + + [JsonField("premium_progress_bar_enabled")] + public bool? ProgressBarEnabled { get; set; } + +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/IntegrationInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/IntegrationInfoAuditLogModel.cs new file mode 100644 index 0000000..f63ab96 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/IntegrationInfoAuditLogModel.cs @@ -0,0 +1,33 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class IntegrationInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("name")] + public string Name { get; set; } + + [JsonField("type")] + public string Type { get; set; } + + [JsonField("enabled")] + public bool? Enabled { get; set; } + + [JsonField("syncing")] + public bool? Syncing { get; set; } + + [JsonField("role_id")] + public ulong? RoleId { get; set; } + + [JsonField("enable_emoticons")] + public bool? EnableEmojis { get; set; } + + [JsonField("expire_behavior")] + public IntegrationExpireBehavior? ExpireBehavior { get; set; } + + [JsonField("expire_grace_period")] + public int? ExpireGracePeriod { get; set; } + + [JsonField("scopes")] + public string[] Scopes { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/InviteInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/InviteInfoAuditLogModel.cs new file mode 100644 index 0000000..836d4ed --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/InviteInfoAuditLogModel.cs @@ -0,0 +1,27 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class InviteInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("code")] + public string Code { get; set; } + + [JsonField("channel_id")] + public ulong? ChannelId { get; set; } + + [JsonField("inviter_id")] + public ulong? InviterId { get; set; } + + [JsonField("uses")] + public int? Uses { get; set; } + + [JsonField("max_uses")] + public int? MaxUses { get; set; } + + [JsonField("max_age")] + public int? MaxAge { get; set; } + + [JsonField("temporary")] + public bool? Temporary { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/MemberInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/MemberInfoAuditLogModel.cs new file mode 100644 index 0000000..f81cea2 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/MemberInfoAuditLogModel.cs @@ -0,0 +1,19 @@ +using Discord.Rest; +using System; + +namespace Discord.API.AuditLogs; + +internal class MemberInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("nick")] + public string Nickname { get; set; } + + [JsonField("mute")] + public bool? IsMuted { get; set; } + + [JsonField("deaf")] + public bool? IsDeafened { get; set; } + + [JsonField("communication_disabled_until")] + public DateTimeOffset? TimeOutUntil { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/OnboardingAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/OnboardingAuditLogModel.cs new file mode 100644 index 0000000..5b36734 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/OnboardingAuditLogModel.cs @@ -0,0 +1,15 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class OnboardingAuditLogModel : IAuditLogInfoModel +{ + [JsonField("default_channel_ids")] + public ulong[] DefaultChannelIds { get; set; } + + [JsonField("prompts")] + public GuildOnboardingPrompt[] Prompts { get; set; } + + [JsonField("enabled")] + public bool? Enabled { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/OnboardingPromptAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/OnboardingPromptAuditLogModel.cs new file mode 100644 index 0000000..31eea7f --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/OnboardingPromptAuditLogModel.cs @@ -0,0 +1,27 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class OnboardingPromptAuditLogModel : IAuditLogInfoModel +{ + [JsonField("id")] + public ulong? Id { get; set; } + + [JsonField("title")] + public string Title { get; set; } + + [JsonField("options")] + public GuildOnboardingPromptOption[] Options { get; set; } + + [JsonField("single_select")] + public bool? IsSingleSelect { get; set; } + + [JsonField("required")] + public bool? IsRequired { get; set; } + + [JsonField("in_onboarding")] + public bool? IsInOnboarding { get; set; } + + [JsonField("type")] + public GuildOnboardingPromptType? Type { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/RoleInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/RoleInfoAuditLogModel.cs new file mode 100644 index 0000000..351a4ee --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/RoleInfoAuditLogModel.cs @@ -0,0 +1,24 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class RoleInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("name")] + public string Name { get; set; } + + [JsonField("color")] + public uint? Color { get; set; } + + [JsonField("hoist")] + public bool? Hoist { get; set; } + + [JsonField("permissions")] + public ulong? Permissions { get; set; } + + [JsonField("mentionable")] + public bool? IsMentionable { get; set; } + + [JsonField("icon_hash")] + public string IconHash { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/ScheduledEventInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/ScheduledEventInfoAuditLogModel.cs new file mode 100644 index 0000000..a8f2762 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/ScheduledEventInfoAuditLogModel.cs @@ -0,0 +1,43 @@ +using Discord.Rest; +using System; + +namespace Discord.API.AuditLogs; + +internal class ScheduledEventInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("channel_id")] + public ulong? ChannelId { get; set; } + + [JsonField("name")] + public string Name { get; set; } + + [JsonField("description")] + public string Description { get; set; } + + [JsonField("scheduled_start_time")] + public DateTimeOffset? StartTime { get; set; } + + [JsonField("scheduled_end_time")] + public DateTimeOffset? EndTime { get; set; } + + [JsonField("privacy_level")] + public GuildScheduledEventPrivacyLevel? PrivacyLevel { get; set; } + + [JsonField("status")] + public GuildScheduledEventStatus? EventStatus { get; set; } + + [JsonField("entity_type")] + public GuildScheduledEventType? EventType { get; set; } + + [JsonField("entity_id")] + public ulong? EntityId { get; set; } + + [JsonField("user_count")] + public int? UserCount { get; set; } + + [JsonField("location")] + public string Location { get; set; } + + [JsonField("image")] + public string Image { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/StickerInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/StickerInfoAuditLogModel.cs new file mode 100644 index 0000000..3868674 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/StickerInfoAuditLogModel.cs @@ -0,0 +1,15 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class StickerInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("name")] + public string Name { get; set; } + + [JsonField("tags")] + public string Tags { get; set; } + + [JsonField("description")] + public string Description { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/ThreadInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/ThreadInfoAuditLogModel.cs new file mode 100644 index 0000000..5215d58 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/ThreadInfoAuditLogModel.cs @@ -0,0 +1,30 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class ThreadInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("name")] + public string Name { get; set; } + + [JsonField("type")] + public ThreadType Type { get; set; } + + [JsonField("archived")] + public bool? IsArchived { get; set; } + + [JsonField("locked")] + public bool? IsLocked { get; set;} + + [JsonField("auto_archive_duration")] + public ThreadArchiveDuration? ArchiveDuration { get; set; } + + [JsonField("rate_limit_per_user")] + public int? SlowModeInterval { get; set; } + + [JsonField("flags")] + public ChannelFlags? ChannelFlags { get; set; } + + [JsonField("applied_tags")] + public ulong[] AppliedTags { get; set; } +} diff --git a/src/Discord.Net.Rest/API/AuditLogs/WebhookInfoAuditLogModel.cs b/src/Discord.Net.Rest/API/AuditLogs/WebhookInfoAuditLogModel.cs new file mode 100644 index 0000000..e8d8a19 --- /dev/null +++ b/src/Discord.Net.Rest/API/AuditLogs/WebhookInfoAuditLogModel.cs @@ -0,0 +1,18 @@ +using Discord.Rest; + +namespace Discord.API.AuditLogs; + +internal class WebhookInfoAuditLogModel : IAuditLogInfoModel +{ + [JsonField("channel_id")] + public ulong? ChannelId { get; set; } + + [JsonField("name")] + public string Name { get; set; } + + [JsonField("type")] + public WebhookType? Type { get; set; } + + [JsonField("avatar_hash")] + public string AvatarHash { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/ActionMetadata.cs b/src/Discord.Net.Rest/API/Common/ActionMetadata.cs new file mode 100644 index 0000000..93b85d0 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ActionMetadata.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class ActionMetadata + { + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + + [JsonProperty("duration_seconds")] + public Optional DurationSeconds { get; set; } + + [JsonProperty("custom_message")] + public Optional CustomMessage { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ActionRowComponent.cs b/src/Discord.Net.Rest/API/Common/ActionRowComponent.cs new file mode 100644 index 0000000..e97ca71 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ActionRowComponent.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using System.Linq; + +namespace Discord.API +{ + internal class ActionRowComponent : IMessageComponent + { + [JsonProperty("type")] + public ComponentType Type { get; set; } + + [JsonProperty("components")] + public IMessageComponent[] Components { get; set; } + + internal ActionRowComponent() { } + internal ActionRowComponent(Discord.ActionRowComponent c) + { + Type = c.Type; + Components = c.Components?.Select(x => + { + return x.Type switch + { + ComponentType.Button => new ButtonComponent(x as Discord.ButtonComponent), + ComponentType.SelectMenu => new SelectMenuComponent(x as Discord.SelectMenuComponent), + ComponentType.ChannelSelect => new SelectMenuComponent(x as Discord.SelectMenuComponent), + ComponentType.UserSelect => new SelectMenuComponent(x as Discord.SelectMenuComponent), + ComponentType.RoleSelect => new SelectMenuComponent(x as Discord.SelectMenuComponent), + ComponentType.MentionableSelect => new SelectMenuComponent(x as Discord.SelectMenuComponent), + ComponentType.TextInput => new TextInputComponent(x as Discord.TextInputComponent), + _ => null + }; + }).ToArray(); + } + + [JsonIgnore] + string IMessageComponent.CustomId => null; + } +} diff --git a/src/Discord.Net.Rest/API/Common/AllowedMentions.cs b/src/Discord.Net.Rest/API/Common/AllowedMentions.cs new file mode 100644 index 0000000..7737a46 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AllowedMentions.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class AllowedMentions + { + [JsonProperty("parse")] + public Optional Parse { get; set; } + // Roles and Users have a max size of 100 + [JsonProperty("roles")] + public Optional Roles { get; set; } + [JsonProperty("users")] + public Optional Users { get; set; } + [JsonProperty("replied_user")] + public Optional RepliedUser { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Application.cs b/src/Discord.Net.Rest/API/Common/Application.cs new file mode 100644 index 0000000..70c7625 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Application.cs @@ -0,0 +1,104 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API; + +internal class Application +{ + [JsonProperty("description")] + public string Description { get; set; } + [JsonProperty("rpc_origins")] + public Optional RPCOrigins { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("icon")] + public string Icon { get; set; } + + [JsonProperty("bot_public")] + public Optional IsBotPublic { get; set; } + [JsonProperty("bot_require_code_grant")] + public Optional BotRequiresCodeGrant { get; set; } + + [JsonProperty("install_params")] + public Optional InstallParams { get; set; } + [JsonProperty("team")] + public Team Team { get; set; } + [JsonProperty("flags"), Int53] + public Optional Flags { get; set; } + [JsonProperty("owner")] + public Optional Owner { get; set; } + [JsonProperty("tags")] + public Optional Tags { get; set; } + + [JsonProperty("verify_key")] + public string VerifyKey { get; set; } + + [JsonProperty("approximate_guild_count")] + public Optional ApproximateGuildCount { get; set; } + + [JsonProperty("guild")] + public Optional PartialGuild { get; set; } + + /// Urls + [JsonProperty("terms_of_service_url")] + public string TermsOfService { get; set; } + + [JsonProperty("privacy_policy_url")] + public string PrivacyPolicy { get; set; } + + [JsonProperty("custom_install_url")] + public Optional CustomInstallUrl { get; set; } + + [JsonProperty("role_connections_verification_url")] + public Optional RoleConnectionsUrl { get; set; } + + [JsonProperty("interactions_endpoint_url")] + public Optional InteractionsEndpointUrl { get; set; } + + [JsonProperty("redirect_uris")] + public Optional RedirectUris { get; set; } + + [JsonProperty("discoverability_state")] + public Optional DiscoverabilityState { get; set; } + + [JsonProperty("discovery_eligibility_flags")] + public Optional DiscoveryEligibilityFlags { get; set; } + + [JsonProperty("explicit_content_filter")] + public Optional ExplicitContentFilter { get; set; } + + [JsonProperty("hook")] + public bool IsHook { get; set; } + + [JsonProperty("interactions_event_types")] + public Optional InteractionsEventTypes { get; set; } + + [JsonProperty("interactions_version")] + public Optional InteractionsVersion { get; set; } + + [JsonProperty("is_monetized")] + public bool IsMonetized { get; set; } + + [JsonProperty("monetization_eligibility_flags")] + public Optional MonetizationEligibilityFlags { get; set; } + + [JsonProperty("monetization_state")] + public Optional MonetizationState { get; set; } + + [JsonProperty("rpc_application_state")] + public Optional RpcState { get; set; } + + [JsonProperty("store_application_state")] + public Optional StoreState { get; set; } + + [JsonProperty("verification_state")] + public Optional VerificationState { get; set; } + + [JsonProperty("integration_types")] + public Optional IntegrationTypes { get; set; } + + [JsonProperty("integration_types_config")] + public Optional> IntegrationTypesConfig { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommand.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommand.cs new file mode 100644 index 0000000..24a1e36 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommand.cs @@ -0,0 +1,60 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API +{ + internal class ApplicationCommand + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public ApplicationCommandType Type { get; set; } = ApplicationCommandType.Slash; // defaults to 1 which is slash. + + [JsonProperty("application_id")] + public ulong ApplicationId { get; set; } + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("options")] + public Optional Options { get; set; } + + [JsonProperty("default_permission")] + public Optional DefaultPermissions { get; set; } + + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } + + [JsonProperty("name_localized")] + public Optional NameLocalized { get; set; } + + [JsonProperty("description_localized")] + public Optional DescriptionLocalized { get; set; } + + // V2 Permissions + [JsonProperty("dm_permission")] + public Optional DmPermission { get; set; } + + [JsonProperty("default_member_permissions")] + public Optional DefaultMemberPermission { get; set; } + + [JsonProperty("nsfw")] + public Optional Nsfw { get; set; } + + [JsonProperty("contexts")] + public Optional ContextTypes { get; set; } + + [JsonProperty("integration_types")] + public Optional IntegrationTypes { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionData.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionData.cs new file mode 100644 index 0000000..a98ed77 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionData.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class ApplicationCommandInteractionData : IResolvable, IDiscordInteractionData + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("options")] + public Optional Options { get; set; } + + [JsonProperty("resolved")] + public Optional Resolved { get; set; } + + [JsonProperty("type")] + public ApplicationCommandType Type { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionDataOption.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionDataOption.cs new file mode 100644 index 0000000..1e488c4 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionDataOption.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class ApplicationCommandInteractionDataOption + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public ApplicationCommandOptionType Type { get; set; } + + [JsonProperty("value")] + public Optional Value { get; set; } + + [JsonProperty("options")] + public Optional Options { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionDataResolved.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionDataResolved.cs new file mode 100644 index 0000000..690be6c --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandInteractionDataResolved.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API +{ + internal class ApplicationCommandInteractionDataResolved + { + [JsonProperty("users")] + public Optional> Users { get; set; } + + [JsonProperty("members")] + public Optional> Members { get; set; } + + [JsonProperty("channels")] + public Optional> Channels { get; set; } + + [JsonProperty("roles")] + public Optional> Roles { get; set; } + [JsonProperty("messages")] + public Optional> Messages { get; set; } + [JsonProperty("attachments")] + public Optional> Attachments { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs new file mode 100644 index 0000000..ae0c321 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandOption.cs @@ -0,0 +1,121 @@ +using Newtonsoft.Json; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.API +{ + internal class ApplicationCommandOption + { + [JsonProperty("type")] + public ApplicationCommandOptionType Type { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("default")] + public Optional Default { get; set; } + + [JsonProperty("required")] + public Optional Required { get; set; } + + [JsonProperty("choices")] + public Optional Choices { get; set; } + + [JsonProperty("options")] + public Optional Options { get; set; } + + [JsonProperty("autocomplete")] + public Optional Autocomplete { get; set; } + + [JsonProperty("min_value")] + public Optional MinValue { get; set; } + + [JsonProperty("max_value")] + public Optional MaxValue { get; set; } + + [JsonProperty("channel_types")] + public Optional ChannelTypes { get; set; } + + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } + + [JsonProperty("name_localized")] + public Optional NameLocalized { get; set; } + + [JsonProperty("description_localized")] + public Optional DescriptionLocalized { get; set; } + + [JsonProperty("min_length")] + public Optional MinLength { get; set; } + + [JsonProperty("max_length")] + public Optional MaxLength { get; set; } + + public ApplicationCommandOption() { } + + public ApplicationCommandOption(IApplicationCommandOption cmd) + { + Choices = cmd.Choices.Select(x => new ApplicationCommandOptionChoice + { + Name = x.Name, + Value = x.Value + }).ToArray(); + + Options = cmd.Options.Select(x => new ApplicationCommandOption(x)).ToArray(); + + ChannelTypes = cmd.ChannelTypes.ToArray(); + + Required = cmd.IsRequired ?? Optional.Unspecified; + Default = cmd.IsDefault ?? Optional.Unspecified; + MinValue = cmd.MinValue ?? Optional.Unspecified; + MaxValue = cmd.MaxValue ?? Optional.Unspecified; + MinLength = cmd.MinLength ?? Optional.Unspecified; + MaxLength = cmd.MaxLength ?? Optional.Unspecified; + Autocomplete = cmd.IsAutocomplete ?? Optional.Unspecified; + + Name = cmd.Name; + Type = cmd.Type; + Description = cmd.Description; + + NameLocalizations = cmd.NameLocalizations?.ToDictionary() ?? Optional>.Unspecified; + DescriptionLocalizations = cmd.DescriptionLocalizations?.ToDictionary() ?? Optional>.Unspecified; + NameLocalized = cmd.NameLocalized; + DescriptionLocalized = cmd.DescriptionLocalized; + } + public ApplicationCommandOption(ApplicationCommandOptionProperties option) + { + Choices = option.Choices?.Select(x => new ApplicationCommandOptionChoice + { + Name = x.Name, + Value = x.Value, + NameLocalizations = x.NameLocalizations?.ToDictionary() ?? Optional>.Unspecified, + }).ToArray() ?? Optional.Unspecified; + + Options = option.Options?.Select(x => new ApplicationCommandOption(x)).ToArray() ?? Optional.Unspecified; + + Required = option.IsRequired ?? Optional.Unspecified; + + Default = option.IsDefault ?? Optional.Unspecified; + MinValue = option.MinValue ?? Optional.Unspecified; + MaxValue = option.MaxValue ?? Optional.Unspecified; + MinLength = option.MinLength ?? Optional.Unspecified; + MaxLength = option.MaxLength ?? Optional.Unspecified; + + ChannelTypes = option.ChannelTypes?.ToArray() ?? Optional.Unspecified; + + Name = option.Name; + Type = option.Type; + Description = option.Description; + Autocomplete = option.IsAutocomplete; + + NameLocalizations = option.NameLocalizations?.ToDictionary() ?? Optional>.Unspecified; + DescriptionLocalizations = option.DescriptionLocalizations?.ToDictionary() ?? Optional>.Unspecified; + } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs new file mode 100644 index 0000000..966405c --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandOptionChoice.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API +{ + internal class ApplicationCommandOptionChoice + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("value")] + public object Value { get; set; } + + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("name_localized")] + public Optional NameLocalized { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ApplicationCommandPermissions.cs b/src/Discord.Net.Rest/API/Common/ApplicationCommandPermissions.cs new file mode 100644 index 0000000..8bde80f --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ApplicationCommandPermissions.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class ApplicationCommandPermissions + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public ApplicationCommandPermissionTarget Type { get; set; } + + [JsonProperty("permission")] + public bool Permission { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Attachment.cs b/src/Discord.Net.Rest/API/Common/Attachment.cs new file mode 100644 index 0000000..b8f7783 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Attachment.cs @@ -0,0 +1,55 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API; + +internal class Attachment +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("filename")] + public string Filename { get; set; } + + [JsonProperty("description")] + public Optional Description { get; set; } + + [JsonProperty("content_type")] + public Optional ContentType { get; set; } + + [JsonProperty("size")] + public int Size { get; set; } + + [JsonProperty("url")] + public string Url { get; set; } + + [JsonProperty("proxy_url")] + public string ProxyUrl { get; set; } + + [JsonProperty("height")] + public Optional Height { get; set; } + + [JsonProperty("width")] + public Optional Width { get; set; } + + [JsonProperty("ephemeral")] + public Optional Ephemeral { get; set; } + + [JsonProperty("duration_secs")] + public Optional DurationSeconds { get; set; } + + [JsonProperty("waveform")] + public Optional Waveform { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + + [JsonProperty("title")] + public Optional Title { get; set; } + + [JsonProperty("clip_created_at")] + public Optional ClipCreatedAt { get; set; } + + [JsonProperty("clip_participants")] + public Optional ClipParticipants { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/AuditLog.cs b/src/Discord.Net.Rest/API/Common/AuditLog.cs new file mode 100644 index 0000000..f6264ef --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLog.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class AuditLog + { + [JsonProperty("webhooks")] + public Webhook[] Webhooks { get; set; } + + [JsonProperty("threads")] + public Channel[] Threads { get; set; } + + [JsonProperty("integrations")] + public Integration[] Integrations { get; set; } + + [JsonProperty("users")] + public User[] Users { get; set; } + + [JsonProperty("audit_log_entries")] + public AuditLogEntry[] Entries { get; set; } + + [JsonProperty("application_commands")] + public ApplicationCommand[] Commands { get; set; } + + [JsonProperty("auto_moderation_rules")] + public AutoModerationRule[] AutoModerationRules { get; set;} + + [JsonProperty("guild_scheduled_events")] + public GuildScheduledEvent[] GuildScheduledEvents { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AuditLogChange.cs b/src/Discord.Net.Rest/API/Common/AuditLogChange.cs new file mode 100644 index 0000000..c2efcc7 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLogChange.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Discord.API +{ + internal class AuditLogChange + { + [JsonProperty("key")] + public string ChangedProperty { get; set; } + + [JsonProperty("new_value")] + public JToken NewValue { get; set; } + + [JsonProperty("old_value")] + public JToken OldValue { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AuditLogEntry.cs b/src/Discord.Net.Rest/API/Common/AuditLogEntry.cs new file mode 100644 index 0000000..9626ad6 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLogEntry.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class AuditLogEntry + { + [JsonProperty("target_id")] + public ulong? TargetId { get; set; } + [JsonProperty("user_id")] + public ulong? UserId { get; set; } + + [JsonProperty("changes")] + public AuditLogChange[] Changes { get; set; } + [JsonProperty("options")] + public AuditLogOptions Options { get; set; } + + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("action_type")] + public ActionType Action { get; set; } + + [JsonProperty("reason")] + public string Reason { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AuditLogOptions.cs b/src/Discord.Net.Rest/API/Common/AuditLogOptions.cs new file mode 100644 index 0000000..41292ca --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AuditLogOptions.cs @@ -0,0 +1,45 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class AuditLogOptions +{ + [JsonProperty("count")] + public int? Count { get; set; } + [JsonProperty("channel_id")] + public ulong? ChannelId { get; set; } + [JsonProperty("message_id")] + public ulong? MessageId { get; set; } + + //Prune + [JsonProperty("delete_member_days")] + public int? PruneDeleteMemberDays { get; set; } + [JsonProperty("members_removed")] + public int? PruneMembersRemoved { get; set; } + + //Overwrite Update + [JsonProperty("role_name")] + public string OverwriteRoleName { get; set; } + [JsonProperty("type")] + public PermissionTarget OverwriteType { get; set; } + [JsonProperty("id")] + public ulong? OverwriteTargetId { get; set; } + + // App command perm update + [JsonProperty("application_id")] + public ulong? ApplicationId { get; set; } + + // Automod + + [JsonProperty("auto_moderation_rule_name")] + public string AutoModRuleName { get; set; } + + [JsonProperty("auto_moderation_rule_trigger_type")] + public AutoModTriggerType? AutoModRuleTriggerType { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } + + [JsonProperty("integration_type")] + public string IntegrationType { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/AutoModAction.cs b/src/Discord.Net.Rest/API/Common/AutoModAction.cs new file mode 100644 index 0000000..a6fec3c --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AutoModAction.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class AutoModAction + { + [JsonProperty("type")] + public AutoModActionType Type { get; set; } + + [JsonProperty("metadata")] + public Optional Metadata { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AutoModerationRule.cs b/src/Discord.Net.Rest/API/Common/AutoModerationRule.cs new file mode 100644 index 0000000..f30af5b --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AutoModerationRule.cs @@ -0,0 +1,45 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class AutoModerationRule + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("creator_id")] + public ulong CreatorId { get; set; } + + [JsonProperty("event_type")] + public AutoModEventType EventType { get; set; } + + [JsonProperty("trigger_type")] + public AutoModTriggerType TriggerType { get; set; } + + [JsonProperty("trigger_metadata")] + public TriggerMetadata TriggerMetadata { get; set; } + + [JsonProperty("actions")] + public AutoModAction[] Actions { get; set; } + + [JsonProperty("enabled")] + public bool Enabled { get; set; } + + [JsonProperty("exempt_roles")] + public ulong[] ExemptRoles { get; set; } + + [JsonProperty("exempt_channels")] + public ulong[] ExemptChannels { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AutocompleteInteractionData.cs b/src/Discord.Net.Rest/API/Common/AutocompleteInteractionData.cs new file mode 100644 index 0000000..2184a0e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AutocompleteInteractionData.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class AutocompleteInteractionData : IDiscordInteractionData + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public ApplicationCommandType Type { get; set; } + + [JsonProperty("version")] + public ulong Version { get; set; } + + [JsonProperty("options")] + public AutocompleteInteractionDataOption[] Options { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AutocompleteInteractionDataOption.cs b/src/Discord.Net.Rest/API/Common/AutocompleteInteractionDataOption.cs new file mode 100644 index 0000000..1419f93 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AutocompleteInteractionDataOption.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class AutocompleteInteractionDataOption + { + [JsonProperty("type")] + public ApplicationCommandOptionType Type { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("options")] + public Optional Options { get; set; } + + [JsonProperty("value")] + public Optional Value { get; set; } + + [JsonProperty("focused")] + public Optional Focused { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/AvatarDecorationData.cs b/src/Discord.Net.Rest/API/Common/AvatarDecorationData.cs new file mode 100644 index 0000000..7bfe590 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/AvatarDecorationData.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class AvatarDecorationData +{ + [JsonProperty("asset")] + public string Asset { get; set; } + + [JsonProperty("sku_id")] + public ulong SkuId { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/Ban.cs b/src/Discord.Net.Rest/API/Common/Ban.cs new file mode 100644 index 0000000..ff47c79 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Ban.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Ban + { + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("reason")] + public string Reason { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/BulkBanResult.cs b/src/Discord.Net.Rest/API/Common/BulkBanResult.cs new file mode 100644 index 0000000..51e387e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/BulkBanResult.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class BulkBanResult +{ + [JsonProperty("banned_users")] + public ulong[] BannedUsers { get; set; } + + [JsonProperty("failed_users")] + public ulong[] FailedUsers { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/ButtonComponent.cs b/src/Discord.Net.Rest/API/Common/ButtonComponent.cs new file mode 100644 index 0000000..7f737d7 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ButtonComponent.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class ButtonComponent : IMessageComponent + { + [JsonProperty("type")] + public ComponentType Type { get; set; } + + [JsonProperty("style")] + public ButtonStyle Style { get; set; } + + [JsonProperty("label")] + public Optional Label { get; set; } + + [JsonProperty("emoji")] + public Optional Emote { get; set; } + + [JsonProperty("custom_id")] + public Optional CustomId { get; set; } + + [JsonProperty("url")] + public Optional Url { get; set; } + + [JsonProperty("disabled")] + public Optional Disabled { get; set; } + + public ButtonComponent() { } + + public ButtonComponent(Discord.ButtonComponent c) + { + Type = c.Type; + Style = c.Style; + Label = c.Label; + CustomId = c.CustomId; + Url = c.Url; + Disabled = c.IsDisabled; + + if (c.Emote != null) + { + if (c.Emote is Emote e) + { + Emote = new Emoji + { + Name = e.Name, + Animated = e.Animated, + Id = e.Id + }; + } + else + { + Emote = new Emoji + { + Name = c.Emote.Name + }; + } + } + } + + [JsonIgnore] + string IMessageComponent.CustomId => CustomId.GetValueOrDefault(); + } +} diff --git a/src/Discord.Net.Rest/API/Common/Channel.cs b/src/Discord.Net.Rest/API/Common/Channel.cs new file mode 100644 index 0000000..2bc2676 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Channel.cs @@ -0,0 +1,102 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class Channel + { + //Shared + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("type")] + public ChannelType Type { get; set; } + [JsonProperty("last_message_id")] + public ulong? LastMessageId { get; set; } + + //GuildChannel + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("position")] + public Optional Position { get; set; } + [JsonProperty("permission_overwrites")] + public Optional PermissionOverwrites { get; set; } + [JsonProperty("parent_id")] + public ulong? CategoryId { get; set; } + + //TextChannel + [JsonProperty("topic")] + public Optional Topic { get; set; } + [JsonProperty("last_pin_timestamp")] + public Optional LastPinTimestamp { get; set; } + [JsonProperty("nsfw")] + public Optional Nsfw { get; set; } + [JsonProperty("rate_limit_per_user")] + public Optional SlowMode { get; set; } + + //VoiceChannel + [JsonProperty("bitrate")] + public Optional Bitrate { get; set; } + [JsonProperty("user_limit")] + public Optional UserLimit { get; set; } + [JsonProperty("rtc_region")] + public Optional RTCRegion { get; set; } + + [JsonProperty("video_quality_mode")] + public Optional VideoQualityMode { get; set; } + + [JsonProperty("status")] + public Optional Status { get; set; } + + //PrivateChannel + [JsonProperty("recipients")] + public Optional Recipients { get; set; } + + //GroupChannel + [JsonProperty("icon")] + public Optional Icon { get; set; } + + //ThreadChannel + [JsonProperty("member")] + public Optional ThreadMember { get; set; } + + [JsonProperty("thread_metadata")] + public Optional ThreadMetadata { get; set; } + + [JsonProperty("owner_id")] + public Optional OwnerId { get; set; } + + [JsonProperty("message_count")] + public Optional MessageCount { get; set; } + + [JsonProperty("member_count")] + public Optional MemberCount { get; set; } + + //ForumChannel + [JsonProperty("available_tags")] + public Optional ForumTags { get; set; } + + [JsonProperty("applied_tags")] + public Optional AppliedTags { get; set; } + + [JsonProperty("default_auto_archive_duration")] + public Optional AutoArchiveDuration { get; set; } + + [JsonProperty("default_thread_rate_limit_per_user")] + public Optional ThreadRateLimitPerUser { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + + [JsonProperty("default_sort_order")] + public Optional DefaultSortOrder { get; set; } + + [JsonProperty("default_reaction_emoji")] + public Optional DefaultReactionEmoji { get; set; } + + [JsonProperty("default_forum_layout")] + public Optional DefaultForumLayout { get; set; } + + } +} diff --git a/src/Discord.Net.Rest/API/Common/ChannelThreads.cs b/src/Discord.Net.Rest/API/Common/ChannelThreads.cs new file mode 100644 index 0000000..9fa3e38 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ChannelThreads.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class ChannelThreads + { + [JsonProperty("threads")] + public Channel[] Threads { get; set; } + + [JsonProperty("members")] + public ThreadMember[] Members { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Connection.cs b/src/Discord.Net.Rest/API/Common/Connection.cs new file mode 100644 index 0000000..0a9940e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Connection.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API +{ + internal class Connection + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("type")] + public string Type { get; set; } + [JsonProperty("revoked")] + public Optional Revoked { get; set; } + [JsonProperty("integrations")] + public Optional> Integrations { get; set; } + [JsonProperty("verified")] + public bool Verified { get; set; } + [JsonProperty("friend_sync")] + public bool FriendSync { get; set; } + [JsonProperty("show_activity")] + public bool ShowActivity { get; set; } + [JsonProperty("visibility")] + public ConnectionVisibility Visibility { get; set; } + + } +} diff --git a/src/Discord.Net.Rest/API/Common/DiscordError.cs b/src/Discord.Net.Rest/API/Common/DiscordError.cs new file mode 100644 index 0000000..ac1e5e1 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/DiscordError.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + [JsonConverter(typeof(Discord.Net.Converters.DiscordErrorConverter))] + internal class DiscordError + { + [JsonProperty("message")] + public string Message { get; set; } + [JsonProperty("code")] + public DiscordErrorCode Code { get; set; } + [JsonProperty("errors")] + public Optional Errors { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Embed.cs b/src/Discord.Net.Rest/API/Common/Embed.cs new file mode 100644 index 0000000..0570936 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Embed.cs @@ -0,0 +1,36 @@ +using Discord.Net.Converters; +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class Embed + { + [JsonProperty("title")] + public string Title { get; set; } + [JsonProperty("description")] + public string Description { get; set; } + [JsonProperty("url")] + public string Url { get; set; } + [JsonProperty("color")] + public uint? Color { get; set; } + [JsonProperty("type"), JsonConverter(typeof(EmbedTypeConverter))] + public EmbedType Type { get; set; } + [JsonProperty("timestamp")] + public DateTimeOffset? Timestamp { get; set; } + [JsonProperty("author")] + public Optional Author { get; set; } + [JsonProperty("footer")] + public Optional Footer { get; set; } + [JsonProperty("video")] + public Optional Video { get; set; } + [JsonProperty("thumbnail")] + public Optional Thumbnail { get; set; } + [JsonProperty("image")] + public Optional Image { get; set; } + [JsonProperty("provider")] + public Optional Provider { get; set; } + [JsonProperty("fields")] + public Optional Fields { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/EmbedAuthor.cs b/src/Discord.Net.Rest/API/Common/EmbedAuthor.cs new file mode 100644 index 0000000..d7f3ae6 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/EmbedAuthor.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class EmbedAuthor + { + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("url")] + public string Url { get; set; } + [JsonProperty("icon_url")] + public string IconUrl { get; set; } + [JsonProperty("proxy_icon_url")] + public string ProxyIconUrl { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/EmbedField.cs b/src/Discord.Net.Rest/API/Common/EmbedField.cs new file mode 100644 index 0000000..8afd02b --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/EmbedField.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class EmbedField + { + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("value")] + public string Value { get; set; } + [JsonProperty("inline")] + public bool Inline { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/EmbedFooter.cs b/src/Discord.Net.Rest/API/Common/EmbedFooter.cs new file mode 100644 index 0000000..cd08e7e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/EmbedFooter.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class EmbedFooter + { + [JsonProperty("text")] + public string Text { get; set; } + [JsonProperty("icon_url")] + public string IconUrl { get; set; } + [JsonProperty("proxy_icon_url")] + public string ProxyIconUrl { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/EmbedImage.cs b/src/Discord.Net.Rest/API/Common/EmbedImage.cs new file mode 100644 index 0000000..6b5db06 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/EmbedImage.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class EmbedImage + { + [JsonProperty("url")] + public string Url { get; set; } + [JsonProperty("proxy_url")] + public string ProxyUrl { get; set; } + [JsonProperty("height")] + public Optional Height { get; set; } + [JsonProperty("width")] + public Optional Width { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/EmbedProvider.cs b/src/Discord.Net.Rest/API/Common/EmbedProvider.cs new file mode 100644 index 0000000..ed0f7c3 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/EmbedProvider.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class EmbedProvider + { + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/EmbedThumbnail.cs b/src/Discord.Net.Rest/API/Common/EmbedThumbnail.cs new file mode 100644 index 0000000..dd25a1a --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/EmbedThumbnail.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class EmbedThumbnail + { + [JsonProperty("url")] + public string Url { get; set; } + [JsonProperty("proxy_url")] + public string ProxyUrl { get; set; } + [JsonProperty("height")] + public Optional Height { get; set; } + [JsonProperty("width")] + public Optional Width { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/EmbedVideo.cs b/src/Discord.Net.Rest/API/Common/EmbedVideo.cs new file mode 100644 index 0000000..f668217 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/EmbedVideo.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class EmbedVideo + { + [JsonProperty("url")] + public string Url { get; set; } + [JsonProperty("height")] + public Optional Height { get; set; } + [JsonProperty("width")] + public Optional Width { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Emoji.cs b/src/Discord.Net.Rest/API/Common/Emoji.cs new file mode 100644 index 0000000..ff0baa7 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Emoji.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Emoji + { + [JsonProperty("id")] + public ulong? Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("animated")] + public bool? Animated { get; set; } + [JsonProperty("roles")] + public ulong[] Roles { get; set; } + [JsonProperty("require_colons")] + public bool RequireColons { get; set; } + [JsonProperty("managed")] + public bool Managed { get; set; } + [JsonProperty("user")] + public Optional User { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Entitlement.cs b/src/Discord.Net.Rest/API/Common/Entitlement.cs new file mode 100644 index 0000000..f12945e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Entitlement.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API; + +internal class Entitlement +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("sku_id")] + public ulong SkuId { get; set; } + + [JsonProperty("user_id")] + public Optional UserId { get; set; } + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("application_id")] + public ulong ApplicationId { get; set; } + + [JsonProperty("type")] + public EntitlementType Type { get; set; } + + [JsonProperty("consumed")] + public bool IsConsumed { get; set; } + + [JsonProperty("starts_at")] + public Optional StartsAt { get; set; } + + [JsonProperty("ends_at")] + public Optional EndsAt { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/Error.cs b/src/Discord.Net.Rest/API/Common/Error.cs new file mode 100644 index 0000000..a2b1777 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Error.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Error + { + [JsonProperty("code")] + public string Code { get; set; } + [JsonProperty("message")] + public string Message { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/FollowedChannel.cs b/src/Discord.Net.Rest/API/Common/FollowedChannel.cs new file mode 100644 index 0000000..ad42568 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/FollowedChannel.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class FollowedChannel +{ + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + [JsonProperty("webhook_id")] + public ulong WebhookId { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/ForumReactionEmoji.cs b/src/Discord.Net.Rest/API/Common/ForumReactionEmoji.cs new file mode 100644 index 0000000..12615cb --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ForumReactionEmoji.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class ForumReactionEmoji +{ + [JsonProperty("emoji_id")] + public ulong? EmojiId { get; set; } + + [JsonProperty("emoji_name")] + public Optional EmojiName { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/ForumTag.cs b/src/Discord.Net.Rest/API/Common/ForumTag.cs new file mode 100644 index 0000000..d287bcb --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ForumTag.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class ForumTag + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("emoji_id")] + public Optional EmojiId { get; set; } + [JsonProperty("emoji_name")] + public Optional EmojiName { get; set; } + + [JsonProperty("moderated")] + public bool Moderated { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ForumThreadMessage.cs b/src/Discord.Net.Rest/API/Common/ForumThreadMessage.cs new file mode 100644 index 0000000..132e38e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ForumThreadMessage.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class ForumThreadMessage + { + [JsonProperty("content")] + public Optional Content { get; set; } + + [JsonProperty("nonce")] + public Optional Nonce { get; set; } + + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + + [JsonProperty("components")] + public Optional Components { get; set; } + + [JsonProperty("sticker_ids")] + public Optional Stickers { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Game.cs b/src/Discord.Net.Rest/API/Common/Game.cs new file mode 100644 index 0000000..105ce0d --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Game.cs @@ -0,0 +1,52 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System.Runtime.Serialization; + +namespace Discord.API +{ + internal class Game + { + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("url")] + public Optional StreamUrl { get; set; } + [JsonProperty("type")] + public Optional Type { get; set; } + [JsonProperty("details")] + public Optional Details { get; set; } + [JsonProperty("state")] + public Optional State { get; set; } + [JsonProperty("application_id")] + public Optional ApplicationId { get; set; } + [JsonProperty("assets")] + public Optional Assets { get; set; } + [JsonProperty("party")] + public Optional Party { get; set; } + [JsonProperty("secrets")] + public Optional Secrets { get; set; } + [JsonProperty("timestamps")] + public Optional Timestamps { get; set; } + [JsonProperty("instance")] + public Optional Instance { get; set; } + [JsonProperty("sync_id")] + public Optional SyncId { get; set; } + [JsonProperty("session_id")] + public Optional SessionId { get; set; } + [JsonProperty("Flags")] + public Optional Flags { get; set; } + [JsonProperty("id")] + public Optional Id { get; set; } + [JsonProperty("emoji")] + public Optional Emoji { get; set; } + [JsonProperty("created_at")] + public Optional CreatedAt { get; set; } + //[JsonProperty("buttons")] + //public Optional Buttons { get; set; } + + [OnError] + internal void OnError(StreamingContext context, ErrorContext errorContext) + { + errorContext.Handled = true; + } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GameAssets.cs b/src/Discord.Net.Rest/API/Common/GameAssets.cs new file mode 100644 index 0000000..94a5407 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GameAssets.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class GameAssets + { + [JsonProperty("small_text")] + public Optional SmallText { get; set; } + [JsonProperty("small_image")] + public Optional SmallImage { get; set; } + [JsonProperty("large_text")] + public Optional LargeText { get; set; } + [JsonProperty("large_image")] + public Optional LargeImage { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GameParty.cs b/src/Discord.Net.Rest/API/Common/GameParty.cs new file mode 100644 index 0000000..4f8ce26 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GameParty.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class GameParty + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("size")] + public long[] Size { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GameSecrets.cs b/src/Discord.Net.Rest/API/Common/GameSecrets.cs new file mode 100644 index 0000000..dc9bbf1 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GameSecrets.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class GameSecrets + { + [JsonProperty("match")] + public string Match { get; set; } + [JsonProperty("join")] + public string Join { get; set; } + [JsonProperty("spectate")] + public string Spectate { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GameTimestamps.cs b/src/Discord.Net.Rest/API/Common/GameTimestamps.cs new file mode 100644 index 0000000..ea324d9 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GameTimestamps.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class GameTimestamps + { + [JsonProperty("start")] + [UnixTimestamp] + public Optional Start { get; set; } + [JsonProperty("end")] + [UnixTimestamp] + public Optional End { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Guild.cs b/src/Discord.Net.Rest/API/Common/Guild.cs new file mode 100644 index 0000000..21912f1 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Guild.cs @@ -0,0 +1,101 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Guild + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("icon")] + public string Icon { get; set; } + [JsonProperty("splash")] + public string Splash { get; set; } + [JsonProperty("discovery_splash")] + public string DiscoverySplash { get; set; } + [JsonProperty("owner_id")] + public ulong OwnerId { get; set; } + [JsonProperty("region")] + public string Region { get; set; } + [JsonProperty("afk_channel_id")] + public ulong? AFKChannelId { get; set; } + [JsonProperty("afk_timeout")] + public int AFKTimeout { get; set; } + [JsonProperty("verification_level")] + public VerificationLevel VerificationLevel { get; set; } + [JsonProperty("default_message_notifications")] + public DefaultMessageNotifications DefaultMessageNotifications { get; set; } + [JsonProperty("explicit_content_filter")] + public ExplicitContentFilterLevel ExplicitContentFilter { get; set; } + [JsonProperty("voice_states")] + public VoiceState[] VoiceStates { get; set; } + [JsonProperty("roles")] + public Role[] Roles { get; set; } + [JsonProperty("emojis")] + public Emoji[] Emojis { get; set; } + [JsonProperty("features")] + public GuildFeatures Features { get; set; } + [JsonProperty("mfa_level")] + public MfaLevel MfaLevel { get; set; } + [JsonProperty("application_id")] + public ulong? ApplicationId { get; set; } + [JsonProperty("widget_enabled")] + public Optional WidgetEnabled { get; set; } + [JsonProperty("widget_channel_id")] + public Optional WidgetChannelId { get; set; } + [JsonProperty("safety_alerts_channel_id")] + public Optional SafetyAlertsChannelId { get; set; } + [JsonProperty("system_channel_id")] + public ulong? SystemChannelId { get; set; } + [JsonProperty("premium_tier")] + public PremiumTier PremiumTier { get; set; } + [JsonProperty("vanity_url_code")] + public string VanityURLCode { get; set; } + [JsonProperty("banner")] + public string Banner { get; set; } + [JsonProperty("description")] + public string Description { get; set; } + // this value is inverted, flags set will turn OFF features + [JsonProperty("system_channel_flags")] + public SystemChannelMessageDeny SystemChannelFlags { get; set; } + [JsonProperty("rules_channel_id")] + public ulong? RulesChannelId { get; set; } + [JsonProperty("max_presences")] + public Optional MaxPresences { get; set; } + [JsonProperty("max_members")] + public Optional MaxMembers { get; set; } + [JsonProperty("premium_subscription_count")] + public int? PremiumSubscriptionCount { get; set; } + [JsonProperty("preferred_locale")] + public string PreferredLocale { get; set; } + [JsonProperty("public_updates_channel_id")] + public ulong? PublicUpdatesChannelId { get; set; } + [JsonProperty("max_video_channel_users")] + public Optional MaxVideoChannelUsers { get; set; } + [JsonProperty("approximate_member_count")] + public Optional ApproximateMemberCount { get; set; } + [JsonProperty("approximate_presence_count")] + public Optional ApproximatePresenceCount { get; set; } + [JsonProperty("threads")] + public Optional Threads { get; set; } + [JsonProperty("nsfw_level")] + public NsfwLevel NsfwLevel { get; set; } + [JsonProperty("stickers")] + public Sticker[] Stickers { get; set; } + [JsonProperty("premium_progress_bar_enabled")] + public Optional IsBoostProgressBarEnabled { get; set; } + + [JsonProperty("welcome_screen")] + public Optional WelcomeScreen { get; set; } + + [JsonProperty("max_stage_video_channel_users")] + public Optional MaxStageVideoChannelUsers { get; set; } + + [JsonProperty("inventory_settings")] + public Optional InventorySettings { get; set; } + + [JsonProperty("incidents_data")] + public GuildIncidentsData IncidentsData { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildApplicationCommandPermissions.cs b/src/Discord.Net.Rest/API/Common/GuildApplicationCommandPermissions.cs new file mode 100644 index 0000000..cc74299 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildApplicationCommandPermissions.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class GuildApplicationCommandPermission + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("application_id")] + public ulong ApplicationId { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("permissions")] + public ApplicationCommandPermissions[] Permissions { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildIncidentsData.cs b/src/Discord.Net.Rest/API/Common/GuildIncidentsData.cs new file mode 100644 index 0000000..5b8d02e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildIncidentsData.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API; + +internal class GuildIncidentsData +{ + [JsonProperty("invites_disabled_until")] + public DateTimeOffset? InvitesDisabledUntil { get; set; } + + [JsonProperty("dms_disabled_until")] + public DateTimeOffset? DmsDisabledUntil { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildInventorySettings.cs b/src/Discord.Net.Rest/API/Common/GuildInventorySettings.cs new file mode 100644 index 0000000..c85faf0 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildInventorySettings.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class GuildInventorySettings +{ + [JsonProperty("is_emoji_pack_collectible")] + public Optional IsEmojiPackCollectible { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildMember.cs b/src/Discord.Net.Rest/API/Common/GuildMember.cs new file mode 100644 index 0000000..3bdf0d6 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildMember.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class GuildMember + { + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("nick")] + public Optional Nick { get; set; } + [JsonProperty("avatar")] + public Optional Avatar { get; set; } + [JsonProperty("roles")] + public Optional Roles { get; set; } + [JsonProperty("joined_at")] + public Optional JoinedAt { get; set; } + [JsonProperty("deaf")] + public Optional Deaf { get; set; } + [JsonProperty("mute")] + public Optional Mute { get; set; } + [JsonProperty("pending")] + public Optional Pending { get; set; } + [JsonProperty("premium_since")] + public Optional PremiumSince { get; set; } + [JsonProperty("communication_disabled_until")] + public Optional TimedOutUntil { get; set; } + + [JsonProperty("flags")] + public GuildUserFlags Flags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildOnboarding.cs b/src/Discord.Net.Rest/API/Common/GuildOnboarding.cs new file mode 100644 index 0000000..bf47ac2 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildOnboarding.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class GuildOnboarding +{ + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("prompts")] + public GuildOnboardingPrompt[] Prompts { get; set; } + + [JsonProperty("default_channel_ids")] + public ulong[] DefaultChannelIds { get; set; } + + [JsonProperty("enabled")] + public bool Enabled { get; set; } + + [JsonProperty("mode")] + public GuildOnboardingMode Mode { get; set; } + + [JsonProperty("below_requirements")] + public bool IsBelowRequirements { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildOnboardingPrompt.cs b/src/Discord.Net.Rest/API/Common/GuildOnboardingPrompt.cs new file mode 100644 index 0000000..600ea5d --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildOnboardingPrompt.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class GuildOnboardingPrompt +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("options")] + public GuildOnboardingPromptOption[] Options { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("single_select")] + public bool IsSingleSelect { get; set; } + + [JsonProperty("required")] + public bool IsRequired { get; set; } + + [JsonProperty("in_onboarding")] + public bool IsInOnboarding { get; set; } + + [JsonProperty("type")] + public GuildOnboardingPromptType Type { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildOnboardingPromptOption.cs b/src/Discord.Net.Rest/API/Common/GuildOnboardingPromptOption.cs new file mode 100644 index 0000000..e8313ff --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildOnboardingPromptOption.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class GuildOnboardingPromptOption +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("channel_ids")] + public ulong[] ChannelIds { get; set; } + + [JsonProperty("role_ids")] + public ulong[] RoleIds { get; set; } + + [JsonProperty("emoji")] + public Emoji Emoji { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildScheduledEvent.cs b/src/Discord.Net.Rest/API/Common/GuildScheduledEvent.cs new file mode 100644 index 0000000..bafff4c --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildScheduledEvent.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; + +using System; + +namespace Discord.API +{ + internal class GuildScheduledEvent + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + [JsonProperty("creator_id")] + public Optional CreatorId { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("description")] + public Optional Description { get; set; } + [JsonProperty("scheduled_start_time")] + public DateTimeOffset ScheduledStartTime { get; set; } + [JsonProperty("scheduled_end_time")] + public DateTimeOffset? ScheduledEndTime { get; set; } + [JsonProperty("privacy_level")] + public GuildScheduledEventPrivacyLevel PrivacyLevel { get; set; } + [JsonProperty("status")] + public GuildScheduledEventStatus Status { get; set; } + [JsonProperty("entity_type")] + public GuildScheduledEventType EntityType { get; set; } + [JsonProperty("entity_id")] + public ulong? EntityId { get; set; } + [JsonProperty("entity_metadata")] + public GuildScheduledEventEntityMetadata EntityMetadata { get; set; } + [JsonProperty("creator")] + public Optional Creator { get; set; } + [JsonProperty("user_count")] + public Optional UserCount { get; set; } + [JsonProperty("image")] + public string Image { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildScheduledEventEntityMetadata.cs b/src/Discord.Net.Rest/API/Common/GuildScheduledEventEntityMetadata.cs new file mode 100644 index 0000000..1db38c0 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildScheduledEventEntityMetadata.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class GuildScheduledEventEntityMetadata + { + [JsonProperty("location")] + public Optional Location { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildScheduledEventUser.cs b/src/Discord.Net.Rest/API/Common/GuildScheduledEventUser.cs new file mode 100644 index 0000000..1b0b937 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildScheduledEventUser.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class GuildScheduledEventUser + { + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("member")] + public Optional Member { get; set; } + [JsonProperty("guild_scheduled_event_id")] + public ulong GuildScheduledEventId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/GuildWidget.cs b/src/Discord.Net.Rest/API/Common/GuildWidget.cs new file mode 100644 index 0000000..6b1d29c --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/GuildWidget.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class GuildWidget + { + [JsonProperty("enabled")] + public bool Enabled { get; set; } + [JsonProperty("channel_id")] + public ulong? ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/InstallParams.cs b/src/Discord.Net.Rest/API/Common/InstallParams.cs new file mode 100644 index 0000000..f4ca60e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/InstallParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class InstallParams +{ + [JsonProperty("scopes")] + public string[] Scopes { get; set; } + + [JsonProperty("permissions")] + public GuildPermission Permission { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/Integration.cs b/src/Discord.Net.Rest/API/Common/Integration.cs new file mode 100644 index 0000000..5a2b000 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Integration.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class Integration + { + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("type")] + public string Type { get; set; } + [JsonProperty("enabled")] + public bool Enabled { get; set; } + [JsonProperty("syncing")] + public Optional Syncing { get; set; } + [JsonProperty("role_id")] + public Optional RoleId { get; set; } + [JsonProperty("enable_emoticons")] + public Optional EnableEmoticons { get; set; } + [JsonProperty("expire_behavior")] + public Optional ExpireBehavior { get; set; } + [JsonProperty("expire_grace_period")] + public Optional ExpireGracePeriod { get; set; } + [JsonProperty("user")] + public Optional User { get; set; } + [JsonProperty("account")] + public Optional Account { get; set; } + [JsonProperty("synced_at")] + public Optional SyncedAt { get; set; } + [JsonProperty("subscriber_count")] + public Optional SubscriberAccount { get; set; } + [JsonProperty("revoked")] + public Optional Revoked { get; set; } + [JsonProperty("application")] + public Optional Application { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/IntegrationAccount.cs b/src/Discord.Net.Rest/API/Common/IntegrationAccount.cs new file mode 100644 index 0000000..6b83280 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/IntegrationAccount.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class IntegrationAccount + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/IntegrationApplication.cs b/src/Discord.Net.Rest/API/Common/IntegrationApplication.cs new file mode 100644 index 0000000..4e07398 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/IntegrationApplication.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class IntegrationApplication + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("icon")] + public Optional Icon { get; set; } + [JsonProperty("description")] + public string Description { get; set; } + [JsonProperty("summary")] + public string Summary { get; set; } + [JsonProperty("bot")] + public Optional Bot { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Interaction.cs b/src/Discord.Net.Rest/API/Common/Interaction.cs new file mode 100644 index 0000000..8ca8399 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Interaction.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json; + +using System.Collections.Generic; + +namespace Discord.API; + +[JsonConverter(typeof(Net.Converters.InteractionConverter))] +internal class Interaction +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("application_id")] + public ulong ApplicationId { get; set; } + + [JsonProperty("type")] + public InteractionType Type { get; set; } + + [JsonProperty("data")] + public Optional Data { get; set; } + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("channel")] + public Optional Channel { get; set; } + + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + + [JsonProperty("member")] + public Optional Member { get; set; } + + [JsonProperty("user")] + public Optional User { get; set; } + + [JsonProperty("token")] + public string Token { get; set; } + + [JsonProperty("version")] + public int Version { get; set; } + + [JsonProperty("message")] + public Optional Message { get; set; } + + [JsonProperty("locale")] + public Optional UserLocale { get; set; } + + [JsonProperty("guild_locale")] + public Optional GuildLocale { get; set; } + + [JsonProperty("entitlements")] + public Entitlement[] Entitlements { get; set; } + + [JsonProperty("authorizing_integration_owners")] + public Dictionary IntegrationOwners { get; set; } + + [JsonProperty("context")] + public Optional ContextType { get; set; } + + [JsonProperty("app_permissions")] + public GuildPermission ApplicationPermissions { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs b/src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs new file mode 100644 index 0000000..3685d7a --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/InteractionCallbackData.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class InteractionCallbackData + { + [JsonProperty("tts")] + public Optional TTS { get; set; } + + [JsonProperty("content")] + public Optional Content { get; set; } + + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + + [JsonProperty("components")] + public Optional Components { get; set; } + + [JsonProperty("choices")] + public Optional Choices { get; set; } + + [JsonProperty("title")] + public Optional Title { get; set; } + + [JsonProperty("custom_id")] + public Optional CustomId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/InteractionResponse.cs b/src/Discord.Net.Rest/API/Common/InteractionResponse.cs new file mode 100644 index 0000000..93d4cd3 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/InteractionResponse.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class InteractionResponse + { + [JsonProperty("type")] + public InteractionResponseType Type { get; set; } + + [JsonProperty("data")] + public Optional Data { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Invite.cs b/src/Discord.Net.Rest/API/Common/Invite.cs new file mode 100644 index 0000000..5b425aa --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Invite.cs @@ -0,0 +1,41 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class Invite + { + [JsonProperty("code")] + public string Code { get; set; } + + [JsonProperty("guild")] + public Optional Guild { get; set; } + + [JsonProperty("channel")] + public InviteChannel Channel { get; set; } + + [JsonProperty("inviter")] + public Optional Inviter { get; set; } + + [JsonProperty("approximate_presence_count")] + public Optional PresenceCount { get; set; } + + [JsonProperty("approximate_member_count")] + public Optional MemberCount { get; set; } + + [JsonProperty("target_user")] + public Optional TargetUser { get; set; } + + [JsonProperty("target_type")] + public Optional TargetUserType { get; set; } + + [JsonProperty("target_application")] + public Optional Application { get; set; } + + [JsonProperty("expires_at")] + public Optional ExpiresAt { get; set; } + + [JsonProperty("guild_scheduled_event")] + public Optional ScheduledEvent { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/InviteChannel.cs b/src/Discord.Net.Rest/API/Common/InviteChannel.cs new file mode 100644 index 0000000..d601e65 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/InviteChannel.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class InviteChannel + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("type")] + public int Type { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/InviteMetadata.cs b/src/Discord.Net.Rest/API/Common/InviteMetadata.cs new file mode 100644 index 0000000..a06d069 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/InviteMetadata.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class InviteMetadata : Invite + { + [JsonProperty("uses")] + public int Uses { get; set; } + [JsonProperty("max_uses")] + public int MaxUses { get; set; } + [JsonProperty("max_age")] + public int MaxAge { get; set; } + [JsonProperty("temporary")] + public bool Temporary { get; set; } + [JsonProperty("created_at")] + public DateTimeOffset CreatedAt { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/InviteVanity.cs b/src/Discord.Net.Rest/API/Common/InviteVanity.cs new file mode 100644 index 0000000..2ae9716 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/InviteVanity.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + /// + /// Represents a vanity invite. + /// + internal class InviteVanity + { + /// + /// The unique code for the invite link. + /// + [JsonProperty("code")] + public string Code { get; set; } + + /// + /// The total amount of vanity invite uses. + /// + [JsonProperty("uses")] + public int Uses { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/MembershipState.cs b/src/Discord.Net.Rest/API/Common/MembershipState.cs new file mode 100644 index 0000000..67fcc89 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MembershipState.cs @@ -0,0 +1,9 @@ +namespace Discord.API +{ + internal enum MembershipState + { + None = 0, + Invited = 1, + Accepted = 2, + } +} diff --git a/src/Discord.Net.Rest/API/Common/Message.cs b/src/Discord.Net.Rest/API/Common/Message.cs new file mode 100644 index 0000000..c9cc61a --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Message.cs @@ -0,0 +1,104 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API; + +internal class Message +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public MessageType Type { get; set; } + + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + // ALWAYS sent on WebSocket messages + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("webhook_id")] + public Optional WebhookId { get; set; } + + [JsonProperty("author")] + public Optional Author { get; set; } + + // ALWAYS sent on WebSocket messages + [JsonProperty("member")] + public Optional Member { get; set; } + + [JsonProperty("content")] + public Optional Content { get; set; } + + [JsonProperty("timestamp")] + public Optional Timestamp { get; set; } + + [JsonProperty("edited_timestamp")] + public Optional EditedTimestamp { get; set; } + + [JsonProperty("tts")] + public Optional IsTextToSpeech { get; set; } + + [JsonProperty("mention_everyone")] + public Optional MentionEveryone { get; set; } + + [JsonProperty("mentions")] + public Optional UserMentions { get; set; } + + [JsonProperty("mention_roles")] + public Optional RoleMentions { get; set; } + + [JsonProperty("attachments")] + public Optional Attachments { get; set; } + + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + + [JsonProperty("pinned")] + public Optional Pinned { get; set; } + + [JsonProperty("reactions")] + public Optional Reactions { get; set; } + + // sent with Rich Presence-related chat embeds + [JsonProperty("activity")] + public Optional Activity { get; set; } + + // sent with Rich Presence-related chat embeds + [JsonProperty("application")] + public Optional Application { get; set; } + + [JsonProperty("message_reference")] + public Optional Reference { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + + [JsonProperty("referenced_message")] + public Optional ReferencedMessage { get; set; } + + [JsonProperty("components")] + public Optional Components { get; set; } + + [JsonProperty("interaction")] + public Optional Interaction { get; set; } + + [JsonProperty("sticker_items")] + public Optional StickerItems { get; set; } + + [JsonProperty("role_subscription_data")] + public Optional RoleSubscriptionData { get; set; } + + [JsonProperty("thread")] + public Optional Thread { get; set; } + + [JsonProperty("resolved")] + public Optional Resolved { get; set; } + + [JsonProperty("interaction_metadata")] + public Optional InteractionMetadata { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageActivity.cs b/src/Discord.Net.Rest/API/Common/MessageActivity.cs new file mode 100644 index 0000000..bbe0d52 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageActivity.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class MessageActivity + { + [JsonProperty("type")] + public Optional Type { get; set; } + [JsonProperty("party_id")] + public Optional PartyId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageApplication.cs b/src/Discord.Net.Rest/API/Common/MessageApplication.cs new file mode 100644 index 0000000..ee59630 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageApplication.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class MessageApplication + { + /// + /// Gets the snowflake ID of the application. + /// + [JsonProperty("id")] + public ulong Id { get; set; } + /// + /// Gets the ID of the embed's image asset. + /// + [JsonProperty("cover_image")] + public string CoverImage { get; set; } + /// + /// Gets the application's description. + /// + [JsonProperty("description")] + public string Description { get; set; } + /// + /// Gets the ID of the application's icon. + /// + [JsonProperty("icon")] + public string Icon { get; set; } + /// + /// Gets the name of the application. + /// + [JsonProperty("name")] + public string Name { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs b/src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs new file mode 100644 index 0000000..1bc45d2 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageComponentInteractionData.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API +{ + internal class MessageComponentInteractionData : IDiscordInteractionData + { + [JsonProperty("custom_id")] + public string CustomId { get; set; } + + [JsonProperty("component_type")] + public ComponentType ComponentType { get; set; } + + [JsonProperty("values")] + public Optional Values { get; set; } + + [JsonProperty("value")] + public Optional Value { get; set; } + + [JsonProperty("resolved")] + public Optional Resolved { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageComponentInteractionDataResolved.cs b/src/Discord.Net.Rest/API/Common/MessageComponentInteractionDataResolved.cs new file mode 100644 index 0000000..04f97cd --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageComponentInteractionDataResolved.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API; + +internal class MessageComponentInteractionDataResolved +{ + [JsonProperty("users")] + public Optional> Users { get; set; } + + [JsonProperty("members")] + public Optional> Members { get; set; } + + [JsonProperty("channels")] + public Optional> Channels { get; set; } + + [JsonProperty("roles")] + public Optional> Roles { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageInteraction.cs b/src/Discord.Net.Rest/API/Common/MessageInteraction.cs new file mode 100644 index 0000000..48f2783 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageInteraction.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class MessageInteraction + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("type")] + public InteractionType Type { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("user")] + public User User { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageInteractionMetadata.cs b/src/Discord.Net.Rest/API/Common/MessageInteractionMetadata.cs new file mode 100644 index 0000000..f5f8866 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageInteractionMetadata.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API; + +internal class MessageInteractionMetadata +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public InteractionType Type { get; set; } + + [JsonProperty("user_id")] + public ulong UserId { get; set; } + + [JsonProperty("authorizing_integration_owners")] + public Dictionary IntegrationOwners { get; set; } + + [JsonProperty("original_response_message_id")] + public Optional OriginalResponseMessageId { get; set; } + + [JsonProperty("name")] + public Optional Name { get; set; } + + [JsonProperty("interacted_message_id")] + public Optional InteractedMessageId { get; set; } + + [JsonProperty("triggering_interaction_metadata")] + public Optional TriggeringInteractionMetadata { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageReference.cs b/src/Discord.Net.Rest/API/Common/MessageReference.cs new file mode 100644 index 0000000..70ef4e6 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageReference.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class MessageReference + { + [JsonProperty("message_id")] + public Optional MessageId { get; set; } + + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } // Optional when sending, always present when receiving + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("fail_if_not_exists")] + public Optional FailIfNotExists { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/MessageRoleSubscriptionData.cs b/src/Discord.Net.Rest/API/Common/MessageRoleSubscriptionData.cs new file mode 100644 index 0000000..da97ec6 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/MessageRoleSubscriptionData.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class MessageRoleSubscriptionData +{ + [JsonProperty("role_subscription_listing_id")] + public ulong SubscriptionListingId { get; set; } + + [JsonProperty("tier_name")] + public string TierName { get; set; } + + [JsonProperty("total_months_subscribed")] + public int MonthsSubscribed { get; set; } + + [JsonProperty("is_renewal")] + public bool IsRenewal { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs b/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs new file mode 100644 index 0000000..182fa53 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class ModalInteractionData : IDiscordInteractionData + { + [JsonProperty("custom_id")] + public string CustomId { get; set; } + + [JsonProperty("components")] + public API.ActionRowComponent[] Components { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/NitroStickerPacks.cs b/src/Discord.Net.Rest/API/Common/NitroStickerPacks.cs new file mode 100644 index 0000000..cc2f0d9 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/NitroStickerPacks.cs @@ -0,0 +1,11 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API +{ + internal class NitroStickerPacks + { + [JsonProperty("sticker_packs")] + public List StickerPacks { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Overwrite.cs b/src/Discord.Net.Rest/API/Common/Overwrite.cs new file mode 100644 index 0000000..a1fb534 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Overwrite.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Overwrite + { + [JsonProperty("id")] + public ulong TargetId { get; set; } + [JsonProperty("type")] + public PermissionTarget TargetType { get; set; } + [JsonProperty("deny"), Int53] + public string Deny { get; set; } + [JsonProperty("allow"), Int53] + public string Allow { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/PartialGuild.cs b/src/Discord.Net.Rest/API/Common/PartialGuild.cs new file mode 100644 index 0000000..85c00c8 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/PartialGuild.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class PartialGuild + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("splash")] + public Optional Splash { get; set; } + + [JsonProperty("banner")] + public Optional BannerHash { get; set; } + + [JsonProperty("description")] + public Optional Description { get; set; } + + [JsonProperty("icon")] + public Optional IconHash { get; set; } + + [JsonProperty("features")] + public Optional Features { get; set; } + + [JsonProperty("verification_level")] + public Optional VerificationLevel { get; set; } + + [JsonProperty("vanity_url_code")] + public Optional VanityUrlCode { get; set; } + + [JsonProperty("premium_subscription_count")] + public Optional PremiumSubscriptionCount { get; set; } + + [JsonProperty("nsfw")] + public Optional Nsfw { get; set; } + + [JsonProperty("nsfw_level")] + public Optional NsfwLevel { get; set; } + + [JsonProperty("welcome_screen")] + public Optional WelcomeScreen { get; set; } + + [JsonProperty("approximate_member_count")] + public Optional ApproximateMemberCount { get; set; } + + [JsonProperty("approximate_presence_count")] + public Optional ApproximatePresenceCount { get; set; } + + } +} diff --git a/src/Discord.Net.Rest/API/Common/Presence.cs b/src/Discord.Net.Rest/API/Common/Presence.cs new file mode 100644 index 0000000..23f871a --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Presence.cs @@ -0,0 +1,32 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + +namespace Discord.API +{ + internal class Presence + { + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("status")] + public UserStatus Status { get; set; } + + [JsonProperty("roles")] + public Optional Roles { get; set; } + [JsonProperty("nick")] + public Optional Nick { get; set; } + // This property is a Dictionary where each key is the ClientType + // and the values are the current client status. + // The client status values are all the same. + // Example: + // "client_status": { "desktop": "dnd", "mobile": "dnd" } + [JsonProperty("client_status")] + public Optional> ClientStatus { get; set; } + [JsonProperty("activities")] + public List Activities { get; set; } + [JsonProperty("premium_since")] + public Optional PremiumSince { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/PropertyErrorDescription.cs b/src/Discord.Net.Rest/API/Common/PropertyErrorDescription.cs new file mode 100644 index 0000000..145288e --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/PropertyErrorDescription.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class ErrorDetails + { + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("errors")] + public Error[] Errors { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Ratelimit.cs b/src/Discord.Net.Rest/API/Common/Ratelimit.cs new file mode 100644 index 0000000..a7320cb --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Ratelimit.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class Ratelimit + { + [JsonProperty("global")] + public bool Global { get; set; } + + [JsonProperty("message")] + public string Message { get; set; } + + [JsonProperty("retry_after")] + public double RetryAfter { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Reaction.cs b/src/Discord.Net.Rest/API/Common/Reaction.cs new file mode 100644 index 0000000..b85d3b0 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Reaction.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class Reaction +{ + [JsonProperty("count")] + public int Count { get; set; } + + [JsonProperty("me")] + public bool Me { get; set; } + + [JsonProperty("me_burst")] + public bool MeBurst { get; set; } + + [JsonProperty("emoji")] + public Emoji Emoji { get; set; } + + [JsonProperty("count_details")] + public ReactionCountDetails CountDetails { get; set; } + + [JsonProperty("burst_colors")] + public Color[] Colors { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/ReactionCountDetails.cs b/src/Discord.Net.Rest/API/Common/ReactionCountDetails.cs new file mode 100644 index 0000000..b8ab048 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ReactionCountDetails.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class ReactionCountDetails +{ + [JsonProperty("normal")] + public int NormalCount { get; set;} + + [JsonProperty("burst")] + public int BurstCount { get; set;} +} diff --git a/src/Discord.Net.Rest/API/Common/ReadState.cs b/src/Discord.Net.Rest/API/Common/ReadState.cs new file mode 100644 index 0000000..9a66880 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ReadState.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class ReadState + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("mention_count")] + public int MentionCount { get; set; } + [JsonProperty("last_message_id")] + public Optional LastMessageId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Relationship.cs b/src/Discord.Net.Rest/API/Common/Relationship.cs new file mode 100644 index 0000000..d17f766 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Relationship.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Relationship + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("type")] + public RelationshipType Type { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/RelationshipType.cs b/src/Discord.Net.Rest/API/Common/RelationshipType.cs new file mode 100644 index 0000000..776ba15 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/RelationshipType.cs @@ -0,0 +1,10 @@ +namespace Discord.API +{ + internal enum RelationshipType + { + Friend = 1, + Blocked = 2, + IncomingPending = 3, + OutgoingPending = 4 + } +} diff --git a/src/Discord.Net.Rest/API/Common/Role.cs b/src/Discord.Net.Rest/API/Common/Role.cs new file mode 100644 index 0000000..c856400 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Role.cs @@ -0,0 +1,42 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class Role +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("icon")] + public Optional Icon { get; set; } + + [JsonProperty("unicode_emoji")] + public Optional Emoji { get; set; } + + [JsonProperty("color")] + public uint Color { get; set; } + + [JsonProperty("hoist")] + public bool Hoist { get; set; } + + [JsonProperty("mentionable")] + public bool Mentionable { get; set; } + + [JsonProperty("position")] + public int Position { get; set; } + + [JsonProperty("permissions"), Int53] + public string Permissions { get; set; } + + [JsonProperty("managed")] + public bool Managed { get; set; } + + [JsonProperty("tags")] + public Optional Tags { get; set; } + + [JsonProperty("flags")] + public RoleFlags Flags { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/RoleConnection.cs b/src/Discord.Net.Rest/API/Common/RoleConnection.cs new file mode 100644 index 0000000..4ee5a14 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/RoleConnection.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API; + +internal class RoleConnection +{ + [JsonProperty("platform_name")] + public Optional PlatformName { get; set; } + + [JsonProperty("platform_username")] + public Optional PlatformUsername { get; set; } + + [JsonProperty("metadata")] + public Optional> Metadata { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/RoleConnectionMetadata.cs b/src/Discord.Net.Rest/API/Common/RoleConnectionMetadata.cs new file mode 100644 index 0000000..7171bd8 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/RoleConnectionMetadata.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API; + +internal class RoleConnectionMetadata +{ + [JsonProperty("type")] + public RoleConnectionMetadataType Type { get; set; } + + [JsonProperty("key")] + public string Key { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/RoleTags.cs b/src/Discord.Net.Rest/API/Common/RoleTags.cs new file mode 100644 index 0000000..9ddd39a --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/RoleTags.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class RoleTags + { + [JsonProperty("bot_id")] + public Optional BotId { get; set; } + [JsonProperty("integration_id")] + public Optional IntegrationId { get; set; } + [JsonProperty("premium_subscriber")] + public Optional IsPremiumSubscriber { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/SKU.cs b/src/Discord.Net.Rest/API/Common/SKU.cs new file mode 100644 index 0000000..dd3bb48 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/SKU.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class SKU +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public SKUType Type { get; set; } + + [JsonProperty("application_id")] + public ulong ApplicationId { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("slug")] + public string Slug { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs b/src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs new file mode 100644 index 0000000..c7a6956 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/SelectMenuComponent.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json; +using System.Linq; + +namespace Discord.API +{ + internal class SelectMenuComponent : IMessageComponent + { + [JsonProperty("type")] + public ComponentType Type { get; set; } + + [JsonProperty("custom_id")] + public string CustomId { get; set; } + + [JsonProperty("options")] + public SelectMenuOption[] Options { get; set; } + + [JsonProperty("placeholder")] + public Optional Placeholder { get; set; } + + [JsonProperty("min_values")] + public int MinValues { get; set; } + + [JsonProperty("max_values")] + public int MaxValues { get; set; } + + [JsonProperty("disabled")] + public bool Disabled { get; set; } + + [JsonProperty("channel_types")] + public Optional ChannelTypes { get; set; } + + [JsonProperty("resolved")] + public Optional Resolved { get; set; } + + [JsonProperty("values")] + public Optional Values { get; set; } + + [JsonProperty("default_values")] + public Optional DefaultValues { get; set; } + + public SelectMenuComponent() { } + + public SelectMenuComponent(Discord.SelectMenuComponent component) + { + Type = component.Type; + CustomId = component.CustomId; + Options = component.Options?.Select(x => new SelectMenuOption(x)).ToArray(); + Placeholder = component.Placeholder; + MinValues = component.MinValues; + MaxValues = component.MaxValues; + Disabled = component.IsDisabled; + ChannelTypes = component.ChannelTypes.ToArray(); + DefaultValues = component.DefaultValues.Select(x => new SelectMenuDefaultValue {Id = x.Id, Type = x.Type}).ToArray(); + } + } +} diff --git a/src/Discord.Net.Rest/API/Common/SelectMenuDefaultValue.cs b/src/Discord.Net.Rest/API/Common/SelectMenuDefaultValue.cs new file mode 100644 index 0000000..ebaed14 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/SelectMenuDefaultValue.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class SelectMenuDefaultValue +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public SelectDefaultValueType Type { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/SelectMenuOption.cs b/src/Discord.Net.Rest/API/Common/SelectMenuOption.cs new file mode 100644 index 0000000..d0a25a8 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/SelectMenuOption.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class SelectMenuOption + { + [JsonProperty("label")] + public string Label { get; set; } + + [JsonProperty("value")] + public string Value { get; set; } + + [JsonProperty("description")] + public Optional Description { get; set; } + + [JsonProperty("emoji")] + public Optional Emoji { get; set; } + + [JsonProperty("default")] + public Optional Default { get; set; } + + public SelectMenuOption() { } + + public SelectMenuOption(Discord.SelectMenuOption option) + { + Label = option.Label; + Value = option.Value; + Description = option.Description; + + if (option.Emote != null) + { + if (option.Emote is Emote e) + { + Emoji = new Emoji + { + Name = e.Name, + Animated = e.Animated, + Id = e.Id + }; + } + else + { + Emoji = new Emoji + { + Name = option.Emote.Name + }; + } + } + + Default = option.IsDefault ?? Optional.Unspecified; + } + } +} diff --git a/src/Discord.Net.Rest/API/Common/SessionStartLimit.cs b/src/Discord.Net.Rest/API/Common/SessionStartLimit.cs new file mode 100644 index 0000000..29d5ddf --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/SessionStartLimit.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class SessionStartLimit + { + [JsonProperty("total")] + public int Total { get; set; } + [JsonProperty("remaining")] + public int Remaining { get; set; } + [JsonProperty("reset_after")] + public int ResetAfter { get; set; } + [JsonProperty("max_concurrency")] + public int MaxConcurrency { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/StageInstance.cs b/src/Discord.Net.Rest/API/Common/StageInstance.cs new file mode 100644 index 0000000..3ec6239 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/StageInstance.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class StageInstance + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + [JsonProperty("topic")] + public string Topic { get; set; } + + [JsonProperty("privacy_level")] + public StagePrivacyLevel PrivacyLevel { get; set; } + + [JsonProperty("discoverable_disabled")] + public bool DiscoverableDisabled { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Sticker.cs b/src/Discord.Net.Rest/API/Common/Sticker.cs new file mode 100644 index 0000000..b2c58d5 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Sticker.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Sticker + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("pack_id")] + public ulong PackId { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("description")] + public string Description { get; set; } + [JsonProperty("tags")] + public Optional Tags { get; set; } + [JsonProperty("type")] + public StickerType Type { get; set; } + [JsonProperty("format_type")] + public StickerFormatType FormatType { get; set; } + [JsonProperty("available")] + public bool? Available { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("user")] + public Optional User { get; set; } + [JsonProperty("sort_value")] + public int? SortValue { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/StickerItem.cs b/src/Discord.Net.Rest/API/Common/StickerItem.cs new file mode 100644 index 0000000..4b24f71 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/StickerItem.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class StickerItem + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("format_type")] + public StickerFormatType FormatType { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/StickerPack.cs b/src/Discord.Net.Rest/API/Common/StickerPack.cs new file mode 100644 index 0000000..3daaac5 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/StickerPack.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class StickerPack + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("stickers")] + public Sticker[] Stickers { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("sku_id")] + public ulong SkuId { get; set; } + [JsonProperty("cover_sticker_id")] + public Optional CoverStickerId { get; set; } + [JsonProperty("description")] + public string Description { get; set; } + [JsonProperty("banner_asset_id")] + public ulong BannerAssetId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Team.cs b/src/Discord.Net.Rest/API/Common/Team.cs new file mode 100644 index 0000000..b421dc1 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Team.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class Team + { + [JsonProperty("icon")] + public Optional Icon { get; set; } + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("members")] + public TeamMember[] TeamMembers { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("owner_user_id")] + public ulong OwnerUserId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/TeamMember.cs b/src/Discord.Net.Rest/API/Common/TeamMember.cs new file mode 100644 index 0000000..f3cba60 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/TeamMember.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class TeamMember + { + [JsonProperty("membership_state")] + public MembershipState MembershipState { get; set; } + [JsonProperty("permissions")] + public string[] Permissions { get; set; } + [JsonProperty("team_id")] + public ulong TeamId { get; set; } + [JsonProperty("user")] + public User User { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/TextInputComponent.cs b/src/Discord.Net.Rest/API/Common/TextInputComponent.cs new file mode 100644 index 0000000..a475345 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/TextInputComponent.cs @@ -0,0 +1,49 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class TextInputComponent : IMessageComponent + { + [JsonProperty("type")] + public ComponentType Type { get; set; } + + [JsonProperty("style")] + public TextInputStyle Style { get; set; } + + [JsonProperty("custom_id")] + public string CustomId { get; set; } + + [JsonProperty("label")] + public string Label { get; set; } + + [JsonProperty("placeholder")] + public Optional Placeholder { get; set; } + + [JsonProperty("min_length")] + public Optional MinLength { get; set; } + + [JsonProperty("max_length")] + public Optional MaxLength { get; set; } + + [JsonProperty("value")] + public Optional Value { get; set; } + + [JsonProperty("required")] + public Optional Required { get; set; } + + public TextInputComponent() { } + + public TextInputComponent(Discord.TextInputComponent component) + { + Type = component.Type; + Style = component.Style; + CustomId = component.CustomId; + Label = component.Label; + Placeholder = component.Placeholder; + MinLength = component.MinLength ?? Optional.Unspecified; + MaxLength = component.MaxLength ?? Optional.Unspecified; + Required = component.Required ?? Optional.Unspecified; + Value = component.Value ?? Optional.Unspecified; + } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ThreadMember.cs b/src/Discord.Net.Rest/API/Common/ThreadMember.cs new file mode 100644 index 0000000..fb89f34 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ThreadMember.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class ThreadMember + { + [JsonProperty("id")] + public Optional Id { get; set; } + + [JsonProperty("user_id")] + public Optional UserId { get; set; } + + [JsonProperty("join_timestamp")] + public DateTimeOffset JoinTimestamp { get; set; } + + [JsonProperty("flags")] + public int Flags { get; set; } // No enum type (yet?) + + [JsonProperty("member")] + public Optional GuildMember { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/ThreadMetadata.cs b/src/Discord.Net.Rest/API/Common/ThreadMetadata.cs new file mode 100644 index 0000000..6735504 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ThreadMetadata.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class ThreadMetadata + { + [JsonProperty("archived")] + public bool Archived { get; set; } + + [JsonProperty("auto_archive_duration")] + public ThreadArchiveDuration AutoArchiveDuration { get; set; } + + [JsonProperty("archive_timestamp")] + public DateTimeOffset ArchiveTimestamp { get; set; } + + [JsonProperty("locked")] + public Optional Locked { get; set; } + + [JsonProperty("invitable")] + public Optional Invitable { get; set; } + + [JsonProperty("create_timestamp")] + public Optional CreatedAt { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/TriggerMetadata.cs b/src/Discord.Net.Rest/API/Common/TriggerMetadata.cs new file mode 100644 index 0000000..214e464 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/TriggerMetadata.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class TriggerMetadata + { + [JsonProperty("keyword_filter")] + public Optional KeywordFilter { get; set; } + + [JsonProperty("regex_patterns")] + public Optional RegexPatterns { get; set; } + + [JsonProperty("presets")] + public Optional Presets { get; set; } + + [JsonProperty("allow_list")] + public Optional AllowList { get; set; } + + [JsonProperty("mention_total_limit")] + public Optional MentionLimit { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/User.cs b/src/Discord.Net.Rest/API/Common/User.cs new file mode 100644 index 0000000..bebb995 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/User.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class User + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("username")] + public Optional Username { get; set; } + [JsonProperty("discriminator")] + public Optional Discriminator { get; set; } + [JsonProperty("bot")] + public Optional Bot { get; set; } + [JsonProperty("avatar")] + public Optional Avatar { get; set; } + [JsonProperty("banner")] + public Optional Banner { get; set; } + [JsonProperty("banner_color")] + public Optional BannerColor { get; set; } + [JsonProperty("accent_color")] + public Optional AccentColor { get; set; } + + [JsonProperty("global_name")] + public Optional GlobalName { get; set; } + + [JsonProperty("avatar_decoration_data")] + public Optional AvatarDecoration { get; set; } + + //CurrentUser + [JsonProperty("verified")] + public Optional Verified { get; set; } + [JsonProperty("email")] + public Optional Email { get; set; } + [JsonProperty("mfa_enabled")] + public Optional MfaEnabled { get; set; } + [JsonProperty("flags")] + public Optional Flags { get; set; } + [JsonProperty("premium_type")] + public Optional PremiumType { get; set; } + [JsonProperty("locale")] + public Optional Locale { get; set; } + [JsonProperty("public_flags")] + public Optional PublicFlags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/UserGuild.cs b/src/Discord.Net.Rest/API/Common/UserGuild.cs new file mode 100644 index 0000000..3fc340d --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/UserGuild.cs @@ -0,0 +1,26 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class UserGuild + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("icon")] + public string Icon { get; set; } + [JsonProperty("owner")] + public bool Owner { get; set; } + [JsonProperty("permissions"), Int53] + public string Permissions { get; set; } + [JsonProperty("features")] + public GuildFeatures Features { get; set; } + + [JsonProperty("approximate_member_count")] + public Optional ApproximateMemberCount { get; set; } + + [JsonProperty("approximate_presence_count")] + public Optional ApproximatePresenceCount { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/VoiceRegion.cs b/src/Discord.Net.Rest/API/Common/VoiceRegion.cs new file mode 100644 index 0000000..3cc66a0 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/VoiceRegion.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class VoiceRegion + { + [JsonProperty("id")] + public string Id { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("vip")] + public bool IsVip { get; set; } + [JsonProperty("optimal")] + public bool IsOptimal { get; set; } + [JsonProperty("deprecated")] + public bool IsDeprecated { get; set; } + [JsonProperty("custom")] + public bool IsCustom { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/VoiceState.cs b/src/Discord.Net.Rest/API/Common/VoiceState.cs new file mode 100644 index 0000000..adfa7f2 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/VoiceState.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API +{ + internal class VoiceState + { + [JsonProperty("guild_id")] + public ulong? GuildId { get; set; } + [JsonProperty("channel_id")] + public ulong? ChannelId { get; set; } + [JsonProperty("user_id")] + public ulong UserId { get; set; } + // ALWAYS sent over WebSocket, never on REST + [JsonProperty("member")] + public Optional Member { get; set; } + [JsonProperty("session_id")] + public string SessionId { get; set; } + [JsonProperty("deaf")] + public bool Deaf { get; set; } + [JsonProperty("mute")] + public bool Mute { get; set; } + [JsonProperty("self_deaf")] + public bool SelfDeaf { get; set; } + [JsonProperty("self_mute")] + public bool SelfMute { get; set; } + [JsonProperty("suppress")] + public bool Suppress { get; set; } + [JsonProperty("self_stream")] + public bool SelfStream { get; set; } + [JsonProperty("self_video")] + public bool SelfVideo { get; set; } + [JsonProperty("request_to_speak_timestamp")] + public Optional RequestToSpeakTimestamp { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Common/Webhook.cs b/src/Discord.Net.Rest/API/Common/Webhook.cs new file mode 100644 index 0000000..f7c2ecd --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/Webhook.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class Webhook +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("type")] + public WebhookType Type { get; set; } + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("channel_id")] + public ulong? ChannelId { get; set; } + + [JsonProperty("user")] + public Optional Creator { get; set; } + + [JsonProperty("name")] + public Optional Name { get; set; } + + [JsonProperty("avatar")] + public Optional Avatar { get; set; } + + [JsonProperty("token")] + public Optional Token { get; set; } + + [JsonProperty("application_id")] + public ulong? ApplicationId { get; set; } + + [JsonProperty("source_guild")] + public Optional Guild { get; set; } + + [JsonProperty("source_channel")] + public Optional Channel { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/WelcomeScreen.cs b/src/Discord.Net.Rest/API/Common/WelcomeScreen.cs new file mode 100644 index 0000000..a462eae --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/WelcomeScreen.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class WelcomeScreen +{ + [JsonProperty("description")] + public Optional Description { get; set; } + + [JsonProperty("welcome_channels")] + public WelcomeScreenChannel[] WelcomeChannels { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/WelcomeScreenChannel.cs b/src/Discord.Net.Rest/API/Common/WelcomeScreenChannel.cs new file mode 100644 index 0000000..b82fb92 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/WelcomeScreenChannel.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class WelcomeScreenChannel +{ + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("emoji_id")] + public Optional EmojiId { get; set; } + + [JsonProperty("emoji_name")] + public Optional EmojiName { get; set; } +} diff --git a/src/Discord.Net.Rest/API/EntityOrId.cs b/src/Discord.Net.Rest/API/EntityOrId.cs new file mode 100644 index 0000000..b375d96 --- /dev/null +++ b/src/Discord.Net.Rest/API/EntityOrId.cs @@ -0,0 +1,19 @@ +namespace Discord.API +{ + internal struct EntityOrId + { + public ulong Id { get; } + public T Object { get; } + + public EntityOrId(ulong id) + { + Id = id; + Object = default(T); + } + public EntityOrId(T obj) + { + Id = 0; + Object = obj; + } + } +} diff --git a/src/Discord.Net.Rest/API/Image.cs b/src/Discord.Net.Rest/API/Image.cs new file mode 100644 index 0000000..c37b77c --- /dev/null +++ b/src/Discord.Net.Rest/API/Image.cs @@ -0,0 +1,21 @@ +using System.IO; + +namespace Discord.API +{ + internal struct Image + { + public Stream Stream { get; } + public string Hash { get; } + + public Image(Stream stream) + { + Stream = stream; + Hash = null; + } + public Image(string hash) + { + Stream = null; + Hash = hash; + } + } +} diff --git a/src/Discord.Net.Rest/API/Int53Attribute.cs b/src/Discord.Net.Rest/API/Int53Attribute.cs new file mode 100644 index 0000000..3a21b58 --- /dev/null +++ b/src/Discord.Net.Rest/API/Int53Attribute.cs @@ -0,0 +1,7 @@ +using System; + +namespace Discord.API +{ + [AttributeUsage(AttributeTargets.Property)] + internal class Int53Attribute : Attribute { } +} diff --git a/src/Discord.Net.Rest/API/Net/IResolvable.cs b/src/Discord.Net.Rest/API/Net/IResolvable.cs new file mode 100644 index 0000000..7485f5d --- /dev/null +++ b/src/Discord.Net.Rest/API/Net/IResolvable.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal interface IResolvable + { + Optional Resolved { get; } + } +} diff --git a/src/Discord.Net.Rest/API/Net/MultipartFile.cs b/src/Discord.Net.Rest/API/Net/MultipartFile.cs new file mode 100644 index 0000000..d6bc4c7 --- /dev/null +++ b/src/Discord.Net.Rest/API/Net/MultipartFile.cs @@ -0,0 +1,18 @@ +using System.IO; + +namespace Discord.Net.Rest +{ + internal struct MultipartFile + { + public Stream Stream { get; } + public string Filename { get; } + public string ContentType { get; } + + public MultipartFile(Stream stream, string filename, string contentType = null) + { + Stream = stream; + Filename = filename; + ContentType = contentType; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/AddGuildMemberParams.cs b/src/Discord.Net.Rest/API/Rest/AddGuildMemberParams.cs new file mode 100644 index 0000000..ef6229e --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/AddGuildMemberParams.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class AddGuildMemberParams + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + [JsonProperty("nick")] + public Optional Nickname { get; set; } + [JsonProperty("roles")] + public Optional RoleIds { get; set; } + [JsonProperty("mute")] + public Optional IsMuted { get; set; } + [JsonProperty("deaf")] + public Optional IsDeafened { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/BulkBanParams.cs b/src/Discord.Net.Rest/API/Rest/BulkBanParams.cs new file mode 100644 index 0000000..e7c0337 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/BulkBanParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class BulkBanParams +{ + [JsonProperty("user_ids")] + public ulong[] UserIds { get; set; } + + [JsonProperty("delete_message_seconds")] + public Optional DeleteMessageSeconds { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs b/src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs new file mode 100644 index 0000000..4964db2 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateApplicationCommandParams.cs @@ -0,0 +1,64 @@ +using Newtonsoft.Json; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.API.Rest +{ + internal class CreateApplicationCommandParams + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("type")] + public ApplicationCommandType Type { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("options")] + public Optional Options { get; set; } + + [JsonProperty("default_permission")] + public Optional DefaultPermission { get; set; } + + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } + + [JsonProperty("dm_permission")] + public Optional DmPermission { get; set; } + + [JsonProperty("default_member_permissions")] + public Optional DefaultMemberPermission { get; set; } + + [JsonProperty("nsfw")] + public Optional Nsfw { get; set; } + + [JsonProperty("contexts")] + public Optional> ContextTypes { get; set; } + + [JsonProperty("integration_types")] + public Optional> IntegrationTypes { get; set; } + + public CreateApplicationCommandParams() { } + + public CreateApplicationCommandParams(string name, string description, ApplicationCommandType type, ApplicationCommandOption[] options = null, + IDictionary nameLocalizations = null, IDictionary descriptionLocalizations = null, bool nsfw = false, + HashSet contextTypes = null, HashSet integrationTypes = null) + { + Name = name; + Description = description; + Options = Optional.Create(options); + Type = type; + NameLocalizations = nameLocalizations?.ToDictionary(x => x.Key, x => x.Value) ?? Optional>.Unspecified; + DescriptionLocalizations = descriptionLocalizations?.ToDictionary(x => x.Key, x => x.Value) ?? Optional>.Unspecified; + Nsfw = nsfw; + ContextTypes = contextTypes ?? Optional.Create>(); + IntegrationTypes = integrationTypes ?? Optional.Create>(); + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateAutoModRuleParams.cs b/src/Discord.Net.Rest/API/Rest/CreateAutoModRuleParams.cs new file mode 100644 index 0000000..5438700 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateAutoModRuleParams.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class CreateAutoModRuleParams + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("event_type")] + public AutoModEventType EventType { get; set; } + + [JsonProperty("trigger_type")] + public AutoModTriggerType TriggerType { get; set; } + + [JsonProperty("trigger_metadata")] + public Optional TriggerMetadata { get; set; } + + [JsonProperty("actions")] + public AutoModAction[] Actions { get; set; } + + [JsonProperty("enabled")] + public Optional Enabled { get; set; } + + [JsonProperty("exempt_roles")] + public Optional ExemptRoles { get; set; } + + [JsonProperty("exempt_channels")] + public Optional ExemptChannels { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateChannelInviteParams.cs b/src/Discord.Net.Rest/API/Rest/CreateChannelInviteParams.cs new file mode 100644 index 0000000..852abe3 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateChannelInviteParams.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateChannelInviteParams + { + [JsonProperty("max_age")] + public Optional MaxAge { get; set; } + [JsonProperty("max_uses")] + public Optional MaxUses { get; set; } + [JsonProperty("temporary")] + public Optional IsTemporary { get; set; } + [JsonProperty("unique")] + public Optional IsUnique { get; set; } + [JsonProperty("target_type")] + public Optional TargetType { get; set; } + [JsonProperty("target_user_id")] + public Optional TargetUserId { get; set; } + [JsonProperty("target_application_id")] + public Optional TargetApplicationId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateDMChannelParams.cs b/src/Discord.Net.Rest/API/Rest/CreateDMChannelParams.cs new file mode 100644 index 0000000..0a710dd --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateDMChannelParams.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateDMChannelParams + { + [JsonProperty("recipient_id")] + public ulong RecipientId { get; } + + public CreateDMChannelParams(ulong recipientId) + { + RecipientId = recipientId; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateEntitlementParams.cs b/src/Discord.Net.Rest/API/Rest/CreateEntitlementParams.cs new file mode 100644 index 0000000..e6443d0 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateEntitlementParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class CreateEntitlementParams +{ + [JsonProperty("sku_id")] + public ulong SkuId { get; set; } + + [JsonProperty("owner_id")] + public ulong OwnerId { get; set; } + + [JsonProperty("owner_type")] + public SubscriptionOwnerType Type { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateGuildBanParams.cs b/src/Discord.Net.Rest/API/Rest/CreateGuildBanParams.cs new file mode 100644 index 0000000..e7e9be9 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateGuildBanParams.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class CreateGuildBanParams +{ + [JsonProperty("delete_message_seconds")] + public uint DeleteMessageSeconds { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateGuildChannelParams.cs b/src/Discord.Net.Rest/API/Rest/CreateGuildChannelParams.cs new file mode 100644 index 0000000..9035ad1 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateGuildChannelParams.cs @@ -0,0 +1,58 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateGuildChannelParams + { + [JsonProperty("name")] + public string Name { get; } + [JsonProperty("type")] + public ChannelType Type { get; } + [JsonProperty("parent_id")] + public Optional CategoryId { get; set; } + [JsonProperty("position")] + public Optional Position { get; set; } + [JsonProperty("permission_overwrites")] + public Optional Overwrites { get; set; } + + //Text channels + [JsonProperty("topic")] + public Optional Topic { get; set; } + [JsonProperty("nsfw")] + public Optional IsNsfw { get; set; } + [JsonProperty("rate_limit_per_user")] + public Optional SlowModeInterval { get; set; } + [JsonProperty("default_auto_archive_duration")] + public Optional DefaultAutoArchiveDuration { get; set; } + + //Voice channels + [JsonProperty("bitrate")] + public Optional Bitrate { get; set; } + [JsonProperty("user_limit")] + public Optional UserLimit { get; set; } + [JsonProperty("video_quality_mode")] + public Optional VideoQuality { get; set; } + [JsonProperty("rtc_region")] + public Optional RtcRegion { get; set; } + + //Forum channels + [JsonProperty("default_reaction_emoji")] + public Optional DefaultReactionEmoji { get; set; } + [JsonProperty("default_thread_rate_limit_per_user")] + public Optional ThreadRateLimitPerUser { get; set; } + [JsonProperty("available_tags")] + public Optional AvailableTags { get; set; } + [JsonProperty("default_sort_order")] + public Optional DefaultSortOrder { get; set; } + + [JsonProperty("default_forum_layout")] + public Optional DefaultLayout { get; set; } + + public CreateGuildChannelParams(string name, ChannelType type) + { + Name = name; + Type = type; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateGuildEmoteParams.cs b/src/Discord.Net.Rest/API/Rest/CreateGuildEmoteParams.cs new file mode 100644 index 0000000..c81f62f --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateGuildEmoteParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateGuildEmoteParams + { + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("image")] + public Image Image { get; set; } + [JsonProperty("roles")] + public Optional RoleIds { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateGuildIntegrationParams.cs b/src/Discord.Net.Rest/API/Rest/CreateGuildIntegrationParams.cs new file mode 100644 index 0000000..7358e52 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateGuildIntegrationParams.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateGuildIntegrationParams + { + [JsonProperty("id")] + public ulong Id { get; } + [JsonProperty("type")] + public string Type { get; } + + public CreateGuildIntegrationParams(ulong id, string type) + { + Id = id; + Type = type; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateGuildParams.cs b/src/Discord.Net.Rest/API/Rest/CreateGuildParams.cs new file mode 100644 index 0000000..e89c2b1 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateGuildParams.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateGuildParams + { + [JsonProperty("name")] + public string Name { get; } + [JsonProperty("region")] + public string RegionId { get; } + + [JsonProperty("icon")] + public Optional Icon { get; set; } + + public CreateGuildParams(string name, string regionId) + { + Name = name; + RegionId = regionId; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateGuildScheduledEventParams.cs b/src/Discord.Net.Rest/API/Rest/CreateGuildScheduledEventParams.cs new file mode 100644 index 0000000..2ccd06f --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateGuildScheduledEventParams.cs @@ -0,0 +1,31 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class CreateGuildScheduledEventParams + { + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + [JsonProperty("entity_metadata")] + public Optional EntityMetadata { get; set; } + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("privacy_level")] + public GuildScheduledEventPrivacyLevel PrivacyLevel { get; set; } + [JsonProperty("scheduled_start_time")] + public DateTimeOffset StartTime { get; set; } + [JsonProperty("scheduled_end_time")] + public Optional EndTime { get; set; } + [JsonProperty("description")] + public Optional Description { get; set; } + [JsonProperty("entity_type")] + public GuildScheduledEventType Type { get; set; } + [JsonProperty("image")] + public Optional Image { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateMessageParams.cs b/src/Discord.Net.Rest/API/Rest/CreateMessageParams.cs new file mode 100644 index 0000000..a79c7f7 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateMessageParams.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateMessageParams + { + [JsonProperty("content")] + public Optional Content { get; set; } + + [JsonProperty("nonce")] + public Optional Nonce { get; set; } + + [JsonProperty("tts")] + public Optional IsTTS { get; set; } + + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + + [JsonProperty("message_reference")] + public Optional MessageReference { get; set; } + + [JsonProperty("components")] + public Optional Components { get; set; } + + [JsonProperty("sticker_ids")] + public Optional Stickers { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateMultipartPostAsync.cs b/src/Discord.Net.Rest/API/Rest/CreateMultipartPostAsync.cs new file mode 100644 index 0000000..e1cd383 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateMultipartPostAsync.cs @@ -0,0 +1,100 @@ +using Discord.Net.Converters; +using Discord.Net.Rest; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class CreateMultipartPostAsync + { + private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + + public FileAttachment[] Files { get; } + + public string Title { get; set; } + public ThreadArchiveDuration ArchiveDuration { get; set; } + public Optional Slowmode { get; set; } + + + public Optional Content { get; set; } + public Optional Embeds { get; set; } + public Optional AllowedMentions { get; set; } + public Optional MessageComponent { get; set; } + public Optional Flags { get; set; } + public Optional Stickers { get; set; } + public Optional TagIds { get; set; } + + public CreateMultipartPostAsync(params FileAttachment[] attachments) + { + Files = attachments; + } + + public IReadOnlyDictionary ToDictionary() + { + var d = new Dictionary(); + + var payload = new Dictionary(); + var message = new Dictionary(); + + payload["name"] = Title; + payload["auto_archive_duration"] = ArchiveDuration; + + if (Slowmode.IsSpecified) + payload["rate_limit_per_user"] = Slowmode.Value; + if (TagIds.IsSpecified) + payload["applied_tags"] = TagIds.Value; + + // message + if (Content.IsSpecified) + message["content"] = Content.Value; + if (Embeds.IsSpecified) + message["embeds"] = Embeds.Value; + if (AllowedMentions.IsSpecified) + message["allowed_mentions"] = AllowedMentions.Value; + if (MessageComponent.IsSpecified) + message["components"] = MessageComponent.Value; + if (Stickers.IsSpecified) + message["sticker_ids"] = Stickers.Value; + if (Flags.IsSpecified) + message["flags"] = Flags.Value; + + List attachments = new(); + + for (int n = 0; n != Files.Length; n++) + { + var attachment = Files[n]; + + var filename = attachment.FileName ?? "unknown.dat"; + if (attachment.IsSpoiler && !filename.StartsWith(AttachmentExtensions.SpoilerPrefix)) + filename = filename.Insert(0, AttachmentExtensions.SpoilerPrefix); + d[$"files[{n}]"] = new MultipartFile(attachment.Stream, filename); + + attachments.Add(new + { + id = (ulong)n, + filename = filename, + description = attachment.Description ?? Optional.Unspecified, + is_thumbnail = attachment.IsThumbnail, + }); + } + + message["attachments"] = attachments; + + payload["message"] = message; + + var json = new StringBuilder(); + using (var text = new StringWriter(json)) + using (var writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, payload); + + d["payload_json"] = json.ToString(); + + return d; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreatePostParams.cs b/src/Discord.Net.Rest/API/Rest/CreatePostParams.cs new file mode 100644 index 0000000..d74678f --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreatePostParams.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class CreatePostParams + { + // thread + [JsonProperty("name")] + public string Title { get; set; } + + [JsonProperty("auto_archive_duration")] + public ThreadArchiveDuration ArchiveDuration { get; set; } + + [JsonProperty("rate_limit_per_user")] + public Optional Slowmode { get; set; } + + [JsonProperty("message")] + public ForumThreadMessage Message { get; set; } + + [JsonProperty("applied_tags")] + public Optional Tags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateStageInstanceParams.cs b/src/Discord.Net.Rest/API/Rest/CreateStageInstanceParams.cs new file mode 100644 index 0000000..a1d59bb --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateStageInstanceParams.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class CreateStageInstanceParams + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + [JsonProperty("topic")] + public string Topic { get; set; } + + [JsonProperty("privacy_level")] + public Optional PrivacyLevel { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs b/src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs new file mode 100644 index 0000000..5ad8abe --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateStickerParams.cs @@ -0,0 +1,47 @@ +using Discord.Net.Rest; + +using System.Collections.Generic; +using System.IO; +namespace Discord.API.Rest +{ + internal class CreateStickerParams + { + public Stream File { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public string Tags { get; set; } + public string FileName { get; set; } + + public IReadOnlyDictionary ToDictionary() + { + var d = new Dictionary + { + ["name"] = $"{Name}", + ["tags"] = Tags + }; + + if (Description is not null) + d["description"] = Description; + else + d["description"] = string.Empty; + + string contentType; + if (File is FileStream fileStream) + { + var extension = Path.GetExtension(fileStream.Name).TrimStart('.'); + contentType = extension == "json" ? "application/json" : $"image/{extension}"; + } + else if (FileName != null) + { + var extension = Path.GetExtension(FileName).TrimStart('.'); + contentType = extension == "json" ? "application/json" : $"image/{extension}"; + } + else + contentType = "image/png"; + + d["file"] = new MultipartFile(File, FileName ?? "image", contentType); + + return d; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateWebhookMessageParams.cs b/src/Discord.Net.Rest/API/Rest/CreateWebhookMessageParams.cs new file mode 100644 index 0000000..aac5a21 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateWebhookMessageParams.cs @@ -0,0 +1,94 @@ +using Discord.Net.Converters; +using Discord.Net.Rest; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateWebhookMessageParams + { + private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + + [JsonProperty("content")] + public Optional Content { get; set; } + + [JsonProperty("nonce")] + public Optional Nonce { get; set; } + + [JsonProperty("tts")] + public Optional IsTTS { get; set; } + + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + + [JsonProperty("username")] + public Optional Username { get; set; } + + [JsonProperty("avatar_url")] + public Optional AvatarUrl { get; set; } + + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + + [JsonProperty("components")] + public Optional Components { get; set; } + + [JsonProperty("file")] + public Optional File { get; set; } + + [JsonProperty("thread_name")] + public Optional ThreadName { get; set; } + + [JsonProperty("applied_tags")] + public Optional AppliedTags { get; set; } + + public IReadOnlyDictionary ToDictionary() + { + var d = new Dictionary(); + + if (File.IsSpecified) + { + d["file"] = File.Value; + } + + var payload = new Dictionary + { + ["content"] = Content + }; + + if (IsTTS.IsSpecified) + payload["tts"] = IsTTS.Value.ToString(); + if (Nonce.IsSpecified) + payload["nonce"] = Nonce.Value; + if (Username.IsSpecified) + payload["username"] = Username.Value; + if (AvatarUrl.IsSpecified) + payload["avatar_url"] = AvatarUrl.Value; + if (Embeds.IsSpecified) + payload["embeds"] = Embeds.Value; + if (AllowedMentions.IsSpecified) + payload["allowed_mentions"] = AllowedMentions.Value; + if (Components.IsSpecified) + payload["components"] = Components.Value; + if (ThreadName.IsSpecified) + payload["thread_name"] = ThreadName.Value; + if (AppliedTags.IsSpecified) + payload["applied_tags"] = AppliedTags.Value; + + var json = new StringBuilder(); + using (var text = new StringWriter(json)) + using (var writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, payload); + + d["payload_json"] = json.ToString(); + + return d; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateWebhookParams.cs b/src/Discord.Net.Rest/API/Rest/CreateWebhookParams.cs new file mode 100644 index 0000000..242f451 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateWebhookParams.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class CreateWebhookParams + { + [JsonProperty("name")] + public string Name { get; set; } + [JsonProperty("avatar")] + public Optional Avatar { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/DeleteMessagesParams.cs b/src/Discord.Net.Rest/API/Rest/DeleteMessagesParams.cs new file mode 100644 index 0000000..ca6b784 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/DeleteMessagesParams.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class DeleteMessagesParams + { + [JsonProperty("messages")] + public ulong[] MessageIds { get; } + + public DeleteMessagesParams(ulong[] messageIds) + { + MessageIds = messageIds; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetAuditLogsParams.cs b/src/Discord.Net.Rest/API/Rest/GetAuditLogsParams.cs new file mode 100644 index 0000000..a697db3 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetAuditLogsParams.cs @@ -0,0 +1,11 @@ +namespace Discord.API.Rest +{ + class GetAuditLogsParams + { + public Optional Limit { get; set; } + public Optional BeforeEntryId { get; set; } + public Optional AfterEntryId { get; set; } + public Optional UserId { get; set; } + public Optional ActionType { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetBotGatewayResponse.cs b/src/Discord.Net.Rest/API/Rest/GetBotGatewayResponse.cs new file mode 100644 index 0000000..3f8318c --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetBotGatewayResponse.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class GetBotGatewayResponse + { + [JsonProperty("url")] + public string Url { get; set; } + [JsonProperty("shards")] + public int Shards { get; set; } + [JsonProperty("session_start_limit")] + public SessionStartLimit SessionStartLimit { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetChannelMessagesParams.cs b/src/Discord.Net.Rest/API/Rest/GetChannelMessagesParams.cs new file mode 100644 index 0000000..52dd848 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetChannelMessagesParams.cs @@ -0,0 +1,9 @@ +namespace Discord.API.Rest +{ + internal class GetChannelMessagesParams + { + public Optional Limit { get; set; } + public Optional RelativeDirection { get; set; } + public Optional RelativeMessageId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetEventUsersParams.cs b/src/Discord.Net.Rest/API/Rest/GetEventUsersParams.cs new file mode 100644 index 0000000..db3ac66 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetEventUsersParams.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class GetEventUsersParams + { + public Optional Limit { get; set; } + public Optional RelativeDirection { get; set; } + public Optional RelativeUserId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetGatewayResponse.cs b/src/Discord.Net.Rest/API/Rest/GetGatewayResponse.cs new file mode 100644 index 0000000..1120763 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetGatewayResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class GetGatewayResponse + { + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetGuildBansParams.cs b/src/Discord.Net.Rest/API/Rest/GetGuildBansParams.cs new file mode 100644 index 0000000..6a1e430 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetGuildBansParams.cs @@ -0,0 +1,9 @@ +namespace Discord.API.Rest +{ + internal class GetGuildBansParams + { + public Optional Limit { get; set; } + public Optional RelativeDirection { get; set; } + public Optional RelativeUserId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetGuildMembersParams.cs b/src/Discord.Net.Rest/API/Rest/GetGuildMembersParams.cs new file mode 100644 index 0000000..67d3800 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetGuildMembersParams.cs @@ -0,0 +1,8 @@ +namespace Discord.API.Rest +{ + internal class GetGuildMembersParams + { + public Optional Limit { get; set; } + public Optional AfterUserId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetGuildPruneCountResponse.cs b/src/Discord.Net.Rest/API/Rest/GetGuildPruneCountResponse.cs new file mode 100644 index 0000000..1e7fc8c --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetGuildPruneCountResponse.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class GetGuildPruneCountResponse + { + [JsonProperty("pruned")] + public int Pruned { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetGuildSummariesParams.cs b/src/Discord.Net.Rest/API/Rest/GetGuildSummariesParams.cs new file mode 100644 index 0000000..1d3f70f --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetGuildSummariesParams.cs @@ -0,0 +1,8 @@ +namespace Discord.API.Rest +{ + internal class GetGuildSummariesParams + { + public Optional Limit { get; set; } + public Optional AfterGuildId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GetReactionUsersParams.cs b/src/Discord.Net.Rest/API/Rest/GetReactionUsersParams.cs new file mode 100644 index 0000000..a0967bb --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GetReactionUsersParams.cs @@ -0,0 +1,8 @@ +namespace Discord.API.Rest +{ + internal class GetReactionUsersParams + { + public Optional Limit { get; set; } + public Optional AfterUserId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/GuildPruneParams.cs b/src/Discord.Net.Rest/API/Rest/GuildPruneParams.cs new file mode 100644 index 0000000..c6caa1e --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/GuildPruneParams.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class GuildPruneParams + { + [JsonProperty("days")] + public int Days { get; } + + [JsonProperty("include_roles")] + public ulong[] IncludeRoleIds { get; } + + public GuildPruneParams(int days, ulong[] includeRoleIds) + { + Days = days; + IncludeRoleIds = includeRoleIds; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ListEntitlementsParams.cs b/src/Discord.Net.Rest/API/Rest/ListEntitlementsParams.cs new file mode 100644 index 0000000..6e569ef --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ListEntitlementsParams.cs @@ -0,0 +1,18 @@ +namespace Discord.API.Rest; + +internal class ListEntitlementsParams +{ + public Optional UserId { get; set; } + + public Optional SkuIds { get; set; } + + public Optional BeforeId { get; set; } + + public Optional AfterId { get; set; } + + public Optional Limit { get; set; } + + public Optional GuildId { get; set; } + + public Optional ExcludeEnded { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs new file mode 100644 index 0000000..ddc30e6 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyApplicationCommandParams.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API.Rest +{ + internal class ModifyApplicationCommandParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + + [JsonProperty("description")] + public Optional Description { get; set; } + + [JsonProperty("options")] + public Optional Options { get; set; } + + [JsonProperty("default_permission")] + public Optional DefaultPermission { get; set; } + + [JsonProperty("nsfw")] + public Optional Nsfw { get; set; } + + [JsonProperty("default_member_permissions")] + public Optional DefaultMemberPermission { get; set; } + + [JsonProperty("name_localizations")] + public Optional> NameLocalizations { get; set; } + + [JsonProperty("description_localizations")] + public Optional> DescriptionLocalizations { get; set; } + + [JsonProperty("contexts")] + public Optional> ContextTypes { get; set; } + + [JsonProperty("integration_types")] + public Optional> IntegrationTypes { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyAutoModRuleParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyAutoModRuleParams.cs new file mode 100644 index 0000000..6f275d4 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyAutoModRuleParams.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class ModifyAutoModRuleParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + + [JsonProperty("event_type")] + public Optional EventType { get; set; } + + [JsonProperty("trigger_type")] + public Optional TriggerType { get; set; } + + [JsonProperty("trigger_metadata")] + public Optional TriggerMetadata { get; set; } + + [JsonProperty("actions")] + public Optional Actions { get; set; } + + [JsonProperty("enabled")] + public Optional Enabled { get; set; } + + [JsonProperty("exempt_roles")] + public Optional ExemptRoles { get; set; } + + [JsonProperty("exempt_channels")] + public Optional ExemptChannels { get; set; } + } +} + diff --git a/src/Discord.Net.Rest/API/Rest/ModifyChannelPermissionsParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyChannelPermissionsParams.cs new file mode 100644 index 0000000..acb8103 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyChannelPermissionsParams.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyChannelPermissionsParams + { + [JsonProperty("type")] + public int Type { get; } + [JsonProperty("allow")] + public string Allow { get; } + [JsonProperty("deny")] + public string Deny { get; } + + public ModifyChannelPermissionsParams(int type, string allow, string deny) + { + Type = type; + Allow = allow; + Deny = deny; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyCurrentApplicationBotParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyCurrentApplicationBotParams.cs new file mode 100644 index 0000000..dfa95db --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyCurrentApplicationBotParams.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API.Rest; + +internal class ModifyCurrentApplicationBotParams +{ + [JsonProperty("custom_install_url")] + public Optional CustomInstallUrl { get; set; } + + [JsonProperty("description")] + public Optional Description { get; set; } + + [JsonProperty("role_connections_verification_url")] + public Optional RoleConnectionsEndpointUrl { get; set; } + + [JsonProperty("install_params")] + public Optional InstallParams { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + + [JsonProperty("icon")] + public Optional Icon { get; set; } + + [JsonProperty("cover_image")] + public Optional CoverImage { get; set; } + + [JsonProperty("interactions_endpoint_url")] + public Optional InteractionsEndpointUrl { get; set; } + + [JsonProperty("tags")] + public Optional Tags { get; set; } + + [JsonProperty("integration_types_config")] + public Optional> IntegrationTypesConfig { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyCurrentUserNickParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyCurrentUserNickParams.cs new file mode 100644 index 0000000..c10f2e4 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyCurrentUserNickParams.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyCurrentUserNickParams + { + [JsonProperty("nick")] + public string Nickname { get; } + + public ModifyCurrentUserNickParams(string nickname) + { + Nickname = nickname; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyCurrentUserParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyCurrentUserParams.cs new file mode 100644 index 0000000..4e18543 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyCurrentUserParams.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyCurrentUserParams + { + [JsonProperty("username")] + public Optional Username { get; set; } + [JsonProperty("avatar")] + public Optional Avatar { get; set; } + + [JsonProperty("banner")] + public Optional Banner { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyForumChannelParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyForumChannelParams.cs new file mode 100644 index 0000000..4162709 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyForumChannelParams.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + + +[JsonObject(MemberSerialization = MemberSerialization.OptIn)] +internal class ModifyForumChannelParams : ModifyTextChannelParams +{ + [JsonProperty("available_tags")] + public Optional Tags { get; set; } + + [JsonProperty("rate_limit_per_user")] + public Optional ThreadCreationInterval { get; set; } + + [JsonProperty("default_reaction_emoji")] + public Optional DefaultReactionEmoji { get; set; } + + [JsonProperty("default_sort_order")] + public Optional DefaultSortOrder { get; set; } + + [JsonProperty("default_forum_layout")] + public Optional DefaultLayout { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyForumReactionEmojiParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyForumReactionEmojiParams.cs new file mode 100644 index 0000000..c8540fd --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyForumReactionEmojiParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +[JsonObject(MemberSerialization = MemberSerialization.OptIn)] +internal class ModifyForumReactionEmojiParams +{ + [JsonProperty("emoji_id")] + public Optional EmojiId { get; set; } + + [JsonProperty("emoji_name")] + public Optional EmojiName { get; set; } +} + + diff --git a/src/Discord.Net.Rest/API/Rest/ModifyForumTagParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyForumTagParams.cs new file mode 100644 index 0000000..4d792d0 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyForumTagParams.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyForumTagParams + { + [JsonProperty("id")] + public Optional Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("emoji_id")] + public Optional EmojiId { get; set; } + + [JsonProperty("emoji_name")] + public Optional EmojiName { get; set; } + + [JsonProperty("moderated")] + public bool Moderated { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildApplicationCommandPermissions.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildApplicationCommandPermissions.cs new file mode 100644 index 0000000..a557061 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildApplicationCommandPermissions.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class ModifyGuildApplicationCommandPermissions + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("permissions")] + public ApplicationCommandPermissions[] Permissions { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildApplicationCommandPermissionsParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildApplicationCommandPermissionsParams.cs new file mode 100644 index 0000000..322875b --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildApplicationCommandPermissionsParams.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class ModifyGuildApplicationCommandPermissionsParams + { + [JsonProperty("permissions")] + public ApplicationCommandPermissions[] Permissions { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelParams.cs new file mode 100644 index 0000000..dea0c03 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelParams.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildChannelParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("position")] + public Optional Position { get; set; } + [JsonProperty("parent_id")] + public Optional CategoryId { get; set; } + [JsonProperty("permission_overwrites")] + public Optional Overwrites { get; set; } + [JsonProperty("flags")] + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelsParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelsParams.cs new file mode 100644 index 0000000..91567be --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildChannelsParams.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildChannelsParams + { + [JsonProperty("id")] + public ulong Id { get; } + [JsonProperty("position")] + public int Position { get; } + + public ModifyGuildChannelsParams(ulong id, int position) + { + Id = id; + Position = position; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildEmbedParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildEmbedParams.cs new file mode 100644 index 0000000..cf78abf --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildEmbedParams.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildEmbedParams + { + [JsonProperty("enabled")] + public Optional Enabled { get; set; } + [JsonProperty("channel")] + public Optional ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildEmoteParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildEmoteParams.cs new file mode 100644 index 0000000..08b196d --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildEmoteParams.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildEmoteParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("roles")] + public Optional RoleIds { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildIncidentsDataParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildIncidentsDataParams.cs new file mode 100644 index 0000000..1d3302e --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildIncidentsDataParams.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Rest; + +internal class ModifyGuildIncidentsDataParams +{ + [JsonProperty("invites_disabled_until")] + public Optional InvitesDisabledUntil { get; set; } + + [JsonProperty("dms_disabled_until")] + public Optional DmsDisabledUntil { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildIntegrationParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildIntegrationParams.cs new file mode 100644 index 0000000..cf869c8 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildIntegrationParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildIntegrationParams + { + [JsonProperty("expire_behavior")] + public Optional ExpireBehavior { get; set; } + [JsonProperty("expire_grace_period")] + public Optional ExpireGracePeriod { get; set; } + [JsonProperty("enable_emoticons")] + public Optional EnableEmoticons { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildMemberParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildMemberParams.cs new file mode 100644 index 0000000..ad2b91f --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildMemberParams.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildMemberParams + { + [JsonProperty("mute")] + public Optional Mute { get; set; } + [JsonProperty("deaf")] + public Optional Deaf { get; set; } + [JsonProperty("nick")] + public Optional Nickname { get; set; } + [JsonProperty("roles")] + public Optional RoleIds { get; set; } + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + [JsonProperty("communication_disabled_until")] + public Optional TimedOutUntil { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildOnboardingParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildOnboardingParams.cs new file mode 100644 index 0000000..f0a5a65 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildOnboardingParams.cs @@ -0,0 +1,71 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class ModifyGuildOnboardingParams +{ + [JsonProperty("prompts")] + public Optional Prompts { get; set; } + + [JsonProperty("default_channel_ids")] + public Optional DefaultChannelIds { get; set; } + + [JsonProperty("enabled")] + public Optional Enabled { get; set; } + + [JsonProperty("mode")] + public Optional Mode { get; set; } +} + + +internal class GuildOnboardingPromptParams +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("options")] + public GuildOnboardingPromptOptionParams[] Options { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("single_select")] + public bool IsSingleSelect { get; set; } + + [JsonProperty("required")] + public bool IsRequired { get; set; } + + [JsonProperty("in_onboarding")] + public bool IsInOnboarding { get; set; } + + [JsonProperty("type")] + public GuildOnboardingPromptType Type { get; set; } +} + + +internal class GuildOnboardingPromptOptionParams +{ + [JsonProperty("id")] + public Optional Id { get; set; } + + [JsonProperty("channel_ids")] + public ulong[] ChannelIds { get; set; } + + [JsonProperty("role_ids")] + public ulong[] RoleIds { get; set; } + + [JsonProperty("emoji_name")] + public string EmojiName { get; set; } + + [JsonProperty("emoji_id")] + public ulong? EmojiId { get; set; } + + [JsonProperty("emoji_animated")] + public bool? EmojiAnimated { get; set; } + + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildParams.cs new file mode 100644 index 0000000..a3bc8b7 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildParams.cs @@ -0,0 +1,45 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildParams + { + [JsonProperty("username")] + public Optional Username { get; set; } + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("region")] + public Optional RegionId { get; set; } + [JsonProperty("verification_level")] + public Optional VerificationLevel { get; set; } + [JsonProperty("default_message_notifications")] + public Optional DefaultMessageNotifications { get; set; } + [JsonProperty("afk_timeout")] + public Optional AfkTimeout { get; set; } + [JsonProperty("system_channel_id")] + public Optional SystemChannelId { get; set; } + [JsonProperty("icon")] + public Optional Icon { get; set; } + [JsonProperty("banner")] + public Optional Banner { get; set; } + [JsonProperty("splash")] + public Optional Splash { get; set; } + [JsonProperty("afk_channel_id")] + public Optional AfkChannelId { get; set; } + [JsonProperty("safety_alerts_channel_id")] + public Optional SafetyAlertsChannelId { get; set; } + [JsonProperty("owner_id")] + public Optional OwnerId { get; set; } + [JsonProperty("explicit_content_filter")] + public Optional ExplicitContentFilter { get; set; } + [JsonProperty("system_channel_flags")] + public Optional SystemChannelFlags { get; set; } + [JsonProperty("preferred_locale")] + public Optional PreferredLocale { get; set; } + [JsonProperty("premium_progress_bar_enabled")] + public Optional IsBoostProgressBarEnabled { get; set; } + [JsonProperty("features")] + public Optional GuildFeatures { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildRoleParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildRoleParams.cs new file mode 100644 index 0000000..8aa92d4 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildRoleParams.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildRoleParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("permissions")] + public Optional Permissions { get; set; } + [JsonProperty("color")] + public Optional Color { get; set; } + [JsonProperty("hoist")] + public Optional Hoist { get; set; } + [JsonProperty("icon")] + public Optional Icon { get; set; } + [JsonProperty("unicode_emoji")] + public Optional Emoji { get; set; } + [JsonProperty("mentionable")] + public Optional Mentionable { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildRolesParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildRolesParams.cs new file mode 100644 index 0000000..eeb7245 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildRolesParams.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildRolesParams : ModifyGuildRoleParams + { + [JsonProperty("id")] + public ulong Id { get; } + [JsonProperty("position")] + public int Position { get; } + + public ModifyGuildRolesParams(ulong id, int position) + { + Id = id; + Position = position; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildScheduledEventParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildScheduledEventParams.cs new file mode 100644 index 0000000..1179ddc --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildScheduledEventParams.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class ModifyGuildScheduledEventParams + { + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + [JsonProperty("entity_metadata")] + public Optional EntityMetadata { get; set; } + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("privacy_level")] + public Optional PrivacyLevel { get; set; } + [JsonProperty("scheduled_start_time")] + public Optional StartTime { get; set; } + [JsonProperty("scheduled_end_time")] + public Optional EndTime { get; set; } + [JsonProperty("description")] + public Optional Description { get; set; } + [JsonProperty("entity_type")] + public Optional Type { get; set; } + [JsonProperty("status")] + public Optional Status { get; set; } + [JsonProperty("image")] + public Optional Image { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildWelcomeScreenParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildWelcomeScreenParams.cs new file mode 100644 index 0000000..cf22bd9 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildWelcomeScreenParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class ModifyGuildWelcomeScreenParams +{ + [JsonProperty("enabled")] + public Optional Enabled { get; set; } + + [JsonProperty("welcome_channels")] + public Optional WelcomeChannels { get; set; } + + [JsonProperty("description")] + public Optional Description { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyGuildWidgetParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyGuildWidgetParams.cs new file mode 100644 index 0000000..a1c1a50 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyGuildWidgetParams.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyGuildWidgetParams + { + [JsonProperty("enabled")] + public Optional Enabled { get; set; } + [JsonProperty("channel")] + public Optional ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyInteractionResponseParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyInteractionResponseParams.cs new file mode 100644 index 0000000..a2c7cbe --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyInteractionResponseParams.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class ModifyInteractionResponseParams + { + [JsonProperty("content")] + public Optional Content { get; set; } + + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + + [JsonProperty("components")] + public Optional Components { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyMessageParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyMessageParams.cs new file mode 100644 index 0000000..3dba45a --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyMessageParams.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyMessageParams + { + [JsonProperty("content")] + public Optional Content { get; set; } + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + [JsonProperty("components")] + public Optional Components { get; set; } + [JsonProperty("flags")] + public Optional Flags { get; set; } + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyStageInstanceParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyStageInstanceParams.cs new file mode 100644 index 0000000..c09d8f2 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyStageInstanceParams.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class ModifyStageInstanceParams + { + [JsonProperty("topic")] + public Optional Topic { get; set; } + + [JsonProperty("privacy_level")] + public Optional PrivacyLevel { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyStickerParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyStickerParams.cs new file mode 100644 index 0000000..bd538c7 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyStickerParams.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class ModifyStickerParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("description")] + public Optional Description { get; set; } + [JsonProperty("tags")] + public Optional Tags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyTextChannelParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyTextChannelParams.cs new file mode 100644 index 0000000..7884d74 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyTextChannelParams.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyTextChannelParams : ModifyGuildChannelParams + { + [JsonProperty("topic")] + public Optional Topic { get; set; } + + [JsonProperty("nsfw")] + public Optional IsNsfw { get; set; } + + [JsonProperty("rate_limit_per_user")] + public Optional SlowModeInterval { get; set; } + + [JsonProperty("default_thread_rate_limit_per_user")] + public Optional DefaultSlowModeInterval { get; set; } + + [JsonProperty("type")] + public Optional Type { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyThreadParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyThreadParams.cs new file mode 100644 index 0000000..bd651b2 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyThreadParams.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API.Rest +{ + internal class ModifyThreadParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + + [JsonProperty("archived")] + public Optional Archived { get; set; } + + [JsonProperty("auto_archive_duration")] + public Optional AutoArchiveDuration { get; set; } + + [JsonProperty("locked")] + public Optional Locked { get; set; } + + [JsonProperty("rate_limit_per_user")] + public Optional Slowmode { get; set; } + + [JsonProperty("applied_tags")] + public Optional> AppliedTags { get; set; } + + [JsonProperty("flags")] + public Optional Flags { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyVoiceChannelParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyVoiceChannelParams.cs new file mode 100644 index 0000000..0f0c6cc --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyVoiceChannelParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyVoiceChannelParams : ModifyTextChannelParams + { + [JsonProperty("bitrate")] + public Optional Bitrate { get; set; } + [JsonProperty("user_limit")] + public Optional UserLimit { get; set; } + [JsonProperty("rtc_region")] + public Optional RTCRegion { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyVoiceStateParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyVoiceStateParams.cs new file mode 100644 index 0000000..1ff0f3e --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyVoiceStateParams.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Rest +{ + internal class ModifyVoiceStateParams + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + [JsonProperty("suppress")] + public Optional Suppressed { get; set; } + + [JsonProperty("request_to_speak_timestamp")] + public Optional RequestToSpeakTimestamp { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyVoiceStatusParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyVoiceStatusParams.cs new file mode 100644 index 0000000..179e8aa --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyVoiceStatusParams.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class ModifyVoiceStatusParams +{ + [JsonProperty("status")] + public string Status { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyWebhookMessageParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyWebhookMessageParams.cs new file mode 100644 index 0000000..e73efaf --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyWebhookMessageParams.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyWebhookMessageParams + { + [JsonProperty("content")] + public Optional Content { get; set; } + [JsonProperty("embeds")] + public Optional Embeds { get; set; } + [JsonProperty("allowed_mentions")] + public Optional AllowedMentions { get; set; } + [JsonProperty("components")] + public Optional Components { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/ModifyWebhookParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyWebhookParams.cs new file mode 100644 index 0000000..2e4e6a4 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyWebhookParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ModifyWebhookParams + { + [JsonProperty("name")] + public Optional Name { get; set; } + [JsonProperty("avatar")] + public Optional Avatar { get; set; } + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/SearchGuildMembersParams.cs b/src/Discord.Net.Rest/API/Rest/SearchGuildMembersParams.cs new file mode 100644 index 0000000..56b3595 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/SearchGuildMembersParams.cs @@ -0,0 +1,8 @@ +namespace Discord.API.Rest +{ + internal class SearchGuildMembersParams + { + public string Query { get; set; } + public Optional Limit { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/StartThreadParams.cs b/src/Discord.Net.Rest/API/Rest/StartThreadParams.cs new file mode 100644 index 0000000..a13161c --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/StartThreadParams.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest +{ + internal class StartThreadParams + { + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("auto_archive_duration")] + public ThreadArchiveDuration Duration { get; set; } + + [JsonProperty("type")] + public ThreadType Type { get; set; } + + [JsonProperty("invitable")] + public Optional Invitable { get; set; } + + [JsonProperty("rate_limit_per_user")] + public Optional Ratelimit { get; set; } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/UploadFileParams.cs b/src/Discord.Net.Rest/API/Rest/UploadFileParams.cs new file mode 100644 index 0000000..82725c9 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/UploadFileParams.cs @@ -0,0 +1,87 @@ +using Discord.Net.Converters; +using Discord.Net.Rest; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +namespace Discord.API.Rest +{ + internal class UploadFileParams + { + private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + + public FileAttachment[] Files { get; } + + public Optional Content { get; set; } + public Optional Nonce { get; set; } + public Optional IsTTS { get; set; } + public Optional Embeds { get; set; } + public Optional AllowedMentions { get; set; } + public Optional MessageReference { get; set; } + public Optional MessageComponent { get; set; } + public Optional Flags { get; set; } + public Optional Stickers { get; set; } + + public UploadFileParams(params Discord.FileAttachment[] attachments) + { + Files = attachments; + } + + public IReadOnlyDictionary ToDictionary() + { + var d = new Dictionary(); + + var payload = new Dictionary(); + if (Content.IsSpecified) + payload["content"] = Content.Value; + if (IsTTS.IsSpecified) + payload["tts"] = IsTTS.Value; + if (Nonce.IsSpecified) + payload["nonce"] = Nonce.Value; + if (Embeds.IsSpecified) + payload["embeds"] = Embeds.Value; + if (AllowedMentions.IsSpecified) + payload["allowed_mentions"] = AllowedMentions.Value; + if (MessageComponent.IsSpecified) + payload["components"] = MessageComponent.Value; + if (MessageReference.IsSpecified) + payload["message_reference"] = MessageReference.Value; + if (Stickers.IsSpecified) + payload["sticker_ids"] = Stickers.Value; + if (Flags.IsSpecified) + payload["flags"] = Flags.Value; + + List attachments = new(); + + for (int n = 0; n != Files.Length; n++) + { + var attachment = Files[n]; + + var filename = attachment.FileName ?? "unknown.dat"; + if (attachment.IsSpoiler && !filename.StartsWith(AttachmentExtensions.SpoilerPrefix)) + filename = filename.Insert(0, AttachmentExtensions.SpoilerPrefix); + d[$"files[{n}]"] = new MultipartFile(attachment.Stream, filename); + + attachments.Add(new + { + id = (ulong)n, + filename = filename, + description = attachment.Description ?? Optional.Unspecified + }); + } + + payload["attachments"] = attachments; + + var json = new StringBuilder(); + using (var text = new StringWriter(json)) + using (var writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, payload); + + d["payload_json"] = json.ToString(); + + return d; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/UploadInteractionFileParams.cs b/src/Discord.Net.Rest/API/Rest/UploadInteractionFileParams.cs new file mode 100644 index 0000000..eda650c --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/UploadInteractionFileParams.cs @@ -0,0 +1,99 @@ +using Discord.Net.Converters; +using Discord.Net.Rest; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Rest +{ + internal class UploadInteractionFileParams + { + private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + + public FileAttachment[] Files { get; } + + public InteractionResponseType Type { get; set; } + public Optional Content { get; set; } + public Optional IsTTS { get; set; } + public Optional Embeds { get; set; } + public Optional AllowedMentions { get; set; } + public Optional MessageComponents { get; set; } + public Optional Flags { get; set; } + + public bool HasData + => Content.IsSpecified || + IsTTS.IsSpecified || + Embeds.IsSpecified || + AllowedMentions.IsSpecified || + MessageComponents.IsSpecified || + Flags.IsSpecified || + Files.Any(); + + public UploadInteractionFileParams(params FileAttachment[] files) + { + Files = files; + } + + public IReadOnlyDictionary ToDictionary() + { + var d = new Dictionary(); + + + var payload = new Dictionary(); + payload["type"] = Type; + + var data = new Dictionary(); + if (Content.IsSpecified) + data["content"] = Content.Value; + if (IsTTS.IsSpecified) + data["tts"] = IsTTS.Value; + if (MessageComponents.IsSpecified) + data["components"] = MessageComponents.Value; + if (Embeds.IsSpecified) + data["embeds"] = Embeds.Value; + if (AllowedMentions.IsSpecified) + data["allowed_mentions"] = AllowedMentions.Value; + if (Flags.IsSpecified) + data["flags"] = Flags.Value; + + List attachments = new(); + + for (int n = 0; n != Files.Length; n++) + { + var attachment = Files[n]; + + var filename = attachment.FileName ?? "unknown.dat"; + if (attachment.IsSpoiler && !filename.StartsWith(AttachmentExtensions.SpoilerPrefix)) + filename = filename.Insert(0, AttachmentExtensions.SpoilerPrefix); + d[$"files[{n}]"] = new MultipartFile(attachment.Stream, filename); + + attachments.Add(new + { + id = (ulong)n, + filename = filename, + description = attachment.Description ?? Optional.Unspecified + }); + } + + data["attachments"] = attachments; + + payload["data"] = data; + + + if (data.Any()) + { + var json = new StringBuilder(); + using (var text = new StringWriter(json)) + using (var writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, payload); + d["payload_json"] = json.ToString(); + } + + return d; + } + } +} diff --git a/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs new file mode 100644 index 0000000..22d1d9a --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/UploadWebhookFileParams.cs @@ -0,0 +1,92 @@ +using Discord.Net.Converters; +using Discord.Net.Rest; +using Newtonsoft.Json; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace Discord.API.Rest +{ + internal class UploadWebhookFileParams + { + private static JsonSerializer _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + + public FileAttachment[] Files { get; } + + public Optional Content { get; set; } + public Optional Nonce { get; set; } + public Optional IsTTS { get; set; } + public Optional Username { get; set; } + public Optional AvatarUrl { get; set; } + public Optional Embeds { get; set; } + public Optional AllowedMentions { get; set; } + public Optional MessageComponents { get; set; } + public Optional Flags { get; set; } + public Optional ThreadName { get; set; } + public Optional AppliedTags { get; set; } + + public UploadWebhookFileParams(params FileAttachment[] files) + { + Files = files; + } + + public IReadOnlyDictionary ToDictionary() + { + var d = new Dictionary(); + + var payload = new Dictionary(); + if (Content.IsSpecified) + payload["content"] = Content.Value; + if (IsTTS.IsSpecified) + payload["tts"] = IsTTS.Value; + if (Nonce.IsSpecified) + payload["nonce"] = Nonce.Value; + if (Username.IsSpecified) + payload["username"] = Username.Value; + if (AvatarUrl.IsSpecified) + payload["avatar_url"] = AvatarUrl.Value; + if (MessageComponents.IsSpecified) + payload["components"] = MessageComponents.Value; + if (Embeds.IsSpecified) + payload["embeds"] = Embeds.Value; + if (AllowedMentions.IsSpecified) + payload["allowed_mentions"] = AllowedMentions.Value; + if (Flags.IsSpecified) + payload["flags"] = Flags.Value; + if (ThreadName.IsSpecified) + payload["thread_name"] = ThreadName.Value; + if (AppliedTags.IsSpecified) + payload["applied_tags"] = AppliedTags.Value; + + List attachments = new(); + + for (int n = 0; n != Files.Length; n++) + { + var attachment = Files[n]; + + var filename = attachment.FileName ?? "unknown.dat"; + if (attachment.IsSpoiler && !filename.StartsWith(AttachmentExtensions.SpoilerPrefix)) + filename = filename.Insert(0, AttachmentExtensions.SpoilerPrefix); + d[$"files[{n}]"] = new MultipartFile(attachment.Stream, filename); + + attachments.Add(new + { + id = (ulong)n, + filename = filename, + description = attachment.Description ?? Optional.Unspecified + }); + } + + payload["attachments"] = attachments; + + var json = new StringBuilder(); + using (var text = new StringWriter(json)) + using (var writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, payload); + + d["payload_json"] = json.ToString(); + + return d; + } + } +} diff --git a/src/Discord.Net.Rest/API/UnixTimestampAttribute.cs b/src/Discord.Net.Rest/API/UnixTimestampAttribute.cs new file mode 100644 index 0000000..eac4573 --- /dev/null +++ b/src/Discord.Net.Rest/API/UnixTimestampAttribute.cs @@ -0,0 +1,7 @@ +using System; + +namespace Discord.API +{ + [AttributeUsage(AttributeTargets.Property)] + internal class UnixTimestampAttribute : Attribute { } +} diff --git a/src/Discord.Net.Rest/AssemblyInfo.cs b/src/Discord.Net.Rest/AssemblyInfo.cs new file mode 100644 index 0000000..59e1f0b --- /dev/null +++ b/src/Discord.Net.Rest/AssemblyInfo.cs @@ -0,0 +1,25 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Discord.Net.Rpc")] +[assembly: InternalsVisibleTo("Discord.Net.WebSocket")] +[assembly: InternalsVisibleTo("Discord.Net.Webhook")] +[assembly: InternalsVisibleTo("Discord.Net.Commands")] +[assembly: InternalsVisibleTo("Discord.Net.Tests")] +[assembly: InternalsVisibleTo("Discord.Net.Tests.Unit")] +[assembly: InternalsVisibleTo("Discord.Net.Tests.Integration")] +[assembly: InternalsVisibleTo("Discord.Net.Interactions")] + +[assembly: TypeForwardedTo(typeof(Discord.Embed))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedBuilder))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedBuilderExtensions))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedAuthor))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedAuthorBuilder))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedField))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedFieldBuilder))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedFooter))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedFooterBuilder))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedImage))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedProvider))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedThumbnail))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedType))] +[assembly: TypeForwardedTo(typeof(Discord.EmbedVideo))] diff --git a/src/Discord.Net.Rest/BaseDiscordClient.cs b/src/Discord.Net.Rest/BaseDiscordClient.cs new file mode 100644 index 0000000..d385395 --- /dev/null +++ b/src/Discord.Net.Rest/BaseDiscordClient.cs @@ -0,0 +1,297 @@ +using Discord.Logging; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + public abstract class BaseDiscordClient : IDiscordClient + { + #region BaseDiscordClient + public event Func Log { add { _logEvent.Add(value); } remove { _logEvent.Remove(value); } } + internal readonly AsyncEvent> _logEvent = new AsyncEvent>(); + + public event Func LoggedIn { add { _loggedInEvent.Add(value); } remove { _loggedInEvent.Remove(value); } } + private readonly AsyncEvent> _loggedInEvent = new AsyncEvent>(); + public event Func LoggedOut { add { _loggedOutEvent.Add(value); } remove { _loggedOutEvent.Remove(value); } } + private readonly AsyncEvent> _loggedOutEvent = new AsyncEvent>(); + + internal readonly AsyncEvent> _sentRequest = new(); + /// + /// Fired when a REST request is sent to the API. First parameter is the HTTP method, + /// second is the endpoint, and third is the time taken to complete the request. + /// + public event Func SentRequest { add { _sentRequest.Add(value); } remove { _sentRequest.Remove(value); } } + + internal readonly Logger _restLogger; + private readonly SemaphoreSlim _stateLock; + private bool _isFirstLogin, _isDisposed; + + internal API.DiscordRestApiClient ApiClient { get; } + internal LogManager LogManager { get; } + /// + /// Gets the login state of the client. + /// + public LoginState LoginState { get; private set; } + /// + /// Gets the logged-in user. + /// + public ISelfUser CurrentUser { get; protected set; } + /// + public TokenType TokenType => ApiClient.AuthTokenType; + internal bool UseInteractionSnowflakeDate { get; private set; } + internal bool FormatUsersInBidirectionalUnicode { get; private set; } + + /// Creates a new REST-only Discord client. + internal BaseDiscordClient(DiscordRestConfig config, API.DiscordRestApiClient client) + { + ApiClient = client; + LogManager = new LogManager(config.LogLevel); + LogManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + + _stateLock = new SemaphoreSlim(1, 1); + _restLogger = LogManager.CreateLogger("Rest"); + _isFirstLogin = config.DisplayInitialLog; + + UseInteractionSnowflakeDate = config.UseInteractionSnowflakeDate; + FormatUsersInBidirectionalUnicode = config.FormatUsersInBidirectionalUnicode; + + ApiClient.RequestQueue.RateLimitTriggered += async (id, info, endpoint) => + { + if (info == null) + await _restLogger.VerboseAsync($"Preemptive Rate limit triggered: {endpoint} {(id.IsHashBucket ? $"(Bucket: {id.BucketHash})" : "")}").ConfigureAwait(false); + else + await _restLogger.WarningAsync($"Rate limit triggered: {endpoint} Remaining: {info.Value.RetryAfter}s {(id.IsHashBucket ? $"(Bucket: {id.BucketHash})" : "")}").ConfigureAwait(false); + }; + ApiClient.SentRequest += async (method, endpoint, millis) => await _restLogger.VerboseAsync($"{method} {endpoint}: {millis} ms").ConfigureAwait(false); + ApiClient.SentRequest += (method, endpoint, millis) => _sentRequest.InvokeAsync(method, endpoint, millis); + } + + public async Task LoginAsync(TokenType tokenType, string token, bool validateToken = true) + { + await _stateLock.WaitAsync().ConfigureAwait(false); + try + { + await LoginInternalAsync(tokenType, token, validateToken).ConfigureAwait(false); + } + finally { _stateLock.Release(); } + } + internal virtual async Task LoginInternalAsync(TokenType tokenType, string token, bool validateToken) + { + if (_isFirstLogin) + { + _isFirstLogin = false; + await LogManager.WriteInitialLog().ConfigureAwait(false); + } + + if (LoginState != LoginState.LoggedOut) + await LogoutInternalAsync().ConfigureAwait(false); + LoginState = LoginState.LoggingIn; + + try + { + // If token validation is enabled, validate the token and let it throw any ArgumentExceptions + // that result from invalid parameters + if (validateToken) + { + try + { + TokenUtils.ValidateToken(tokenType, token); + } + catch (ArgumentException ex) + { + // log these ArgumentExceptions and allow for the client to attempt to log in anyways + await LogManager.WarningAsync("Discord", "A supplied token was invalid.", ex).ConfigureAwait(false); + } + } + + await ApiClient.LoginAsync(tokenType, token).ConfigureAwait(false); + await OnLoginAsync(tokenType, token).ConfigureAwait(false); + LoginState = LoginState.LoggedIn; + } + catch + { + await LogoutInternalAsync().ConfigureAwait(false); + throw; + } + + await _loggedInEvent.InvokeAsync().ConfigureAwait(false); + } + internal virtual Task OnLoginAsync(TokenType tokenType, string token) + => Task.Delay(0); + + public async Task LogoutAsync() + { + await _stateLock.WaitAsync().ConfigureAwait(false); + try + { + await LogoutInternalAsync().ConfigureAwait(false); + } + finally { _stateLock.Release(); } + } + internal virtual async Task LogoutInternalAsync() + { + if (LoginState == LoginState.LoggedOut) + return; + LoginState = LoginState.LoggingOut; + + await ApiClient.LogoutAsync().ConfigureAwait(false); + + await OnLogoutAsync().ConfigureAwait(false); + CurrentUser = null; + LoginState = LoginState.LoggedOut; + + await _loggedOutEvent.InvokeAsync().ConfigureAwait(false); + } + internal virtual Task OnLogoutAsync() + => Task.Delay(0); + + internal virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { +#pragma warning disable IDISP007 + ApiClient.Dispose(); +#pragma warning restore IDISP007 + _stateLock?.Dispose(); + _isDisposed = true; + } + } + + internal virtual async ValueTask DisposeAsync(bool disposing) + { + if (!_isDisposed) + { +#pragma warning disable IDISP007 + await ApiClient.DisposeAsync().ConfigureAwait(false); +#pragma warning restore IDISP007 + _stateLock?.Dispose(); + _isDisposed = true; + } + } + + /// + public void Dispose() => Dispose(true); + + public ValueTask DisposeAsync() => DisposeAsync(true); + + /// + public Task GetRecommendedShardCountAsync(RequestOptions options = null) + => ClientHelper.GetRecommendShardCountAsync(this, options); + + /// + public Task GetBotGatewayAsync(RequestOptions options = null) + => ClientHelper.GetBotGatewayAsync(this, options); + + /// + public virtual ConnectionState ConnectionState => ConnectionState.Disconnected; + #endregion + + #region IDiscordClient + /// + ISelfUser IDiscordClient.CurrentUser => CurrentUser; + + /// + Task IDiscordClient.GetApplicationInfoAsync(RequestOptions options) + => throw new NotSupportedException(); + + /// + Task IDiscordClient.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); + /// + Task> IDiscordClient.GetPrivateChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + /// + Task> IDiscordClient.GetDMChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + /// + Task> IDiscordClient.GetGroupChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + + /// + Task> IDiscordClient.GetConnectionsAsync(RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + + /// + Task IDiscordClient.GetInviteAsync(string inviteId, RequestOptions options) + => Task.FromResult(null); + + /// + Task IDiscordClient.GetGuildAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); + /// + Task> IDiscordClient.GetGuildsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + /// + /// Creating a guild is not supported with the base client. + Task IDiscordClient.CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon, RequestOptions options) + => throw new NotSupportedException(); + + /// + Task IDiscordClient.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); + /// + Task IDiscordClient.GetUserAsync(string username, string discriminator, RequestOptions options) + => Task.FromResult(null); + + /// + Task> IDiscordClient.GetVoiceRegionsAsync(RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + /// + Task IDiscordClient.GetVoiceRegionAsync(string id, RequestOptions options) + => Task.FromResult(null); + + /// + Task IDiscordClient.GetWebhookAsync(ulong id, RequestOptions options) + => Task.FromResult(null); + + /// + Task IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options) + => Task.FromResult(null); + + /// + Task> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + Task IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options) + => Task.FromResult(null); + Task> IDiscordClient.BulkOverwriteGlobalApplicationCommand(ApplicationCommandProperties[] properties, + RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + + /// + Task IDiscordClient.StartAsync() + => Task.Delay(0); + /// + Task IDiscordClient.StopAsync() + => Task.Delay(0); + + /// + /// Creates a test entitlement to a given SKU for a given guild or user. + /// + Task IDiscordClient.CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options) + => Task.FromResult(null); + + /// + /// Deletes a currently-active test entitlement. + /// + Task IDiscordClient.DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options) + => Task.CompletedTask; + + /// + /// Returns all entitlements for a given app. + /// + IAsyncEnumerable> IDiscordClient.GetEntitlementsAsync(int? limit, ulong? afterId, ulong? beforeId, + bool excludeEnded, ulong? guildId, ulong? userId, ulong[] skuIds, RequestOptions options) => AsyncEnumerable.Empty>(); + + /// + /// Gets all SKUs for a given application. + /// + Task> IDiscordClient.GetSKUsAsync(RequestOptions options) => Task.FromResult>(Array.Empty()); + + #endregion + } +} diff --git a/src/Discord.Net.Rest/ClientHelper.cs b/src/Discord.Net.Rest/ClientHelper.cs new file mode 100644 index 0000000..3d7d3c9 --- /dev/null +++ b/src/Discord.Net.Rest/ClientHelper.cs @@ -0,0 +1,451 @@ +using Discord.API; +using Discord.API.Rest; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + internal static class ClientHelper + { + #region Applications + public static async Task GetApplicationInfoAsync(BaseDiscordClient client, RequestOptions options) + { + var model = await client.ApiClient.GetMyApplicationAsync(options).ConfigureAwait(false); + return RestApplication.Create(client, model); + } + + public static async Task GetCurrentBotApplicationAsync(BaseDiscordClient client, RequestOptions options) + { + var model = await client.ApiClient.GetCurrentBotApplicationAsync(options).ConfigureAwait(false); + return RestApplication.Create(client, model); + } + + public static Task ModifyCurrentBotApplicationAsync(BaseDiscordClient client, Action func, RequestOptions options) + { + var args = new ModifyApplicationProperties(); + func(args); + + if (args.Tags.IsSpecified) + { + Preconditions.AtMost(args.Tags.Value.Length, DiscordConfig.MaxApplicationTagCount, nameof(args.Tags), $"An application can have a maximum of {DiscordConfig.MaxApplicationTagCount} applied."); + foreach (var tag in args.Tags.Value) + Preconditions.AtMost(tag.Length, DiscordConfig.MaxApplicationTagLength, nameof(args.Tags), $"An application tag must have length less or equal to {DiscordConfig.MaxApplicationTagLength}"); + } + + if (args.Description.IsSpecified) + Preconditions.AtMost(args.Description.Value.Length, DiscordConfig.MaxApplicationDescriptionLength, nameof(args.Description), $"An application description tag mus have length less or equal to {DiscordConfig.MaxApplicationDescriptionLength}"); + + return client.ApiClient.ModifyCurrentBotApplicationAsync(new() + { + Description = args.Description, + Tags = args.Tags, + Icon = args.Icon.IsSpecified ? args.Icon.Value?.ToModel() : Optional.Unspecified, + InteractionsEndpointUrl = args.InteractionsEndpointUrl, + RoleConnectionsEndpointUrl = args.RoleConnectionsEndpointUrl, + Flags = args.Flags, + CoverImage = args.CoverImage.IsSpecified ? args.CoverImage.Value?.ToModel() : Optional.Unspecified, + CustomInstallUrl = args.CustomInstallUrl, + InstallParams = args.InstallParams.IsSpecified + ? args.InstallParams.Value is null + ? null + : new InstallParams + { + Permission = args.InstallParams.Value.Permission, + Scopes = args.InstallParams.Value.Scopes.ToArray() + } + : Optional.Unspecified, + IntegrationTypesConfig = args.IntegrationTypesConfig.IsSpecified + ? args.IntegrationTypesConfig.Value?.ToDictionary(x => x.Key, x => new InstallParams + { + Permission = x.Value.Permission, + Scopes = x.Value.Scopes.ToArray() + }) + : Optional>.Unspecified + }, options); + } + + public static async Task GetChannelAsync(BaseDiscordClient client, + ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetChannelAsync(id, options).ConfigureAwait(false); + if (model != null) + return RestChannel.Create(client, model); + return null; + } + /// Unexpected channel type. + public static async Task> GetPrivateChannelsAsync(BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetMyPrivateChannelsAsync(options).ConfigureAwait(false); + return models.Select(x => RestChannel.CreatePrivate(client, x)).ToImmutableArray(); + } + public static async Task> GetDMChannelsAsync(BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetMyPrivateChannelsAsync(options).ConfigureAwait(false); + return models + .Where(x => x.Type == ChannelType.DM) + .Select(x => RestDMChannel.Create(client, x)).ToImmutableArray(); + } + public static async Task> GetGroupChannelsAsync(BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetMyPrivateChannelsAsync(options).ConfigureAwait(false); + return models + .Where(x => x.Type == ChannelType.Group) + .Select(x => RestGroupChannel.Create(client, x)).ToImmutableArray(); + } + + public static async Task> GetConnectionsAsync(BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetMyConnectionsAsync(options).ConfigureAwait(false); + return models.Select(model => RestConnection.Create(client, model)).ToImmutableArray(); + } + + public static async Task GetInviteAsync(BaseDiscordClient client, string inviteId, RequestOptions options, ulong? scheduledEventId = null) + { + var model = await client.ApiClient.GetInviteAsync(inviteId, options, scheduledEventId).ConfigureAwait(false); + if (model != null) + return RestInviteMetadata.Create(client, null, null, model); + return null; + } + + public static async Task GetGuildAsync(BaseDiscordClient client, + ulong id, bool withCounts, RequestOptions options) + { + var model = await client.ApiClient.GetGuildAsync(id, withCounts, options).ConfigureAwait(false); + if (model != null) + return RestGuild.Create(client, model); + return null; + } + public static async Task GetGuildWidgetAsync(BaseDiscordClient client, + ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetGuildWidgetAsync(id, options).ConfigureAwait(false); + if (model != null) + return RestGuildWidget.Create(model); + return null; + } + public static IAsyncEnumerable> GetGuildSummariesAsync(BaseDiscordClient client, + ulong? fromGuildId, int? limit, RequestOptions options) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxGuildsPerBatch, + async (info, ct) => + { + var args = new GetGuildSummariesParams + { + Limit = info.PageSize + }; + if (info.Position != null) + args.AfterGuildId = info.Position.Value; + var models = await client.ApiClient.GetMyGuildsAsync(args, options).ConfigureAwait(false); + return models + .Select(x => RestUserGuild.Create(client, x)) + .ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxMessagesPerBatch) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: fromGuildId, + count: limit + ); + } + public static async Task> GetGuildsAsync(BaseDiscordClient client, bool withCounts, RequestOptions options) + { + var summaryModels = await GetGuildSummariesAsync(client, null, null, options).FlattenAsync().ConfigureAwait(false); + var guilds = ImmutableArray.CreateBuilder(); + foreach (var summaryModel in summaryModels) + { + var guildModel = await client.ApiClient.GetGuildAsync(summaryModel.Id, withCounts).ConfigureAwait(false); + if (guildModel != null) + guilds.Add(RestGuild.Create(client, guildModel)); + } + return guilds.ToImmutable(); + } + public static async Task CreateGuildAsync(BaseDiscordClient client, + string name, IVoiceRegion region, Stream jpegIcon, RequestOptions options) + { + var args = new CreateGuildParams(name, region.Id); + if (jpegIcon != null) + args.Icon = new API.Image(jpegIcon); + + var model = await client.ApiClient.CreateGuildAsync(args, options).ConfigureAwait(false); + return RestGuild.Create(client, model); + } + + public static async Task GetUserAsync(BaseDiscordClient client, + ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetUserAsync(id, options).ConfigureAwait(false); + if (model != null) + return RestUser.Create(client, model); + return null; + } + public static async Task GetGuildUserAsync(BaseDiscordClient client, + ulong guildId, ulong id, RequestOptions options) + { + var guild = await GetGuildAsync(client, guildId, false, options).ConfigureAwait(false); + if (guild == null) + return null; + + var model = await client.ApiClient.GetGuildMemberAsync(guildId, id, options).ConfigureAwait(false); + if (model != null) + return RestGuildUser.Create(client, guild, model); + + return null; + } + + public static async Task GetWebhookAsync(BaseDiscordClient client, ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetWebhookAsync(id).ConfigureAwait(false); + if (model != null) + return RestWebhook.Create(client, (IGuild)null, model); + return null; + } + + public static async Task> GetVoiceRegionsAsync(BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetVoiceRegionsAsync(options).ConfigureAwait(false); + return models.Select(x => RestVoiceRegion.Create(client, x)).ToImmutableArray(); + } + public static async Task GetVoiceRegionAsync(BaseDiscordClient client, + string id, RequestOptions options) + { + var models = await client.ApiClient.GetVoiceRegionsAsync(options).ConfigureAwait(false); + return models.Select(x => RestVoiceRegion.Create(client, x)).FirstOrDefault(x => x.Id == id); + } + + public static async Task GetRecommendShardCountAsync(BaseDiscordClient client, RequestOptions options) + { + var response = await client.ApiClient.GetBotGatewayAsync(options).ConfigureAwait(false); + return response.Shards; + } + + public static async Task GetBotGatewayAsync(BaseDiscordClient client, RequestOptions options) + { + var response = await client.ApiClient.GetBotGatewayAsync(options).ConfigureAwait(false); + return new BotGateway + { + Url = response.Url, + Shards = response.Shards, + SessionStartLimit = new SessionStartLimit + { + Total = response.SessionStartLimit.Total, + Remaining = response.SessionStartLimit.Remaining, + ResetAfter = response.SessionStartLimit.ResetAfter, + MaxConcurrency = response.SessionStartLimit.MaxConcurrency + } + }; + } + + public static async Task> GetGlobalApplicationCommandsAsync(BaseDiscordClient client, bool withLocalizations = false, + string locale = null, RequestOptions options = null) + { + var response = await client.ApiClient.GetGlobalApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false); + + if (!response.Any()) + return Array.Empty(); + + return response.Select(x => RestGlobalCommand.Create(client, x)).ToArray(); + } + public static async Task GetGlobalApplicationCommandAsync(BaseDiscordClient client, ulong id, + RequestOptions options = null) + { + var model = await client.ApiClient.GetGlobalApplicationCommandAsync(id, options); + + return model != null ? RestGlobalCommand.Create(client, model) : null; + } + + public static async Task> GetGuildApplicationCommandsAsync(BaseDiscordClient client, ulong guildId, bool withLocalizations = false, + string locale = null, RequestOptions options = null) + { + var response = await client.ApiClient.GetGuildApplicationCommandsAsync(guildId, withLocalizations, locale, options).ConfigureAwait(false); + + if (!response.Any()) + return ImmutableArray.Create(); + + return response.Select(x => RestGuildCommand.Create(client, x, guildId)).ToImmutableArray(); + } + public static async Task GetGuildApplicationCommandAsync(BaseDiscordClient client, ulong id, ulong guildId, + RequestOptions options = null) + { + var model = await client.ApiClient.GetGuildApplicationCommandAsync(guildId, id, options); + + return model != null ? RestGuildCommand.Create(client, model, guildId) : null; + } + public static async Task CreateGuildApplicationCommandAsync(BaseDiscordClient client, ulong guildId, ApplicationCommandProperties properties, + RequestOptions options = null) + { + var model = await InteractionHelper.CreateGuildCommandAsync(client, guildId, properties, options); + + return RestGuildCommand.Create(client, model, guildId); + } + public static async Task CreateGlobalApplicationCommandAsync(BaseDiscordClient client, ApplicationCommandProperties properties, + RequestOptions options = null) + { + var model = await InteractionHelper.CreateGlobalCommandAsync(client, properties, options); + + return RestGlobalCommand.Create(client, model); + } + public static async Task> BulkOverwriteGlobalApplicationCommandAsync(BaseDiscordClient client, ApplicationCommandProperties[] properties, + RequestOptions options = null) + { + var models = await InteractionHelper.BulkOverwriteGlobalCommandsAsync(client, properties, options); + + return models.Select(x => RestGlobalCommand.Create(client, x)).ToImmutableArray(); + } + public static async Task> BulkOverwriteGuildApplicationCommandAsync(BaseDiscordClient client, ulong guildId, + ApplicationCommandProperties[] properties, RequestOptions options = null) + { + var models = await InteractionHelper.BulkOverwriteGuildCommandsAsync(client, guildId, properties, options); + + return models.Select(x => RestGuildCommand.Create(client, x, guildId)).ToImmutableArray(); + } + + public static Task AddRoleAsync(BaseDiscordClient client, ulong guildId, ulong userId, ulong roleId, RequestOptions options = null) + => client.ApiClient.AddRoleAsync(guildId, userId, roleId, options); + + public static Task RemoveRoleAsync(BaseDiscordClient client, ulong guildId, ulong userId, ulong roleId, RequestOptions options = null) + => client.ApiClient.RemoveRoleAsync(guildId, userId, roleId, options); + #endregion + + #region Role Connection Metadata + + public static async Task> GetRoleConnectionMetadataRecordsAsync(BaseDiscordClient client, RequestOptions options = null) + => (await client.ApiClient.GetApplicationRoleConnectionMetadataRecordsAsync(options)) + .Select(model + => new RoleConnectionMetadata( + model.Type, + model.Key, + model.Name, + model.Description, + model.NameLocalizations.IsSpecified + ? model.NameLocalizations.Value?.ToImmutableDictionary() + : null, + model.DescriptionLocalizations.IsSpecified + ? model.DescriptionLocalizations.Value?.ToImmutableDictionary() + : null)) + .ToImmutableArray(); + + public static async Task> ModifyRoleConnectionMetadataRecordsAsync(ICollection metadata, BaseDiscordClient client, RequestOptions options = null) + => (await client.ApiClient.UpdateApplicationRoleConnectionMetadataRecordsAsync(metadata + .Select(x => new API.RoleConnectionMetadata + { + Name = x.Name, + Description = x.Description, + Key = x.Key, + Type = x.Type, + NameLocalizations = x.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = x.DescriptionLocalizations?.ToDictionary() + }).ToArray())) + .Select(model + => new RoleConnectionMetadata( + model.Type, + model.Key, + model.Name, + model.Description, + model.NameLocalizations.IsSpecified + ? model.NameLocalizations.Value?.ToImmutableDictionary() + : null, + model.DescriptionLocalizations.IsSpecified + ? model.DescriptionLocalizations.Value?.ToImmutableDictionary() + : null)) + .ToImmutableArray(); + + public static async Task GetUserRoleConnectionAsync(ulong applicationId, BaseDiscordClient client, RequestOptions options = null) + { + var roleConnection = await client.ApiClient.GetUserApplicationRoleConnectionAsync(applicationId, options); + + return new RoleConnection(roleConnection.PlatformName.GetValueOrDefault(null), + roleConnection.PlatformUsername.GetValueOrDefault(null), + roleConnection.Metadata.GetValueOrDefault()); + } + + public static async Task ModifyUserRoleConnectionAsync(ulong applicationId, RoleConnectionProperties roleConnection, BaseDiscordClient client, RequestOptions options = null) + { + var updatedConnection = await client.ApiClient.ModifyUserApplicationRoleConnectionAsync(applicationId, + new API.RoleConnection + { + PlatformName = roleConnection.PlatformName, + PlatformUsername = roleConnection.PlatformUsername, + Metadata = roleConnection.Metadata + }, options); + + return new RoleConnection( + updatedConnection.PlatformName.GetValueOrDefault(null), + updatedConnection.PlatformUsername.GetValueOrDefault(null), + updatedConnection.Metadata.GetValueOrDefault()?.ToImmutableDictionary() + ); + } + + + #endregion + + #region App Subscriptions + + public static async Task CreateTestEntitlementAsync(BaseDiscordClient client, ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, + RequestOptions options = null) + { + var model = await client.ApiClient.CreateEntitlementAsync(new CreateEntitlementParams + { + Type = ownerType, + OwnerId = ownerId, + SkuId = skuId + }, options); + + return RestEntitlement.Create(client, model); + } + + public static IAsyncEnumerable> ListEntitlementsAsync(BaseDiscordClient client, int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxEntitlementsPerBatch, + async (info, ct) => + { + var args = new ListEntitlementsParams() + { + Limit = info.PageSize, + BeforeId = beforeId ?? Optional.Unspecified, + ExcludeEnded = excludeEnded, + GuildId = guildId ?? Optional.Unspecified, + UserId = userId ?? Optional.Unspecified, + SkuIds = skuIds ?? Optional.Unspecified, + }; + if (info.Position != null) + args.AfterId = info.Position.Value; + var models = await client.ApiClient.ListEntitlementAsync(args, options).ConfigureAwait(false); + return models + .Select(x => RestEntitlement.Create(client, x)) + .ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxEntitlementsPerBatch) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: afterId, + count: limit + ); + } + + public static async Task> ListSKUsAsync(BaseDiscordClient client, RequestOptions options = null) + { + var models = await client.ApiClient.ListSKUsAsync(options).ConfigureAwait(false); + + return models.Select(x => new SKU(x.Id, x.Type, x.ApplicationId, x.Name, x.Slug)).ToImmutableArray(); + } + + #endregion + } +} diff --git a/src/Discord.Net.Rest/Discord.Net.Rest.csproj b/src/Discord.Net.Rest/Discord.Net.Rest.csproj index 74abf5c..53b6a7b 100644 --- a/src/Discord.Net.Rest/Discord.Net.Rest.csproj +++ b/src/Discord.Net.Rest/Discord.Net.Rest.csproj @@ -1,10 +1,23 @@ - + + - Exe - net6.0 - enable - enable + Discord.Net.Rest + Discord.Rest + A core Discord.Net library containing the REST client and models. + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + 5 + True + false + false - + + + + + + + + + diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs new file mode 100644 index 0000000..5c1b6c6 --- /dev/null +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -0,0 +1,2833 @@ +using Discord.API.Rest; +using Discord.Net; +using Discord.Net.Converters; +using Discord.Net.Queue; +using Discord.Net.Rest; + +using Newtonsoft.Json; + +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel.Design; +using System.Diagnostics; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Linq.Expressions; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.API +{ + internal class DiscordRestApiClient : IDisposable, IAsyncDisposable + { + #region DiscordRestApiClient + private static readonly ConcurrentDictionary> _bucketIdGenerators = new ConcurrentDictionary>(); + + public event Func SentRequest { add { _sentRequestEvent.Add(value); } remove { _sentRequestEvent.Remove(value); } } + private readonly AsyncEvent> _sentRequestEvent = new AsyncEvent>(); + + protected readonly JsonSerializer _serializer; + protected readonly SemaphoreSlim _stateLock; + private readonly RestClientProvider _restClientProvider; + + protected bool _isDisposed; + private CancellationTokenSource _loginCancelToken; + + public RetryMode DefaultRetryMode { get; } + public string UserAgent { get; } + internal RequestQueue RequestQueue { get; } + + public LoginState LoginState { get; private set; } + public TokenType AuthTokenType { get; private set; } + internal string AuthToken { get; private set; } + internal IRestClient RestClient { get; private set; } + internal ulong? CurrentUserId { get; set; } + internal ulong? CurrentApplicationId { get; set; } + internal bool UseSystemClock { get; set; } + internal Func DefaultRatelimitCallback { get; set; } + internal JsonSerializer Serializer => _serializer; + + /// Unknown OAuth token type. + public DiscordRestApiClient(RestClientProvider restClientProvider, string userAgent, RetryMode defaultRetryMode = RetryMode.AlwaysRetry, + JsonSerializer serializer = null, bool useSystemClock = true, Func defaultRatelimitCallback = null) + { + _restClientProvider = restClientProvider; + UserAgent = userAgent; + DefaultRetryMode = defaultRetryMode; + _serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + UseSystemClock = useSystemClock; + DefaultRatelimitCallback = defaultRatelimitCallback; + + RequestQueue = new RequestQueue(); + _stateLock = new SemaphoreSlim(1, 1); + + SetBaseUrl(DiscordConfig.APIUrl); + } + + /// Unknown OAuth token type. + internal void SetBaseUrl(string baseUrl) + { + RestClient?.Dispose(); + RestClient = _restClientProvider(baseUrl); + RestClient.SetHeader("accept", "*/*"); + RestClient.SetHeader("user-agent", UserAgent); + RestClient.SetHeader("authorization", GetPrefixedToken(AuthTokenType, AuthToken)); + } + /// Unknown OAuth token type. + internal static string GetPrefixedToken(TokenType tokenType, string token) + { + return tokenType switch + { + TokenType.Bot => $"Bot {token}", + TokenType.Bearer => $"Bearer {token}", + _ => throw new ArgumentException(message: "Unknown OAuth token type.", paramName: nameof(tokenType)), + }; + } + internal virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _loginCancelToken?.Dispose(); + RestClient?.Dispose(); + RequestQueue?.Dispose(); + _stateLock?.Dispose(); + } + _isDisposed = true; + } + } + + internal virtual async ValueTask DisposeAsync(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _loginCancelToken?.Dispose(); + RestClient?.Dispose(); + + if (!(RequestQueue is null)) + await RequestQueue.DisposeAsync().ConfigureAwait(false); + + _stateLock?.Dispose(); + } + _isDisposed = true; + } + } + + public void Dispose() => Dispose(true); + + public ValueTask DisposeAsync() => DisposeAsync(true); + + public async Task LoginAsync(TokenType tokenType, string token, RequestOptions options = null) + { + await _stateLock.WaitAsync().ConfigureAwait(false); + try + { + await LoginInternalAsync(tokenType, token, options).ConfigureAwait(false); + } + finally { _stateLock.Release(); } + } + private async Task LoginInternalAsync(TokenType tokenType, string token, RequestOptions options = null) + { + if (LoginState != LoginState.LoggedOut) + await LogoutInternalAsync().ConfigureAwait(false); + LoginState = LoginState.LoggingIn; + + try + { + _loginCancelToken?.Dispose(); + _loginCancelToken = new CancellationTokenSource(); + + AuthToken = null; + await RequestQueue.SetCancelTokenAsync(_loginCancelToken.Token).ConfigureAwait(false); + RestClient.SetCancelToken(_loginCancelToken.Token); + + AuthTokenType = tokenType; + AuthToken = token?.TrimEnd(); + if (tokenType != TokenType.Webhook) + RestClient.SetHeader("authorization", GetPrefixedToken(AuthTokenType, AuthToken)); + + LoginState = LoginState.LoggedIn; + } + catch + { + await LogoutInternalAsync().ConfigureAwait(false); + throw; + } + } + + public async Task LogoutAsync() + { + await _stateLock.WaitAsync().ConfigureAwait(false); + try + { + await LogoutInternalAsync().ConfigureAwait(false); + } + finally { _stateLock.Release(); } + } + private async Task LogoutInternalAsync() + { + //An exception here will lock the client into the unusable LoggingOut state, but that's probably fine since our client is in an undefined state too. + if (LoginState == LoginState.LoggedOut) + return; + LoginState = LoginState.LoggingOut; + + try + { _loginCancelToken?.Cancel(false); } + catch { } + + await DisconnectInternalAsync(null).ConfigureAwait(false); + await RequestQueue.ClearAsync().ConfigureAwait(false); + + await RequestQueue.SetCancelTokenAsync(CancellationToken.None).ConfigureAwait(false); + RestClient.SetCancelToken(CancellationToken.None); + + CurrentUserId = null; + LoginState = LoginState.LoggedOut; + } + + internal virtual Task ConnectInternalAsync() => Task.Delay(0); + internal virtual Task DisconnectInternalAsync(Exception ex = null) => Task.Delay(0); + #endregion + + #region Core + internal Task SendAsync(string method, Expression> endpointExpr, BucketIds ids, + ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) + => SendAsync(method, GetEndpoint(endpointExpr), GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); + public Task SendAsync(string method, string endpoint, + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + { + options ??= new RequestOptions(); + options.HeaderOnly = true; + options.BucketId = bucketId; + + var request = new RestRequest(RestClient, method, endpoint, options); + return SendInternalAsync(method, endpoint, request); + } + + internal Task SendJsonAsync(string method, Expression> endpointExpr, object payload, BucketIds ids, + ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) + => SendJsonAsync(method, GetEndpoint(endpointExpr), payload, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); + public Task SendJsonAsync(string method, string endpoint, object payload, + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + { + options ??= new RequestOptions(); + options.HeaderOnly = true; + options.BucketId = bucketId; + + string json = payload != null ? SerializeJson(payload) : null; + var request = new JsonRestRequest(RestClient, method, endpoint, json, options); + return SendInternalAsync(method, endpoint, request); + } + + internal Task SendMultipartAsync(string method, Expression> endpointExpr, IReadOnlyDictionary multipartArgs, BucketIds ids, + ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) + => SendMultipartAsync(method, GetEndpoint(endpointExpr), multipartArgs, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); + public Task SendMultipartAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + { + options ??= new RequestOptions(); + options.HeaderOnly = true; + options.BucketId = bucketId; + + var request = new MultipartRestRequest(RestClient, method, endpoint, multipartArgs, options); + return SendInternalAsync(method, endpoint, request); + } + + internal Task SendAsync(string method, Expression> endpointExpr, BucketIds ids, + ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) where TResponse : class + => SendAsync(method, GetEndpoint(endpointExpr), GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); + public async Task SendAsync(string method, string endpoint, + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) where TResponse : class + { + options ??= new RequestOptions(); + options.BucketId = bucketId; + + var request = new RestRequest(RestClient, method, endpoint, options); + return DeserializeJson(await SendInternalAsync(method, endpoint, request).ConfigureAwait(false)); + } + + internal Task SendJsonAsync(string method, Expression> endpointExpr, object payload, BucketIds ids, + ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) where TResponse : class + => SendJsonAsync(method, GetEndpoint(endpointExpr), payload, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); + public async Task SendJsonAsync(string method, string endpoint, object payload, + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) where TResponse : class + { + options ??= new RequestOptions(); + options.BucketId = bucketId; + + string json = payload != null ? SerializeJson(payload) : null; + + var request = new JsonRestRequest(RestClient, method, endpoint, json, options); + return DeserializeJson(await SendInternalAsync(method, endpoint, request).ConfigureAwait(false)); + } + + internal Task SendMultipartAsync(string method, Expression> endpointExpr, IReadOnlyDictionary multipartArgs, BucketIds ids, + ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null, [CallerMemberName] string funcName = null) + => SendMultipartAsync(method, GetEndpoint(endpointExpr), multipartArgs, GetBucketId(method, ids, endpointExpr, funcName), clientBucket, options); + public async Task SendMultipartAsync(string method, string endpoint, IReadOnlyDictionary multipartArgs, + BucketId bucketId = null, ClientBucketType clientBucket = ClientBucketType.Unbucketed, RequestOptions options = null) + { + options ??= new RequestOptions(); + options.BucketId = bucketId; + + var request = new MultipartRestRequest(RestClient, method, endpoint, multipartArgs, options); + return DeserializeJson(await SendInternalAsync(method, endpoint, request).ConfigureAwait(false)); + } + + private async Task SendInternalAsync(string method, string endpoint, RestRequest request) + { + if (!request.Options.IgnoreState) + CheckState(); + + request.Options.RetryMode ??= DefaultRetryMode; + request.Options.UseSystemClock ??= UseSystemClock; + request.Options.RatelimitCallback ??= DefaultRatelimitCallback; + + var stopwatch = Stopwatch.StartNew(); + var responseStream = await RequestQueue.SendAsync(request).ConfigureAwait(false); + stopwatch.Stop(); + + double milliseconds = ToMilliseconds(stopwatch); + await _sentRequestEvent.InvokeAsync(method, endpoint, milliseconds).ConfigureAwait(false); + + return responseStream; + } + #endregion + + #region Auth + public Task ValidateTokenAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync("GET", () => "auth/login", new BucketIds(), options: options); + } + #endregion + + #region Gateway + public Task GetGatewayAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync("GET", () => "gateway", new BucketIds(), options: options); + } + public Task GetBotGatewayAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync("GET", () => "gateway/bot", new BucketIds(), options: options); + } + #endregion + + #region Channels + public async Task GetChannelAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(channelId: channelId); + return await SendAsync("GET", () => $"channels/{channelId}", ids, options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + public async Task GetChannelAsync(ulong guildId, ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(channelId: channelId); + var model = await SendAsync("GET", () => $"channels/{channelId}", ids, options: options).ConfigureAwait(false); + if (!model.GuildId.IsSpecified || model.GuildId.Value != guildId) + return null; + + return model; + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + public Task> GetGuildChannelsAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync>("GET", () => $"guilds/{guildId}/channels", ids, options: options); + } + public Task CreateGuildChannelAsync(ulong guildId, CreateGuildChannelParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.GreaterThan(args.Bitrate, 0, nameof(args.Bitrate)); + Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); + Preconditions.AtMost(args.Name.Length, 100, nameof(args.Name)); + if (args.Topic is { IsSpecified: true, Value: not null }) + Preconditions.AtMost(args.Topic.Value.Length, 1024, nameof(args.Name)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("POST", () => $"guilds/{guildId}/channels", args, ids, options: options); + } + + public Task DeleteChannelAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("DELETE", () => $"channels/{channelId}", ids, options: options); + } + /// + /// must not be equal to zero. + /// -and- + /// must be greater than zero. + /// + /// + /// must not be . + /// -and- + /// must not be or empty. + /// + public Task ModifyGuildChannelAsync(ulong channelId, Rest.ModifyGuildChannelParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Position, 0, nameof(args.Position)); + Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); + + if (args.Name.IsSpecified) + Preconditions.AtMost(args.Name.Value.Length, 100, nameof(args.Name)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendJsonAsync("PATCH", () => $"channels/{channelId}", args, ids, options: options); + } + + public async Task ModifyGuildChannelAsync(ulong channelId, Rest.ModifyTextChannelParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Position, 0, nameof(args.Position)); + Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); + + if (args.Name.IsSpecified) + Preconditions.AtMost(args.Name.Value.Length, 100, nameof(args.Name)); + if (args.Topic.IsSpecified) + Preconditions.AtMost(args.Topic.Value.Length, 1024, nameof(args.Name)); + + Preconditions.AtLeast(args.SlowModeInterval, 0, nameof(args.SlowModeInterval)); + Preconditions.AtMost(args.SlowModeInterval, 21600, nameof(args.SlowModeInterval)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return await SendJsonAsync("PATCH", () => $"channels/{channelId}", args, ids, options: options).ConfigureAwait(false); + } + + public Task ModifyGuildChannelAsync(ulong channelId, Rest.ModifyVoiceChannelParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Bitrate, 8000, nameof(args.Bitrate)); + Preconditions.AtLeast(args.UserLimit, 0, nameof(args.UserLimit)); + Preconditions.AtLeast(args.Position, 0, nameof(args.Position)); + Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendJsonAsync("PATCH", () => $"channels/{channelId}", args, ids, options: options); + } + + public Task ModifyGuildChannelsAsync(ulong guildId, IEnumerable args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + var channels = args.ToArray(); + switch (channels.Length) + { + case 0: + return Task.CompletedTask; + case 1: + return ModifyGuildChannelAsync(channels[0].Id, new Rest.ModifyGuildChannelParams { Position = channels[0].Position }); + default: + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/channels", channels, ids, options: options); + } + } + + public async Task ModifyVoiceChannelStatusAsync(ulong channelId, string status, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + var payload = new ModifyVoiceStatusParams { Status = status }; + var ids = new BucketIds(); + + await SendJsonAsync("PUT", () => $"channels/{channelId}/voice-status", payload, ids, options: options); + } + + #endregion + + #region Threads + public Task CreatePostAsync(ulong channelId, CreatePostParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + var bucket = new BucketIds(channelId: channelId); + + return SendJsonAsync("POST", () => $"channels/{channelId}/threads", args, bucket, options: options); + } + + public Task CreatePostAsync(ulong channelId, CreateMultipartPostAsync args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + var bucket = new BucketIds(channelId: channelId); + + return SendMultipartAsync("POST", () => $"channels/{channelId}/threads", args.ToDictionary(), bucket, options: options); + } + + public Task ModifyThreadAsync(ulong channelId, ModifyThreadParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + var bucket = new BucketIds(channelId: channelId); + + return SendJsonAsync("PATCH", () => $"channels/{channelId}", args, bucket, options: options); + } + + public Task StartThreadAsync(ulong channelId, ulong messageId, StartThreadParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(0, channelId); + + return SendJsonAsync("POST", () => $"channels/{channelId}/messages/{messageId}/threads", args, bucket, options: options); + } + + public Task StartThreadAsync(ulong channelId, StartThreadParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + return SendJsonAsync("POST", () => $"channels/{channelId}/threads", args, bucket, options: options); + } + + public Task JoinThreadAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + return SendAsync("PUT", () => $"channels/{channelId}/thread-members/@me", bucket, options: options); + } + + public Task AddThreadMemberAsync(ulong channelId, ulong userId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(userId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + return SendAsync("PUT", () => $"channels/{channelId}/thread-members/{userId}", bucket, options: options); + } + + public Task LeaveThreadAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + return SendAsync("DELETE", () => $"channels/{channelId}/thread-members/@me", bucket, options: options); + } + + public Task RemoveThreadMemberAsync(ulong channelId, ulong userId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(userId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + var bucket = new BucketIds(channelId: channelId); + + return SendAsync("DELETE", () => $"channels/{channelId}/thread-members/{userId}", bucket, options: options); + } + + public async Task ListThreadMembersAsync(ulong channelId, ulong? after = null, int? limit = null, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + var query = "?with_member=true"; + + if (limit.HasValue) + query += $"&limit={limit}"; + if (after.HasValue) + query += $"&after={after}"; + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + return await SendAsync("GET", () => $"channels/{channelId}/thread-members{query}", bucket, options: options).ConfigureAwait(false); + } + + public Task GetThreadMemberAsync(ulong channelId, ulong userId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + var query = "?with_member=true"; + + return SendAsync("GET", () => $"channels/{channelId}/thread-members/{userId}{query}", bucket, options: options); + } + + public Task GetActiveThreadsAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(guildId: guildId); + + return SendAsync("GET", () => $"guilds/{guildId}/threads/active", bucket, options: options); + } + + public Task GetPublicArchivedThreadsAsync(ulong channelId, DateTimeOffset? before = null, int? limit = null, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + string query = ""; + + if (limit.HasValue) + { + string beforeEncoded = WebUtility.UrlEncode(before.GetValueOrDefault(DateTimeOffset.UtcNow).ToString("O")); + query = $"?before={beforeEncoded}&limit={limit.Value}"; + } + else if (before.HasValue) + { + string beforeEncoded = WebUtility.UrlEncode(before.Value.ToString("O")); + query = $"?before={beforeEncoded}"; + } + + return SendAsync("GET", () => $"channels/{channelId}/threads/archived/public{query}", bucket, options: options); + } + + public Task GetPrivateArchivedThreadsAsync(ulong channelId, DateTimeOffset? before = null, int? limit = null, + RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + string query = ""; + + if (limit.HasValue) + { + string beforeEncoded = WebUtility.UrlEncode(before.GetValueOrDefault(DateTimeOffset.UtcNow).ToString("O")); + query = $"?before={beforeEncoded}&limit={limit.Value}"; + } + else if (before.HasValue) + { + string beforeEncoded = WebUtility.UrlEncode(before.Value.ToString("O")); + query = $"?before={beforeEncoded}"; + } + + return SendAsync("GET", () => $"channels/{channelId}/threads/archived/private{query}", bucket, options: options); + } + + public Task GetJoinedPrivateArchivedThreadsAsync(ulong channelId, DateTimeOffset? before = null, int? limit = null, + RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + string query = ""; + + if (limit.HasValue) + { + query = $"?before={SnowflakeUtils.ToSnowflake(before.GetValueOrDefault(DateTimeOffset.UtcNow))}&limit={limit.Value}"; + } + else if (before.HasValue) + { + query = $"?before={before.Value.ToString("O")}"; + } + + return SendAsync("GET", () => $"channels/{channelId}/users/@me/threads/archived/private{query}", bucket, options: options); + } + #endregion + + #region Stage + public Task CreateStageInstanceAsync(CreateStageInstanceParams args, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(); + + return SendJsonAsync("POST", () => $"stage-instances", args, bucket, options: options); + } + + public Task ModifyStageInstanceAsync(ulong channelId, ModifyStageInstanceParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + return SendJsonAsync("PATCH", () => $"stage-instances/{channelId}", args, bucket, options: options); + } + + public async Task DeleteStageInstanceAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + try + { + await SendAsync("DELETE", () => $"stage-instances/{channelId}", bucket, options: options).ConfigureAwait(false); + } + catch (HttpException httpEx) when (httpEx.HttpCode == HttpStatusCode.NotFound) { } + } + + public async Task GetStageInstanceAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(channelId: channelId); + + try + { + return await SendAsync("POST", () => $"stage-instances/{channelId}", bucket, options: options).ConfigureAwait(false); + } + catch (HttpException httpEx) when (httpEx.HttpCode == HttpStatusCode.NotFound) + { + return null; + } + } + + public Task ModifyMyVoiceState(ulong guildId, ModifyVoiceStateParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(); + + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/voice-states/@me", args, bucket, options: options); + } + + public Task ModifyUserVoiceState(ulong guildId, ulong userId, ModifyVoiceStateParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(); + + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/voice-states/{userId}", args, bucket, options: options); + } + #endregion + + #region Roles + public Task AddRoleAsync(ulong guildId, ulong userId, ulong roleId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + Preconditions.NotEqual(roleId, 0, nameof(roleId)); + Preconditions.NotEqual(roleId, guildId, nameof(roleId), "The Everyone role cannot be added to a user."); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("PUT", () => $"guilds/{guildId}/members/{userId}/roles/{roleId}", ids, options: options); + } + public Task RemoveRoleAsync(ulong guildId, ulong userId, ulong roleId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + Preconditions.NotEqual(roleId, 0, nameof(roleId)); + Preconditions.NotEqual(roleId, guildId, nameof(roleId), "The Everyone role cannot be removed from a user."); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"guilds/{guildId}/members/{userId}/roles/{roleId}", ids, options: options); + } + #endregion + + #region Channel Messages + public async Task GetChannelMessageAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(channelId: channelId); + return await SendAsync("GET", () => $"channels/{channelId}/messages/{messageId}", ids, options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + public Task> GetChannelMessagesAsync(ulong channelId, GetChannelMessagesParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxMessagesPerBatch, nameof(args.Limit)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(DiscordConfig.MaxMessagesPerBatch); + ulong? relativeId = args.RelativeMessageId.IsSpecified ? args.RelativeMessageId.Value : (ulong?)null; + var relativeDir = args.RelativeDirection.GetValueOrDefault(Direction.Before) switch + { + Direction.After => "after", + Direction.Around => "around", + _ => "before", + }; + var ids = new BucketIds(channelId: channelId); + Expression> endpoint; + if (relativeId != null) + endpoint = () => $"channels/{channelId}/messages?limit={limit}&{relativeDir}={relativeId}"; + else + endpoint = () => $"channels/{channelId}/messages?limit={limit}"; + + return SendAsync>("GET", endpoint, ids, options: options); + } + + /// Message content is too long, length must be less or equal to . + public Task CreateMessageAsync(ulong channelId, CreateMessageParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendJsonAsync("POST", () => $"channels/{channelId}/messages", args, ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + /// Message content is too long, length must be less or equal to . + /// This operation may only be called with a token. + public Task CreateWebhookMessageAsync(ulong webhookId, CreateWebhookMessageParams args, RequestOptions options = null, ulong? threadId = null) + { + if (AuthTokenType != TokenType.Webhook) + throw new InvalidOperationException($"This operation may only be called with a {nameof(TokenType.Webhook)} token."); + + if (args.Embeds.IsSpecified) + Preconditions.AtMost(args.Embeds.Value.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(args.Embeds), "A max of 10 Embeds are allowed."); + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(webhookId: webhookId); + return SendJsonAsync("POST", () => $"webhooks/{webhookId}/{AuthToken}?{WebhookQuery(true, threadId)}", args, ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + /// Message content is too long, length must be less or equal to . + /// This operation may only be called with a token. + public async Task ModifyWebhookMessageAsync(ulong webhookId, ulong messageId, ModifyWebhookMessageParams args, RequestOptions options = null, ulong? threadId = null) + { + if (AuthTokenType != TokenType.Webhook) + throw new InvalidOperationException($"This operation may only be called with a {nameof(TokenType.Webhook)} token."); + + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(webhookId, 0, nameof(webhookId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + + if (args.Embeds.IsSpecified) + Preconditions.AtMost(args.Embeds.Value.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(args.Embeds), $"A max of {DiscordConfig.MaxEmbedsPerMessage} Embeds are allowed."); + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(webhookId: webhookId); + await SendJsonAsync("PATCH", () => $"webhooks/{webhookId}/{AuthToken}/messages/{messageId}?{WebhookQuery(false, threadId)}", args, ids, clientBucket: ClientBucketType.SendEdit, options: options).ConfigureAwait(false); + } + + /// Message content is too long, length must be less or equal to . + /// This operation may only be called with a token. + public Task ModifyWebhookMessageAsync(ulong webhookId, ulong messageId, UploadWebhookFileParams args, RequestOptions options = null, ulong? threadId = null) + { + if (AuthTokenType != TokenType.Webhook) + throw new InvalidOperationException($"This operation may only be called with a {nameof(TokenType.Webhook)} token."); + + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(webhookId, 0, nameof(webhookId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + + if (args.Embeds.IsSpecified) + Preconditions.AtMost(args.Embeds.Value.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(args.Embeds), $"A max of {DiscordConfig.MaxEmbedsPerMessage} Embeds are allowed."); + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(webhookId: webhookId); + return SendMultipartAsync("PATCH", () => $"webhooks/{webhookId}/{AuthToken}/messages/{messageId}?{WebhookQuery(false, threadId)}", args.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + /// This operation may only be called with a token. + public Task DeleteWebhookMessageAsync(ulong webhookId, ulong messageId, RequestOptions options = null, ulong? threadId = null) + { + if (AuthTokenType != TokenType.Webhook) + throw new InvalidOperationException($"This operation may only be called with a {nameof(TokenType.Webhook)} token."); + + Preconditions.NotEqual(webhookId, 0, nameof(webhookId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(webhookId: webhookId); + return SendAsync("DELETE", () => $"webhooks/{webhookId}/{AuthToken}/messages/{messageId}?{WebhookQuery(false, threadId)}", ids, options: options); + } + + /// Message content is too long, length must be less or equal to . + public Task UploadFileAsync(ulong channelId, UploadFileParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + if (args.Embeds.IsSpecified) + Preconditions.AtMost(args.Embeds.Value.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(args.Embeds), $"A max of {DiscordConfig.MaxEmbedsPerMessage} Embeds are allowed."); + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + var ids = new BucketIds(channelId: channelId); + return SendMultipartAsync("POST", () => $"channels/{channelId}/messages", args.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + /// Message content is too long, length must be less or equal to . + /// This operation may only be called with a token. + public Task UploadWebhookFileAsync(ulong webhookId, UploadWebhookFileParams args, RequestOptions options = null, ulong? threadId = null) + { + if (AuthTokenType != TokenType.Webhook) + throw new InvalidOperationException($"This operation may only be called with a {nameof(TokenType.Webhook)} token."); + + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(webhookId, 0, nameof(webhookId)); + options = RequestOptions.CreateOrClone(options); + + if (args.Embeds.IsSpecified) + Preconditions.AtMost(args.Embeds.Value.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(args.Embeds), $"A max of {DiscordConfig.MaxEmbedsPerMessage} Embeds are allowed."); + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + var ids = new BucketIds(webhookId: webhookId); + return SendMultipartAsync("POST", () => $"webhooks/{webhookId}/{AuthToken}?{WebhookQuery(true, threadId)}", args.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + public Task DeleteMessageAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("DELETE", () => $"channels/{channelId}/messages/{messageId}", ids, options: options); + } + + public Task DeleteMessagesAsync(ulong channelId, DeleteMessagesParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNull(args.MessageIds, nameof(args.MessageIds)); + Preconditions.AtMost(args.MessageIds.Length, 100, nameof(args.MessageIds.Length)); + Preconditions.YoungerThanTwoWeeks(args.MessageIds, nameof(args.MessageIds)); + options = RequestOptions.CreateOrClone(options); + + switch (args.MessageIds.Length) + { + case 0: + return Task.CompletedTask; + case 1: + return DeleteMessageAsync(channelId, args.MessageIds[0]); + default: + var ids = new BucketIds(channelId: channelId); + return SendJsonAsync("POST", () => $"channels/{channelId}/messages/bulk-delete", args, ids, options: options); + } + } + /// Message content is too long, length must be less or equal to . + public Task ModifyMessageAsync(ulong channelId, ulong messageId, Rest.ModifyMessageParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + Preconditions.NotNull(args, nameof(args)); + + if (args.Embeds.IsSpecified) + Preconditions.AtMost(args.Embeds.Value.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(args.Embeds), "A max of 10 Embeds are allowed."); + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendJsonAsync("PATCH", () => $"channels/{channelId}/messages/{messageId}", args, ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + public Task ModifyMessageAsync(ulong channelId, ulong messageId, Rest.UploadFileParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + Preconditions.NotNull(args, nameof(args)); + + if (args.Embeds.IsSpecified) + Preconditions.AtMost(args.Embeds.Value.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(args.Embeds), "A max of 10 Embeds are allowed."); + if (args.Content.IsSpecified && args.Content.Value is not null) + Preconditions.AtMost(args.Content.Value.Length, DiscordConfig.MaxMessageSize, nameof(args.Content), $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}."); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendMultipartAsync("PATCH", () => $"channels/{channelId}/messages/{messageId}", args.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + #endregion + + #region Stickers, Reactions, Crosspost, and Acks + public Task GetStickerAsync(ulong id, RequestOptions options = null) + { + Preconditions.NotEqual(id, 0, nameof(id)); + + options = RequestOptions.CreateOrClone(options); + + return NullifyNotFound(SendAsync("GET", () => $"stickers/{id}", new BucketIds(), options: options)); + } + + public Task GetGuildStickerAsync(ulong guildId, ulong id, RequestOptions options = null) + { + Preconditions.NotEqual(id, 0, nameof(id)); + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return NullifyNotFound(SendAsync("GET", () => $"guilds/{guildId}/stickers/{id}", new BucketIds(guildId), options: options)); + } + + public Task ListGuildStickersAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("GET", () => $"guilds/{guildId}/stickers", new BucketIds(guildId), options: options); + } + + public Task ListNitroStickerPacksAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendAsync("GET", () => $"sticker-packs", new BucketIds(), options: options); + } + + public Task CreateGuildStickerAsync(CreateStickerParams args, ulong guildId, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return SendMultipartAsync("POST", () => $"guilds/{guildId}/stickers", args.ToDictionary(), new BucketIds(guildId), options: options); + } + + public Task ModifyStickerAsync(ModifyStickerParams args, ulong guildId, ulong stickerId, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(stickerId, 0, nameof(stickerId)); + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/stickers/{stickerId}", args, new BucketIds(guildId), options: options); + } + + public Task DeleteStickerAsync(ulong guildId, ulong stickerId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(stickerId, 0, nameof(stickerId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("DELETE", () => $"guilds/{guildId}/stickers/{stickerId}", new BucketIds(guildId), options: options); + } + + public Task AddReactionAsync(ulong channelId, ulong messageId, string emoji, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + Preconditions.NotNullOrWhitespace(emoji, nameof(emoji)); + + options = RequestOptions.CreateOrClone(options); + options.IsReactionBucket = true; + + var ids = new BucketIds(channelId: channelId); + + // @me is non-const to fool the ratelimiter, otherwise it will put add/remove in separate buckets + var me = "@me"; + return SendAsync("PUT", () => $"channels/{channelId}/messages/{messageId}/reactions/{emoji}/{me}", ids, options: options); + } + + public Task RemoveReactionAsync(ulong channelId, ulong messageId, ulong userId, string emoji, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + Preconditions.NotNullOrWhitespace(emoji, nameof(emoji)); + + options = RequestOptions.CreateOrClone(options); + options.IsReactionBucket = true; + + var ids = new BucketIds(channelId: channelId); + + var user = CurrentUserId.HasValue ? (userId == CurrentUserId.Value ? "@me" : userId.ToString()) : userId.ToString(); + return SendAsync("DELETE", () => $"channels/{channelId}/messages/{messageId}/reactions/{emoji}/{user}", ids, options: options); + } + + public Task RemoveAllReactionsAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + + return SendAsync("DELETE", () => $"channels/{channelId}/messages/{messageId}/reactions", ids, options: options); + } + + public Task RemoveAllReactionsForEmoteAsync(ulong channelId, ulong messageId, string emoji, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + Preconditions.NotNullOrWhitespace(emoji, nameof(emoji)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + + return SendAsync("DELETE", () => $"channels/{channelId}/messages/{messageId}/reactions/{emoji}", ids, options: options); + } + + public Task> GetReactionUsersAsync(ulong channelId, ulong messageId, string emoji, GetReactionUsersParams args, ReactionType reactionType, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + Preconditions.NotNullOrWhitespace(emoji, nameof(emoji)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.GreaterThan(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxUserReactionsPerBatch, nameof(args.Limit)); + Preconditions.GreaterThan(args.AfterUserId, 0, nameof(args.AfterUserId)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(DiscordConfig.MaxUserReactionsPerBatch); + ulong afterUserId = args.AfterUserId.GetValueOrDefault(0); + + var ids = new BucketIds(channelId: channelId); + Expression> endpoint = () => $"channels/{channelId}/messages/{messageId}/reactions/{emoji}?limit={limit}&after={afterUserId}&type={(int)reactionType}"; + return SendAsync>("GET", endpoint, ids, options: options); + } + + public Task AckMessageAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("POST", () => $"channels/{channelId}/messages/{messageId}/ack", ids, options: options); + } + + public Task TriggerTypingIndicatorAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("POST", () => $"channels/{channelId}/typing", ids, options: options); + } + + public Task CrosspostAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("POST", () => $"channels/{channelId}/messages/{messageId}/crosspost", ids, options: options); + } + + public Task FollowChannelAsync(ulong newsChannelId, ulong followingChannelId, RequestOptions options = null) + { + Preconditions.NotEqual(newsChannelId, 0, nameof(newsChannelId)); + Preconditions.NotEqual(followingChannelId, 0, nameof(followingChannelId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: newsChannelId); + return SendJsonAsync("POST", () => $"channels/{newsChannelId}/followers", new { webhook_channel_id = followingChannelId }, ids, options: options); + } + #endregion + + #region Channel Permissions + public Task ModifyChannelPermissionsAsync(ulong channelId, ulong targetId, ModifyChannelPermissionsParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(targetId, 0, nameof(targetId)); + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendJsonAsync("PUT", () => $"channels/{channelId}/permissions/{targetId}", args, ids, options: options); + } + + public Task DeleteChannelPermissionAsync(ulong channelId, ulong targetId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(targetId, 0, nameof(targetId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("DELETE", () => $"channels/{channelId}/permissions/{targetId}", ids, options: options); + } + #endregion + + #region Channel Pins + public Task AddPinAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.GreaterThan(channelId, 0, nameof(channelId)); + Preconditions.GreaterThan(messageId, 0, nameof(messageId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("PUT", () => $"channels/{channelId}/pins/{messageId}", ids, options: options); + } + + public Task RemovePinAsync(ulong channelId, ulong messageId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(messageId, 0, nameof(messageId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("DELETE", () => $"channels/{channelId}/pins/{messageId}", ids, options: options); + } + + public Task> GetPinsAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync>("GET", () => $"channels/{channelId}/pins", ids, options: options); + } + #endregion + + #region Channel Recipients + public Task AddGroupRecipientAsync(ulong channelId, ulong userId, RequestOptions options = null) + { + Preconditions.GreaterThan(channelId, 0, nameof(channelId)); + Preconditions.GreaterThan(userId, 0, nameof(userId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("PUT", () => $"channels/{channelId}/recipients/{userId}", ids, options: options); + } + + public Task RemoveGroupRecipientAsync(ulong channelId, ulong userId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync("DELETE", () => $"channels/{channelId}/recipients/{userId}", ids, options: options); + } + #endregion + + #region Interactions + public Task GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + if (locale is not null) + { + if (!System.Text.RegularExpressions.Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"{locale} is not a valid locale.", nameof(locale)); + + options.RequestHeaders["X-Discord-Locale"] = new[] { locale }; + } + + //with_localizations=false doesn't return localized names and descriptions + var query = withLocalizations ? "?with_localizations=true" : string.Empty; + return SendAsync("GET", () => $"applications/{CurrentApplicationId}/commands{query}", new BucketIds(), options: options); + } + + public async Task GetGlobalApplicationCommandAsync(ulong id, RequestOptions options = null) + { + Preconditions.NotEqual(id, 0, nameof(id)); + + options = RequestOptions.CreateOrClone(options); + + try + { + return await SendAsync("GET", () => $"applications/{CurrentApplicationId}/commands/{id}", new BucketIds(), options: options).ConfigureAwait(false); + } + catch (HttpException x) when (x.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + public Task CreateGlobalApplicationCommandAsync(CreateApplicationCommandParams command, RequestOptions options = null) + { + Preconditions.NotNull(command, nameof(command)); + Preconditions.AtMost(command.Name.Length, 32, nameof(command.Name)); + Preconditions.AtLeast(command.Name.Length, 1, nameof(command.Name)); + + if (command.Type == ApplicationCommandType.Slash) + { + Preconditions.NotNullOrEmpty(command.Description, nameof(command.Description)); + Preconditions.AtMost(command.Description.Length, 100, nameof(command.Description)); + Preconditions.AtLeast(command.Description.Length, 1, nameof(command.Description)); + } + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("POST", () => $"applications/{CurrentApplicationId}/commands", command, new BucketIds(), options: options); + } + + public Task ModifyGlobalApplicationCommandAsync(ModifyApplicationCommandParams command, ulong commandId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => $"applications/{CurrentApplicationId}/commands/{commandId}", command, new BucketIds(), options: options); + } + + public Task ModifyGlobalApplicationUserCommandAsync(ModifyApplicationCommandParams command, ulong commandId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => $"applications/{CurrentApplicationId}/commands/{commandId}", command, new BucketIds(), options: options); + } + + public Task ModifyGlobalApplicationMessageCommandAsync(ModifyApplicationCommandParams command, ulong commandId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => $"applications/{CurrentApplicationId}/commands/{commandId}", command, new BucketIds(), options: options); + } + + public Task DeleteGlobalApplicationCommandAsync(ulong commandId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendAsync("DELETE", () => $"applications/{CurrentApplicationId}/commands/{commandId}", new BucketIds(), options: options); + } + + public Task BulkOverwriteGlobalApplicationCommandsAsync(CreateApplicationCommandParams[] commands, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PUT", () => $"applications/{CurrentApplicationId}/commands", commands, new BucketIds(), options: options); + } + + public Task GetGuildApplicationCommandsAsync(ulong guildId, bool withLocalizations = false, string locale = null, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(guildId: guildId); + + if (locale is not null) + { + if (!System.Text.RegularExpressions.Regex.IsMatch(locale, @"^\w{2}(?:-\w{2})?$")) + throw new ArgumentException($"{locale} is not a valid locale.", nameof(locale)); + + options.RequestHeaders["X-Discord-Locale"] = new[] { locale }; + } + + //with_localizations=false doesn't return localized names and descriptions + var query = withLocalizations ? "?with_localizations=true" : string.Empty; + return SendAsync("GET", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands{query}", bucket, options: options); + } + + public async Task GetGuildApplicationCommandAsync(ulong guildId, ulong commandId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(guildId: guildId); + + try + { + return await SendAsync("GET", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands/{commandId}", bucket, options: options); + } + catch (HttpException x) when (x.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + public Task CreateGuildApplicationCommandAsync(CreateApplicationCommandParams command, ulong guildId, RequestOptions options = null) + { + Preconditions.NotNull(command, nameof(command)); + Preconditions.AtMost(command.Name.Length, 32, nameof(command.Name)); + Preconditions.AtLeast(command.Name.Length, 1, nameof(command.Name)); + + if (command.Type == ApplicationCommandType.Slash) + { + Preconditions.NotNullOrEmpty(command.Description, nameof(command.Description)); + Preconditions.AtMost(command.Description.Length, 100, nameof(command.Description)); + Preconditions.AtLeast(command.Description.Length, 1, nameof(command.Description)); + } + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(guildId: guildId); + + return SendJsonAsync("POST", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands", command, bucket, options: options); + } + + public Task ModifyGuildApplicationCommandAsync(ModifyApplicationCommandParams command, ulong guildId, ulong commandId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(guildId: guildId); + + return SendJsonAsync("PATCH", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands/{commandId}", command, bucket, options: options); + } + + public Task DeleteGuildApplicationCommandAsync(ulong guildId, ulong commandId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(guildId: guildId); + + return SendAsync("DELETE", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands/{commandId}", bucket, options: options); + } + + public Task BulkOverwriteGuildApplicationCommandsAsync(ulong guildId, CreateApplicationCommandParams[] commands, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(guildId: guildId); + + return SendJsonAsync("PUT", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands", commands, bucket, options: options); + } + #endregion + + #region Interaction Responses + public Task CreateInteractionResponseAsync(InteractionResponse response, ulong interactionId, string interactionToken, RequestOptions options = null) + { + if (response.Data.IsSpecified && response.Data.Value.Content.IsSpecified) + Preconditions.AtMost(response.Data.Value.Content.Value?.Length ?? 0, 2000, nameof(response.Data.Value.Content)); + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("POST", () => $"interactions/{interactionId}/{interactionToken}/callback", response, new BucketIds(), options: options); + } + + public Task CreateInteractionResponseAsync(UploadInteractionFileParams response, ulong interactionId, string interactionToken, RequestOptions options = null) + { + if ((!response.Embeds.IsSpecified || response.Embeds.Value == null || response.Embeds.Value.Length == 0) && !response.Files.Any()) + Preconditions.NotNullOrEmpty(response.Content, nameof(response.Content)); + + if (response.Content.IsSpecified && response.Content.Value.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(response.Content)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(); + return SendMultipartAsync("POST", () => $"interactions/{interactionId}/{interactionToken}/callback", response.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + public Task GetInteractionResponseAsync(string interactionToken, RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(interactionToken, nameof(interactionToken)); + + options = RequestOptions.CreateOrClone(options); + + return NullifyNotFound(SendAsync("GET", () => $"webhooks/{CurrentApplicationId}/{interactionToken}/messages/@original", new BucketIds(), options: options)); + } + + public Task ModifyInteractionResponseAsync(ModifyInteractionResponseParams args, string interactionToken, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => $"webhooks/{CurrentApplicationId}/{interactionToken}/messages/@original", args, new BucketIds(), options: options); + } + + public Task ModifyInteractionResponseAsync(UploadWebhookFileParams args, string interactionToken, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendMultipartAsync("PATCH", () => $"webhooks/{CurrentApplicationId}/{interactionToken}/messages/@original", args.ToDictionary(), new BucketIds(), options: options); + } + + public Task DeleteInteractionResponseAsync(string interactionToken, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + return SendAsync("DELETE", () => $"webhooks/{CurrentApplicationId}/{interactionToken}/messages/@original", new BucketIds(), options: options); + } + + public Task CreateInteractionFollowupMessageAsync(CreateWebhookMessageParams args, string token, RequestOptions options = null) + { + if ((!args.Embeds.IsSpecified || args.Embeds.Value == null || args.Embeds.Value.Length == 0) + && (!args.Content.IsSpecified || args.Content.Value is null || string.IsNullOrWhiteSpace(args.Content.Value)) + && (!args.Components.IsSpecified || args.Components.Value is null || args.Components.Value.Length == 0) + && (!args.File.IsSpecified)) + { + throw new ArgumentException("At least one of 'Content', 'Embeds', 'File' or 'Components' must be specified.", nameof(args)); + } + + if (args.Content.IsSpecified && args.Content.Value is not null && args.Content.Value.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); + + if (args.Content.IsSpecified && args.Content.Value?.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); + + options = RequestOptions.CreateOrClone(options); + + if (!args.File.IsSpecified) + return SendJsonAsync("POST", () => $"webhooks/{CurrentApplicationId}/{token}?wait=true", args, new BucketIds(), options: options); + else + return SendMultipartAsync("POST", () => $"webhooks/{CurrentApplicationId}/{token}?wait=true", args.ToDictionary(), new BucketIds(), options: options); + } + + public Task CreateInteractionFollowupMessageAsync(UploadWebhookFileParams args, string token, RequestOptions options = null) + { + if ((!args.Embeds.IsSpecified || args.Embeds.Value == null || args.Embeds.Value.Length == 0) + && (!args.Content.IsSpecified || args.Content.Value is null || string.IsNullOrWhiteSpace(args.Content.Value)) + && (!args.MessageComponents.IsSpecified || args.MessageComponents.Value is null || args.MessageComponents.Value.Length == 0) + && (args.Files.Length == 0)) + { + throw new ArgumentException("At least one of 'Content', 'Embeds', 'Files' or 'Components' must be specified.", nameof(args)); + } + if (args.Content.IsSpecified && args.Content.Value?.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(); + return SendMultipartAsync("POST", () => $"webhooks/{CurrentApplicationId}/{token}?wait=true", args.ToDictionary(), ids, clientBucket: ClientBucketType.SendEdit, options: options); + } + + public Task ModifyInteractionFollowupMessageAsync(ModifyInteractionResponseParams args, ulong id, string token, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(id, 0, nameof(id)); + + if (args.Content.IsSpecified && args.Content.Value?.Length > DiscordConfig.MaxMessageSize) + throw new ArgumentException(message: $"Message content is too long, length must be less or equal to {DiscordConfig.MaxMessageSize}.", paramName: nameof(args.Content)); + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => $"webhooks/{CurrentApplicationId}/{token}/messages/{id}", args, new BucketIds(), options: options); + } + + public Task DeleteInteractionFollowupMessageAsync(ulong id, string token, RequestOptions options = null) + { + Preconditions.NotEqual(id, 0, nameof(id)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("DELETE", () => $"webhooks/{CurrentApplicationId}/{token}/messages/{id}", new BucketIds(), options: options); + } + #endregion + + #region Application Command permissions + public Task GetGuildApplicationCommandPermissionsAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("GET", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands/permissions", new BucketIds(), options: options); + } + + public Task GetGuildApplicationCommandPermissionAsync(ulong guildId, ulong commandId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(commandId, 0, nameof(commandId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("GET", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands/{commandId}/permissions", new BucketIds(), options: options); + } + + public Task ModifyApplicationCommandPermissionsAsync(ModifyGuildApplicationCommandPermissionsParams permissions, ulong guildId, ulong commandId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(commandId, 0, nameof(commandId)); + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PUT", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands/{commandId}/permissions", permissions, new BucketIds(), options: options); + } + + public async Task> BatchModifyApplicationCommandPermissionsAsync(ModifyGuildApplicationCommandPermissions[] permissions, ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(permissions, nameof(permissions)); + + options = RequestOptions.CreateOrClone(options); + + return await SendJsonAsync("PUT", () => $"applications/{CurrentApplicationId}/guilds/{guildId}/commands/permissions", permissions, new BucketIds(), options: options).ConfigureAwait(false); + } + #endregion + + #region Guilds + public async Task GetGuildAsync(ulong guildId, bool withCounts, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(guildId: guildId); + return await SendAsync("GET", () => $"guilds/{guildId}?with_counts={(withCounts ? "true" : "false")}", ids, options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + public Task CreateGuildAsync(CreateGuildParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); + Preconditions.NotNullOrWhitespace(args.RegionId, nameof(args.RegionId)); + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("POST", () => "guilds", args, new BucketIds(), options: options); + } + + public Task DeleteGuildAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"guilds/{guildId}", ids, options: options); + } + + public Task LeaveGuildAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"users/@me/guilds/{guildId}", ids, options: options); + } + + public Task ModifyGuildAsync(ulong guildId, Rest.ModifyGuildParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(args.AfkChannelId, 0, nameof(args.AfkChannelId)); + Preconditions.AtLeast(args.AfkTimeout, 0, nameof(args.AfkTimeout)); + Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); + Preconditions.GreaterThan(args.OwnerId, 0, nameof(args.OwnerId)); + Preconditions.NotNull(args.RegionId, nameof(args.RegionId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PATCH", () => $"guilds/{guildId}", args, ids, options: options); + } + + public Task BeginGuildPruneAsync(ulong guildId, GuildPruneParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Days, 1, nameof(args.Days)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("POST", () => $"guilds/{guildId}/prune", args, ids, options: options); + } + + public Task GetGuildPruneCountAsync(ulong guildId, GuildPruneParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Days, 1, nameof(args.Days)); + string endpointRoleIds = args.IncludeRoleIds?.Length > 0 ? $"&include_roles={string.Join(",", args.IncludeRoleIds)}" : ""; + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("GET", () => $"guilds/{guildId}/prune?days={args.Days}{endpointRoleIds}", ids, options: options); + } + + public async Task ModifyGuildIncidentActionsAsync(ulong guildId, ModifyGuildIncidentsDataParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + var ids = new BucketIds(guildId: guildId); + + return await SendJsonAsync("PUT", () => $"guilds/{guildId}/incident-actions", args, ids, options: options).ConfigureAwait(false); + } + + #endregion + + #region Guild Bans + public Task> GetGuildBansAsync(ulong guildId, GetGuildBansParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxBansPerBatch, nameof(args.Limit)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(DiscordConfig.MaxBansPerBatch); + ulong? relativeId = args.RelativeUserId.IsSpecified ? args.RelativeUserId.Value : (ulong?)null; + var relativeDir = args.RelativeDirection.GetValueOrDefault(Direction.Before) switch + { + Direction.After => "after", + Direction.Around => "around", + _ => "before", + }; + var ids = new BucketIds(guildId: guildId); + Expression> endpoint; + if (relativeId != null) + endpoint = () => $"guilds/{guildId}/bans?limit={limit}&{relativeDir}={relativeId}"; + else + endpoint = () => $"guilds/{guildId}/bans?limit={limit}"; + return SendAsync>("GET", endpoint, ids, options: options); + } + + public async Task GetGuildBanAsync(ulong guildId, ulong userId, RequestOptions options) + { + Preconditions.NotEqual(userId, 0, nameof(userId)); + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(guildId: guildId); + return await SendAsync("GET", () => $"guilds/{guildId}/bans/{userId}", ids, options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + /// + /// and must not be equal to zero. + /// -and- + /// must be between 0 and 604800. + /// + public Task CreateGuildBanAsync(ulong guildId, ulong userId, uint deleteMessageSeconds, string reason, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + + Preconditions.AtMost(deleteMessageSeconds, 604800, nameof(deleteMessageSeconds), "Prune length must be within [0, 604800]"); + + var data = new CreateGuildBanParams { DeleteMessageSeconds = deleteMessageSeconds }; + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + if (!string.IsNullOrWhiteSpace(reason)) + options.AuditLogReason = reason; + return SendJsonAsync("PUT", () => $"guilds/{guildId}/bans/{userId}", data, ids, options: options); + } + + /// and must not be equal to zero. + public Task RemoveGuildBanAsync(ulong guildId, ulong userId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"guilds/{guildId}/bans/{userId}", ids, options: options); + } + + public Task BulkBanAsync(ulong guildId, ulong[] userIds, int? deleteMessagesSeconds = null, RequestOptions options = null) + { + Preconditions.NotEqual(userIds.Length, 0, nameof(userIds)); + Preconditions.AtMost(userIds.Length, 200, nameof(userIds)); + Preconditions.AtMost(deleteMessagesSeconds ?? 0, 604800, nameof(deleteMessagesSeconds)); + + options = RequestOptions.CreateOrClone(options); + + var data = new BulkBanParams + { + DeleteMessageSeconds = deleteMessagesSeconds ?? Optional.Unspecified, + UserIds = userIds + }; + + return SendJsonAsync("POST", () => $"guilds/{guildId}/bulk-ban", data, new BucketIds(guildId), options: options); + } + #endregion + + #region Guild Widget + /// must not be equal to zero. + public async Task GetGuildWidgetAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(guildId: guildId); + return await SendAsync("GET", () => $"guilds/{guildId}/widget", ids, options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + /// must not be equal to zero. + /// must not be . + public Task ModifyGuildWidgetAsync(ulong guildId, Rest.ModifyGuildWidgetParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/widget", args, ids, options: options); + } + #endregion + + #region Guild Integrations + /// must not be equal to zero. + public Task> GetIntegrationsAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync>("GET", () => $"guilds/{guildId}/integrations", ids, options: options); + } + + public Task DeleteIntegrationAsync(ulong guildId, ulong integrationId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(integrationId, 0, nameof(integrationId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"guilds/{guildId}/integrations/{integrationId}", ids, options: options); + } + #endregion + + #region Guild Invites + /// cannot be blank. + /// must not be . + public async Task GetInviteAsync(string inviteId, RequestOptions options = null, ulong? scheduledEventId = null) + { + Preconditions.NotNullOrEmpty(inviteId, nameof(inviteId)); + options = RequestOptions.CreateOrClone(options); + + //Remove trailing slash + if (inviteId[inviteId.Length - 1] == '/') + inviteId = inviteId.Substring(0, inviteId.Length - 1); + //Remove leading URL + int index = inviteId.LastIndexOf('/'); + if (index >= 0) + inviteId = inviteId.Substring(index + 1); + + var scheduledEventQuery = scheduledEventId is not null + ? $"&guild_scheduled_event_id={scheduledEventId}" + : string.Empty; + + try + { + return await SendAsync("GET", () => $"invites/{inviteId}?with_counts=true&with_expiration=true{scheduledEventQuery}", new BucketIds(), options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + /// may not be equal to zero. + public Task GetVanityInviteAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("GET", () => $"guilds/{guildId}/vanity-url", ids, options: options); + } + + /// may not be equal to zero. + public Task> GetGuildInvitesAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync>("GET", () => $"guilds/{guildId}/invites", ids, options: options); + } + + /// may not be equal to zero. + public Task> GetChannelInvitesAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync>("GET", () => $"channels/{channelId}/invites", ids, options: options); + } + + /// + /// may not be equal to zero. + /// -and- + /// and must be greater than zero. + /// -and- + /// must be lesser than 86400. + /// + /// must not be . + public Task CreateChannelInviteAsync(ulong channelId, CreateChannelInviteParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.MaxAge, 0, nameof(args.MaxAge)); + Preconditions.AtLeast(args.MaxUses, 0, nameof(args.MaxUses)); + Preconditions.AtMost(args.MaxAge, 86400, nameof(args.MaxAge), + "The maximum age of an invite must be less than or equal to a day (86400 seconds)."); + if (args.TargetType.IsSpecified) + { + Preconditions.NotEqual((int)args.TargetType.Value, (int)TargetUserType.Undefined, nameof(args.TargetType)); + if (args.TargetType.Value == TargetUserType.Stream) + Preconditions.GreaterThan(args.TargetUserId, 0, nameof(args.TargetUserId)); + if (args.TargetType.Value == TargetUserType.EmbeddedApplication) + Preconditions.GreaterThan(args.TargetApplicationId, 0, nameof(args.TargetApplicationId)); + } + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendJsonAsync("POST", () => $"channels/{channelId}/invites", args, ids, options: options); + } + + public Task DeleteInviteAsync(string inviteId, RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(inviteId, nameof(inviteId)); + options = RequestOptions.CreateOrClone(options); + + return SendAsync("DELETE", () => $"invites/{inviteId}", new BucketIds(), options: options); + } + #endregion + + #region Guild Members + public Task AddGuildMemberAsync(ulong guildId, ulong userId, AddGuildMemberParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNullOrWhitespace(args.AccessToken, nameof(args.AccessToken)); + + if (args.RoleIds.IsSpecified) + { + foreach (var roleId in args.RoleIds.Value) + Preconditions.NotEqual(roleId, 0, nameof(roleId)); + } + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PUT", () => $"guilds/{guildId}/members/{userId}", args, ids, options: options); + } + + public async Task GetGuildMemberAsync(ulong guildId, ulong userId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(guildId: guildId); + return await SendAsync("GET", () => $"guilds/{guildId}/members/{userId}", ids, options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + public Task> GetGuildMembersAsync(ulong guildId, GetGuildMembersParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.GreaterThan(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxUsersPerBatch, nameof(args.Limit)); + Preconditions.GreaterThan(args.AfterUserId, 0, nameof(args.AfterUserId)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(int.MaxValue); + ulong afterUserId = args.AfterUserId.GetValueOrDefault(0); + + var ids = new BucketIds(guildId: guildId); + Expression> endpoint = () => $"guilds/{guildId}/members?limit={limit}&after={afterUserId}"; + return SendAsync>("GET", endpoint, ids, options: options); + } + + public Task RemoveGuildMemberAsync(ulong guildId, ulong userId, string reason, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + if (!string.IsNullOrWhiteSpace(reason)) + options.AuditLogReason = reason; + return SendAsync("DELETE", () => $"guilds/{guildId}/members/{userId}", ids, options: options); + } + + public async Task ModifyGuildMemberAsync(ulong guildId, ulong userId, Rest.ModifyGuildMemberParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(userId, 0, nameof(userId)); + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + bool isCurrentUser = userId == CurrentUserId; + + if (isCurrentUser && args.Nickname.IsSpecified) + { + var nickArgs = new Rest.ModifyCurrentUserNickParams(args.Nickname.Value ?? ""); + await ModifyMyNickAsync(guildId, nickArgs).ConfigureAwait(false); + args.Nickname = Optional.Create(); //Remove + } + if (!isCurrentUser || args.Deaf.IsSpecified || args.Mute.IsSpecified || args.RoleIds.IsSpecified) + { + var ids = new BucketIds(guildId: guildId); + await SendJsonAsync("PATCH", () => $"guilds/{guildId}/members/{userId}", args, ids, options: options).ConfigureAwait(false); + } + } + + public Task> SearchGuildMembersAsync(ulong guildId, SearchGuildMembersParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.GreaterThan(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxUsersPerBatch, nameof(args.Limit)); + Preconditions.NotNullOrEmpty(args.Query, nameof(args.Query)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(DiscordConfig.MaxUsersPerBatch); + string query = args.Query; + + var ids = new BucketIds(guildId: guildId); + Expression> endpoint = () => $"guilds/{guildId}/members/search?limit={limit}&query={query}"; + return SendAsync>("GET", endpoint, ids, options: options); + } + #endregion + + #region Guild Roles + public Task> GetGuildRolesAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync>("GET", () => $"guilds/{guildId}/roles", ids, options: options); + } + + public async Task CreateGuildRoleAsync(ulong guildId, Rest.ModifyGuildRoleParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return await SendJsonAsync("POST", () => $"guilds/{guildId}/roles", args, ids, options: options).ConfigureAwait(false); + } + + public Task DeleteGuildRoleAsync(ulong guildId, ulong roleId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(roleId, 0, nameof(roleId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"guilds/{guildId}/roles/{roleId}", ids, options: options); + } + + public Task ModifyGuildRoleAsync(ulong guildId, ulong roleId, Rest.ModifyGuildRoleParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(roleId, 0, nameof(roleId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Color, 0, nameof(args.Color)); + Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/roles/{roleId}", args, ids, options: options); + } + + public Task> ModifyGuildRolesAsync(ulong guildId, IEnumerable args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync>("PATCH", () => $"guilds/{guildId}/roles", args, ids, options: options); + } + #endregion + + #region Guild emoji + public Task> GetGuildEmotesAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync>("GET", () => $"guilds/{guildId}/emojis", ids, options: options); + } + + public Task GetGuildEmoteAsync(ulong guildId, ulong emoteId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(emoteId, 0, nameof(emoteId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("GET", () => $"guilds/{guildId}/emojis/{emoteId}", ids, options: options); + } + + public Task CreateGuildEmoteAsync(ulong guildId, Rest.CreateGuildEmoteParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNullOrWhitespace(args.Name, nameof(args.Name)); + Preconditions.NotNull(args.Image.Stream, nameof(args.Image)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("POST", () => $"guilds/{guildId}/emojis", args, ids, options: options); + } + + public Task ModifyGuildEmoteAsync(ulong guildId, ulong emoteId, ModifyGuildEmoteParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(emoteId, 0, nameof(emoteId)); + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/emojis/{emoteId}", args, ids, options: options); + } + + public Task DeleteGuildEmoteAsync(ulong guildId, ulong emoteId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(emoteId, 0, nameof(emoteId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"guilds/{guildId}/emojis/{emoteId}", ids, options: options); + } + #endregion + + #region Guild Events + + public Task ListGuildScheduledEventsAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("GET", () => $"guilds/{guildId}/scheduled-events?with_user_count=true", ids, options: options); + } + + public Task GetGuildScheduledEventAsync(ulong eventId, ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(eventId, 0, nameof(eventId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return NullifyNotFound(SendAsync("GET", () => $"guilds/{guildId}/scheduled-events/{eventId}?with_user_count=true", ids, options: options)); + } + + public Task CreateGuildScheduledEventAsync(CreateGuildScheduledEventParams args, ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("POST", () => $"guilds/{guildId}/scheduled-events", args, ids, options: options); + } + + public Task ModifyGuildScheduledEventAsync(ModifyGuildScheduledEventParams args, ulong eventId, ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(eventId, 0, nameof(eventId)); + Preconditions.NotNull(args, nameof(args)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/scheduled-events/{eventId}", args, ids, options: options); + } + + public Task DeleteGuildScheduledEventAsync(ulong eventId, ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(eventId, 0, nameof(eventId)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("DELETE", () => $"guilds/{guildId}/scheduled-events/{eventId}", ids, options: options); + } + + public Task GetGuildScheduledEventUsersAsync(ulong eventId, ulong guildId, int limit = 100, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(eventId, 0, nameof(eventId)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync("GET", () => $"guilds/{guildId}/scheduled-events/{eventId}/users?limit={limit}&with_member=true", ids, options: options); + } + + public Task GetGuildScheduledEventUsersAsync(ulong eventId, ulong guildId, GetEventUsersParams args, RequestOptions options = null) + { + Preconditions.NotEqual(eventId, 0, nameof(eventId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtLeast(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxMessagesPerBatch, nameof(args.Limit)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(DiscordConfig.MaxGuildEventUsersPerBatch); + ulong? relativeId = args.RelativeUserId.IsSpecified ? args.RelativeUserId.Value : (ulong?)null; + var relativeDir = args.RelativeDirection.GetValueOrDefault(Direction.Before) switch + { + Direction.After => "after", + Direction.Around => "around", + _ => "before", + }; + var ids = new BucketIds(guildId: guildId); + Expression> endpoint; + if (relativeId != null) + endpoint = () => $"guilds/{guildId}/scheduled-events/{eventId}/users?with_member=true&limit={limit}&{relativeDir}={relativeId}"; + else + endpoint = () => $"guilds/{guildId}/scheduled-events/{eventId}/users?with_member=true&limit={limit}"; + + return SendAsync("GET", endpoint, ids, options: options); + } + + #endregion + + #region Guild AutoMod + + public Task GetGuildAutoModRulesAsync(ulong guildId, RequestOptions options) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("GET", () => $"guilds/{guildId}/auto-moderation/rules", new BucketIds(guildId: guildId), options: options); + } + + public Task GetGuildAutoModRuleAsync(ulong guildId, ulong ruleId, RequestOptions options) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(ruleId, 0, nameof(ruleId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("GET", () => $"guilds/{guildId}/auto-moderation/rules/{ruleId}", new BucketIds(guildId), options: options); + } + + public Task CreateGuildAutoModRuleAsync(ulong guildId, CreateAutoModRuleParams args, RequestOptions options) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("POST", () => $"guilds/{guildId}/auto-moderation/rules", args, new BucketIds(guildId: guildId), options: options); + } + + public Task ModifyGuildAutoModRuleAsync(ulong guildId, ulong ruleId, ModifyAutoModRuleParams args, RequestOptions options) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(ruleId, 0, nameof(ruleId)); + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/auto-moderation/rules/{ruleId}", args, new BucketIds(guildId: guildId), options: options); + } + + public Task DeleteGuildAutoModRuleAsync(ulong guildId, ulong ruleId, RequestOptions options) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotEqual(ruleId, 0, nameof(ruleId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("DELETE", () => $"guilds/{guildId}/auto-moderation/rules/{ruleId}", new BucketIds(guildId: guildId), options: options); + } + + #endregion + + #region Guild Welcome Screen + + public async Task GetGuildWelcomeScreenAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + try + { + var ids = new BucketIds(guildId: guildId); + return await SendAsync("GET", () => $"guilds/{guildId}/welcome-screen", ids, options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + public Task ModifyGuildWelcomeScreenAsync(ModifyGuildWelcomeScreenParams args, ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/welcome-screen", args, ids, options: options); + } + + #endregion + + #region Guild Onboarding + + public Task GetGuildOnboardingAsync(ulong guildId, RequestOptions options) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return SendAsync("GET", () => $"guilds/{guildId}/onboarding", new BucketIds(guildId: guildId), options: options); + } + + public Task ModifyGuildOnboardingAsync(ulong guildId, ModifyGuildOnboardingParams args, RequestOptions options) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PUT", () => $"guilds/{guildId}/onboarding", args, new BucketIds(guildId: guildId), options: options); + } + + #endregion + + #region Users + public async Task GetUserAsync(ulong userId, RequestOptions options = null) + { + Preconditions.NotEqual(userId, 0, nameof(userId)); + options = RequestOptions.CreateOrClone(options); + + try + { + return await SendAsync("GET", () => $"users/{userId}", new BucketIds(), options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + #endregion + + #region Current User/DMs + public Task GetMyUserAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync("GET", () => "users/@me", new BucketIds(), options: options); + } + + public Task> GetMyConnectionsAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync>("GET", () => "users/@me/connections", new BucketIds(), options: options); + } + + public Task> GetMyPrivateChannelsAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync>("GET", () => "users/@me/channels", new BucketIds(), options: options); + } + + public Task> GetMyGuildsAsync(GetGuildSummariesParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.GreaterThan(args.Limit, 0, nameof(args.Limit)); + Preconditions.AtMost(args.Limit, DiscordConfig.MaxGuildsPerBatch, nameof(args.Limit)); + Preconditions.GreaterThan(args.AfterGuildId, 0, nameof(args.AfterGuildId)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(int.MaxValue); + ulong afterGuildId = args.AfterGuildId.GetValueOrDefault(0); + + return SendAsync>("GET", () => $"users/@me/guilds?limit={limit}&after={afterGuildId}&with_counts=true", new BucketIds(), options: options); + } + + public Task GetMyApplicationAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync("GET", () => "oauth2/applications/@me", new BucketIds(), options: options); + } + + public Task GetCurrentBotApplicationAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync("GET", () => "applications/@me", new BucketIds(), options: options); + } + + public Task ModifyCurrentBotApplicationAsync(ModifyCurrentApplicationBotParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => "applications/@me", args, new BucketIds(), options: options); + } + + public Task ModifySelfAsync(Rest.ModifyCurrentUserParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNullOrEmpty(args.Username, nameof(args.Username)); + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("PATCH", () => "users/@me", args, new BucketIds(), options: options); + } + + public Task ModifyMyNickAsync(ulong guildId, Rest.ModifyCurrentUserNickParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNull(args.Nickname, nameof(args.Nickname)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendJsonAsync("PATCH", () => $"guilds/{guildId}/members/@me/nick", args, ids, options: options); + } + + public Task CreateDMChannelAsync(CreateDMChannelParams args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.GreaterThan(args.RecipientId, 0, nameof(args.RecipientId)); + options = RequestOptions.CreateOrClone(options); + + return SendJsonAsync("POST", () => "users/@me/channels", args, new BucketIds(), options: options); + } + + public Task GetCurrentUserGuildMember(ulong guildId, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(); + return SendAsync("GET", () => $"users/@me/guilds/{guildId}/member", ids, options: options); + } + #endregion + + #region Voice Regions + public Task> GetVoiceRegionsAsync(RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendAsync>("GET", () => "voice/regions", new BucketIds(), options: options); + } + + public Task> GetGuildVoiceRegionsAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync>("GET", () => $"guilds/{guildId}/regions", ids, options: options); + } + #endregion + + #region Audit logs + public Task GetAuditLogsAsync(ulong guildId, GetAuditLogsParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + Preconditions.NotNull(args, nameof(args)); + options = RequestOptions.CreateOrClone(options); + + int limit = args.Limit.GetValueOrDefault(int.MaxValue); + + var ids = new BucketIds(guildId: guildId); + Expression> endpoint; + + var queryArgs = new StringBuilder(); + if (args.BeforeEntryId.IsSpecified) + { + queryArgs.Append("&before=") + .Append(args.BeforeEntryId); + } + if (args.UserId.IsSpecified) + { + queryArgs.Append("&user_id=") + .Append(args.UserId.Value); + } + if (args.ActionType.IsSpecified) + { + queryArgs.Append("&action_type=") + .Append(args.ActionType.Value); + } + if (args.AfterEntryId.IsSpecified) + { + queryArgs.Append("&after=") + .Append(args.AfterEntryId); + } + + // Still use string interpolation for the query w/o params, as this is necessary for CreateBucketId + endpoint = () => $"guilds/{guildId}/audit-logs?limit={limit}{queryArgs.ToString()}"; + return SendAsync("GET", endpoint, ids, options: options); + } + #endregion + + #region Webhooks + public Task CreateWebhookAsync(ulong channelId, CreateWebhookParams args, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNull(args.Name, nameof(args.Name)); + options = RequestOptions.CreateOrClone(options); + var ids = new BucketIds(channelId: channelId); + + return SendJsonAsync("POST", () => $"channels/{channelId}/webhooks", args, ids, options: options); + } + + public async Task GetWebhookAsync(ulong webhookId, RequestOptions options = null) + { + Preconditions.NotEqual(webhookId, 0, nameof(webhookId)); + options = RequestOptions.CreateOrClone(options); + + try + { + if (AuthTokenType == TokenType.Webhook) + return await SendAsync("GET", () => $"webhooks/{webhookId}/{AuthToken}", new BucketIds(), options: options).ConfigureAwait(false); + else + return await SendAsync("GET", () => $"webhooks/{webhookId}", new BucketIds(), options: options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.NotFound) { return null; } + } + + public Task ModifyWebhookAsync(ulong webhookId, ModifyWebhookParams args, RequestOptions options = null) + { + Preconditions.NotEqual(webhookId, 0, nameof(webhookId)); + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotNullOrEmpty(args.Name, nameof(args.Name)); + options = RequestOptions.CreateOrClone(options); + + if (AuthTokenType == TokenType.Webhook) + return SendJsonAsync("PATCH", () => $"webhooks/{webhookId}/{AuthToken}", args, new BucketIds(), options: options); + else + return SendJsonAsync("PATCH", () => $"webhooks/{webhookId}", args, new BucketIds(), options: options); + } + + public Task DeleteWebhookAsync(ulong webhookId, RequestOptions options = null) + { + Preconditions.NotEqual(webhookId, 0, nameof(webhookId)); + options = RequestOptions.CreateOrClone(options); + + if (AuthTokenType == TokenType.Webhook) + return SendAsync("DELETE", () => $"webhooks/{webhookId}/{AuthToken}", new BucketIds(), options: options); + else + return SendAsync("DELETE", () => $"webhooks/{webhookId}", new BucketIds(), options: options); + } + + public Task> GetGuildWebhooksAsync(ulong guildId, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(guildId: guildId); + return SendAsync>("GET", () => $"guilds/{guildId}/webhooks", ids, options: options); + } + + public Task> GetChannelWebhooksAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + options = RequestOptions.CreateOrClone(options); + + var ids = new BucketIds(channelId: channelId); + return SendAsync>("GET", () => $"channels/{channelId}/webhooks", ids, options: options); + } + #endregion + + #region Helpers + /// Client is not logged in. + protected void CheckState() + { + if (LoginState != LoginState.LoggedIn) + throw new InvalidOperationException("Client is not logged in."); + } + protected static double ToMilliseconds(Stopwatch stopwatch) => Math.Round((double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2); + protected string SerializeJson(object value) + { + var sb = new StringBuilder(256); + using (TextWriter text = new StringWriter(sb, CultureInfo.InvariantCulture)) + using (JsonWriter writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, value); + return sb.ToString(); + } + protected T DeserializeJson(Stream jsonStream) + { + using (TextReader text = new StreamReader(jsonStream)) + using (JsonReader reader = new JsonTextReader(text)) + return _serializer.Deserialize(reader); + } + + protected async Task NullifyNotFound(Task sendTask) where T : class + { + try + { + var result = await sendTask.ConfigureAwait(false); + + if (sendTask.Exception != null) + { + if (sendTask.Exception.InnerException is HttpException x) + { + if (x.HttpCode == HttpStatusCode.NotFound) + { + return null; + } + } + + throw sendTask.Exception; + } + else + return result; + } + catch (HttpException x) when (x.HttpCode == HttpStatusCode.NotFound) + { + return null; + } + } + internal class BucketIds + { + public ulong GuildId { get; internal set; } + public ulong ChannelId { get; internal set; } + public ulong WebhookId { get; internal set; } + public string HttpMethod { get; internal set; } + + internal BucketIds(ulong guildId = 0, ulong channelId = 0, ulong webhookId = 0) + { + GuildId = guildId; + ChannelId = channelId; + WebhookId = webhookId; + } + + internal object[] ToArray() + => new object[] { HttpMethod, GuildId, ChannelId, WebhookId }; + + internal Dictionary ToMajorParametersDictionary() + { + var dict = new Dictionary(); + if (GuildId != 0) + dict["GuildId"] = GuildId.ToString(); + if (ChannelId != 0) + dict["ChannelId"] = ChannelId.ToString(); + if (WebhookId != 0) + dict["WebhookId"] = WebhookId.ToString(); + return dict; + } + + internal static int? GetIndex(string name) + { + return name switch + { + "httpMethod" => 0, + "guildId" => 1, + "channelId" => 2, + "webhookId" => 3, + _ => null, + }; + } + } + + private static string GetEndpoint(Expression> endpointExpr) + { + return endpointExpr.Compile()(); + } + private static BucketId GetBucketId(string httpMethod, BucketIds ids, Expression> endpointExpr, string callingMethod) + { + ids.HttpMethod ??= httpMethod; + return _bucketIdGenerators.GetOrAdd(callingMethod, x => CreateBucketId(endpointExpr))(ids); + } + + private static Func CreateBucketId(Expression> endpoint) + { + try + { + //Is this a constant string? + if (endpoint.Body.NodeType == ExpressionType.Constant) + return x => BucketId.Create(x.HttpMethod, (endpoint.Body as ConstantExpression).Value.ToString(), x.ToMajorParametersDictionary()); + + var builder = new StringBuilder(); + var methodCall = endpoint.Body as MethodCallExpression; + var methodArgs = methodCall.Arguments.ToArray(); + string format = (methodArgs[0] as ConstantExpression).Value as string; + + //Unpack the array, if one exists (happens with 4+ parameters) + if (methodArgs.Length > 1 && methodArgs[1].NodeType == ExpressionType.NewArrayInit) + { + var arrayExpr = methodArgs[1] as NewArrayExpression; + var elements = arrayExpr.Expressions.ToArray(); + Array.Resize(ref methodArgs, elements.Length + 1); + Array.Copy(elements, 0, methodArgs, 1, elements.Length); + } + + int endIndex = format.IndexOf('?'); //Don't include params + if (endIndex == -1) + endIndex = format.Length; + + int lastIndex = 0; + while (true) + { + int leftIndex = format.IndexOf("{", lastIndex); + if (leftIndex == -1 || leftIndex > endIndex) + { + builder.Append(format, lastIndex, endIndex - lastIndex); + break; + } + builder.Append(format, lastIndex, leftIndex - lastIndex); + int rightIndex = format.IndexOf("}", leftIndex); + + int argId = int.Parse(format.Substring(leftIndex + 1, rightIndex - leftIndex - 1), NumberStyles.None, CultureInfo.InvariantCulture); + string fieldName = GetFieldName(methodArgs[argId + 1]); + + var mappedId = BucketIds.GetIndex(fieldName); + + if (!mappedId.HasValue && rightIndex != endIndex && format.Length > rightIndex + 1 && format[rightIndex + 1] == '/') //Ignore the next slash + rightIndex++; + + if (mappedId.HasValue) + builder.Append($"{{{mappedId.Value}}}"); + + lastIndex = rightIndex + 1; + } + if (builder[builder.Length - 1] == '/') + builder.Remove(builder.Length - 1, 1); + + format = builder.ToString(); + + return x => BucketId.Create(x.HttpMethod, string.Format(format, x.ToArray()), x.ToMajorParametersDictionary()); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to generate the bucket id for this operation.", ex); + } + } + + private static string GetFieldName(Expression expr) + { + if (expr.NodeType == ExpressionType.Convert) + expr = (expr as UnaryExpression).Operand; + + if (expr.NodeType != ExpressionType.MemberAccess) + throw new InvalidOperationException("Unsupported expression"); + + return (expr as MemberExpression).Member.Name; + } + + private static string WebhookQuery(bool wait = false, ulong? threadId = null) + { + List querys = new List() { }; + if (wait) + querys.Add("wait=true"); + if (threadId.HasValue) + querys.Add($"thread_id={threadId}"); + + return $"{string.Join("&", querys)}"; + } + + #endregion + + #region Application Role Connections Metadata + + public Task GetApplicationRoleConnectionMetadataRecordsAsync(RequestOptions options = null) + => SendAsync("GET", () => $"applications/{CurrentApplicationId}/role-connections/metadata", new BucketIds(), options: options); + + public Task UpdateApplicationRoleConnectionMetadataRecordsAsync(RoleConnectionMetadata[] roleConnections, RequestOptions options = null) + => SendJsonAsync("PUT", () => $"applications/{CurrentApplicationId}/role-connections/metadata", roleConnections, new BucketIds(), options: options); + + public Task GetUserApplicationRoleConnectionAsync(ulong applicationId, RequestOptions options = null) + => SendAsync("GET", () => $"users/@me/applications/{applicationId}/role-connection", new BucketIds(), options: options); + + public Task ModifyUserApplicationRoleConnectionAsync(ulong applicationId, RoleConnection connection, RequestOptions options = null) + => SendJsonAsync("PUT", () => $"users/@me/applications/{applicationId}/role-connection", connection, new BucketIds(), options: options); + + #endregion + + #region App Monetization + + public Task CreateEntitlementAsync(CreateEntitlementParams args, RequestOptions options = null) + => SendJsonAsync("POST", () => $"applications/{CurrentApplicationId}/entitlements", args, new BucketIds(), options: options); + + public Task DeleteEntitlementAsync(ulong entitlementId, RequestOptions options = null) + => SendAsync("DELETE", () => $"applications/{CurrentApplicationId}/entitlements/{entitlementId}", new BucketIds(), options: options); + + public Task ListEntitlementAsync(ListEntitlementsParams args, RequestOptions options = null) + { + var query = $"?limit={args.Limit.GetValueOrDefault(100)}"; + + if (args.UserId.IsSpecified) + { + query += $"&user_id={args.UserId.Value}"; + } + + if (args.SkuIds.IsSpecified) + { + query += $"&sku_ids={WebUtility.UrlEncode(string.Join(",", args.SkuIds.Value))}"; + } + + if (args.BeforeId.IsSpecified) + { + query += $"&before={args.BeforeId.Value}"; + } + + if (args.AfterId.IsSpecified) + { + query += $"&after={args.AfterId.Value}"; + } + + if (args.GuildId.IsSpecified) + { + query += $"&guild_id={args.GuildId.Value}"; + } + + if (args.ExcludeEnded.IsSpecified) + { + query += $"&exclude_ended={args.ExcludeEnded.Value}"; + } + + return SendAsync("GET", () => $"applications/{CurrentApplicationId}/entitlements{query}", new BucketIds(), options: options); + } + + public Task ListSKUsAsync(RequestOptions options = null) + => SendAsync("GET", () => $"applications/{CurrentApplicationId}/skus", new BucketIds(), options: options); + + #endregion + } +} diff --git a/src/Discord.Net.Rest/DiscordRestClient.cs b/src/Discord.Net.Rest/DiscordRestClient.cs new file mode 100644 index 0000000..4c8615d --- /dev/null +++ b/src/Discord.Net.Rest/DiscordRestClient.cs @@ -0,0 +1,405 @@ +//using Discord.Rest.Entities.Interactions; +using Discord.Net; +using Discord.Net.Converters; +using Discord.Net.ED25519; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + /// + /// Provides a client to send REST-based requests to Discord. + /// + public class DiscordRestClient : BaseDiscordClient, IDiscordClient, IRestClientProvider + { + #region DiscordRestClient + private RestApplication _applicationInfo; + private RestApplication _currentBotApplication; + + internal static JsonSerializer Serializer = new JsonSerializer() { ContractResolver = new DiscordContractResolver(), NullValueHandling = NullValueHandling.Include }; + + /// + /// Gets the logged-in user. + /// + public new RestSelfUser CurrentUser { get => base.CurrentUser as RestSelfUser; internal set => base.CurrentUser = value; } + + /// + public DiscordRestClient() : this(new DiscordRestConfig()) { } + /// + /// Initializes a new with the provided configuration. + /// + /// The configuration to be used with the client. + public DiscordRestClient(DiscordRestConfig config) : base(config, CreateApiClient(config)) + { + _apiOnCreation = config.APIOnRestInteractionCreation; + } + // used for socket client rest access + internal DiscordRestClient(DiscordRestConfig config, API.DiscordRestApiClient api) : base(config, api) + { + _apiOnCreation = config.APIOnRestInteractionCreation; + } + + private static API.DiscordRestApiClient CreateApiClient(DiscordRestConfig config) + => new API.DiscordRestApiClient(config.RestClientProvider, DiscordRestConfig.UserAgent, serializer: Serializer, useSystemClock: config.UseSystemClock, defaultRatelimitCallback: config.DefaultRatelimitCallback); + + internal override void Dispose(bool disposing) + { + if (disposing) + ApiClient.Dispose(); + + base.Dispose(disposing); + } + + internal override async ValueTask DisposeAsync(bool disposing) + { + if (disposing) + await ApiClient.DisposeAsync().ConfigureAwait(false); + + base.Dispose(disposing); + } + + /// + internal override async Task OnLoginAsync(TokenType tokenType, string token) + { + var user = await ApiClient.GetMyUserAsync(new RequestOptions { RetryMode = RetryMode.AlwaysRetry }).ConfigureAwait(false); + ApiClient.CurrentUserId = user.Id; + base.CurrentUser = RestSelfUser.Create(this, user); + + if (tokenType == TokenType.Bot) + { + await GetApplicationInfoAsync(new RequestOptions { RetryMode = RetryMode.AlwaysRetry }).ConfigureAwait(false); + ApiClient.CurrentApplicationId = _applicationInfo.Id; + } + } + + internal void CreateRestSelfUser(API.User user) + { + base.CurrentUser = RestSelfUser.Create(this, user); + } + /// + internal override Task OnLogoutAsync() + { + _applicationInfo = null; + return Task.Delay(0); + } + + #region Rest interactions + + private readonly bool _apiOnCreation; + + public bool IsValidHttpInteraction(string publicKey, string signature, string timestamp, string body) + => IsValidHttpInteraction(publicKey, signature, timestamp, Encoding.UTF8.GetBytes(body)); + public bool IsValidHttpInteraction(string publicKey, string signature, string timestamp, byte[] body) + { + var key = HexConverter.HexToByteArray(publicKey); + var sig = HexConverter.HexToByteArray(signature); + var tsp = Encoding.UTF8.GetBytes(timestamp); + + var message = new List(); + message.AddRange(tsp); + message.AddRange(body); + + return IsValidHttpInteraction(key, sig, message.ToArray()); + } + + private bool IsValidHttpInteraction(byte[] publicKey, byte[] signature, byte[] message) + { + return Ed25519.Verify(signature, message, publicKey); + } + + /// + /// Creates a from a http message. + /// + /// The public key of your application + /// The signature sent with the interaction. + /// The timestamp sent with the interaction. + /// The body of the http message. + /// + /// A that represents the incoming http interaction. + /// + /// Thrown when the signature doesn't match the public key. + public Task ParseHttpInteractionAsync(string publicKey, string signature, string timestamp, string body, Func doApiCallOnCreation = null) + => ParseHttpInteractionAsync(publicKey, signature, timestamp, Encoding.UTF8.GetBytes(body), doApiCallOnCreation); + + /// + /// Creates a from a http message. + /// + /// The public key of your application + /// The signature sent with the interaction. + /// The timestamp sent with the interaction. + /// The body of the http message. + /// + /// A that represents the incoming http interaction. + /// + /// Thrown when the signature doesn't match the public key. + public async Task ParseHttpInteractionAsync(string publicKey, string signature, string timestamp, byte[] body, Func doApiCallOnCreation = null) + { + if (!IsValidHttpInteraction(publicKey, signature, timestamp, body)) + { + throw new BadSignatureException(); + } + + using (var textReader = new StringReader(Encoding.UTF8.GetString(body))) + using (var jsonReader = new JsonTextReader(textReader)) + { + var model = Serializer.Deserialize(jsonReader); + return await RestInteraction.CreateAsync(this, model, doApiCallOnCreation is not null ? doApiCallOnCreation(new InteractionProperties(model)) : _apiOnCreation); + } + } + + #endregion + + public async Task GetCurrentUserAsync(RequestOptions options = null) + { + var user = await ApiClient.GetMyUserAsync(options); + CurrentUser.Update(user); + return CurrentUser; + } + + public async Task GetCurrentUserGuildMemberAsync(ulong guildId, RequestOptions options = null) + { + var user = await ApiClient.GetCurrentUserGuildMember(guildId, options); + return RestGuildUser.Create(this, null, user, guildId); + } + + public async Task GetApplicationInfoAsync(RequestOptions options = null) + { + return _applicationInfo ??= await ClientHelper.GetApplicationInfoAsync(this, options).ConfigureAwait(false); + } + + public async Task GetCurrentBotInfoAsync(RequestOptions options = null) + { + return _currentBotApplication = await ClientHelper.GetCurrentBotApplicationAsync(this, options); + } + + public async Task ModifyCurrentBotApplicationAsync(Action args, RequestOptions options = null) + { + var model = await ClientHelper.ModifyCurrentBotApplicationAsync(this, args, options); + + if (_currentBotApplication is null) + _currentBotApplication = RestApplication.Create(this, model); + else + _currentBotApplication.Update(model); + return _currentBotApplication; + } + + public Task GetChannelAsync(ulong id, RequestOptions options = null) + => ClientHelper.GetChannelAsync(this, id, options); + public Task> GetPrivateChannelsAsync(RequestOptions options = null) + => ClientHelper.GetPrivateChannelsAsync(this, options); + public Task> GetDMChannelsAsync(RequestOptions options = null) + => ClientHelper.GetDMChannelsAsync(this, options); + public Task> GetGroupChannelsAsync(RequestOptions options = null) + => ClientHelper.GetGroupChannelsAsync(this, options); + + public Task> GetConnectionsAsync(RequestOptions options = null) + => ClientHelper.GetConnectionsAsync(this, options); + + public Task GetInviteAsync(string inviteId, RequestOptions options = null, ulong? scheduledEventId = null) + => ClientHelper.GetInviteAsync(this, inviteId, options, scheduledEventId); + + public Task GetGuildAsync(ulong id, RequestOptions options = null) + => ClientHelper.GetGuildAsync(this, id, false, options); + public Task GetGuildAsync(ulong id, bool withCounts, RequestOptions options = null) + => ClientHelper.GetGuildAsync(this, id, withCounts, options); + public Task GetGuildWidgetAsync(ulong id, RequestOptions options = null) + => ClientHelper.GetGuildWidgetAsync(this, id, options); + public IAsyncEnumerable> GetGuildSummariesAsync(RequestOptions options = null) + => ClientHelper.GetGuildSummariesAsync(this, null, null, options); + public IAsyncEnumerable> GetGuildSummariesAsync(ulong fromGuildId, int limit, RequestOptions options = null) + => ClientHelper.GetGuildSummariesAsync(this, fromGuildId, limit, options); + public Task> GetGuildsAsync(RequestOptions options = null) + => ClientHelper.GetGuildsAsync(this, false, options); + public Task> GetGuildsAsync(bool withCounts, RequestOptions options = null) + => ClientHelper.GetGuildsAsync(this, withCounts, options); + public Task CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon = null, RequestOptions options = null) + => ClientHelper.CreateGuildAsync(this, name, region, jpegIcon, options); + + public Task GetUserAsync(ulong id, RequestOptions options = null) + => ClientHelper.GetUserAsync(this, id, options); + public Task GetGuildUserAsync(ulong guildId, ulong id, RequestOptions options = null) + => ClientHelper.GetGuildUserAsync(this, guildId, id, options); + + public Task> GetVoiceRegionsAsync(RequestOptions options = null) + => ClientHelper.GetVoiceRegionsAsync(this, options); + public Task GetVoiceRegionAsync(string id, RequestOptions options = null) + => ClientHelper.GetVoiceRegionAsync(this, id, options); + public Task GetWebhookAsync(ulong id, RequestOptions options = null) + => ClientHelper.GetWebhookAsync(this, id, options); + + public Task CreateGlobalCommand(ApplicationCommandProperties properties, RequestOptions options = null) + => ClientHelper.CreateGlobalApplicationCommandAsync(this, properties, options); + public Task CreateGuildCommand(ApplicationCommandProperties properties, ulong guildId, RequestOptions options = null) + => ClientHelper.CreateGuildApplicationCommandAsync(this, guildId, properties, options); + public Task> GetGlobalApplicationCommands(bool withLocalizations = false, string locale = null, RequestOptions options = null) + => ClientHelper.GetGlobalApplicationCommandsAsync(this, withLocalizations, locale, options); + public Task> GetGuildApplicationCommands(ulong guildId, bool withLocalizations = false, string locale = null, RequestOptions options = null) + => ClientHelper.GetGuildApplicationCommandsAsync(this, guildId, withLocalizations, locale, options); + public Task> BulkOverwriteGlobalCommands(ApplicationCommandProperties[] commandProperties, RequestOptions options = null) + => ClientHelper.BulkOverwriteGlobalApplicationCommandAsync(this, commandProperties, options); + public Task> BulkOverwriteGuildCommands(ApplicationCommandProperties[] commandProperties, ulong guildId, RequestOptions options = null) + => ClientHelper.BulkOverwriteGuildApplicationCommandAsync(this, guildId, commandProperties, options); + public Task> BatchEditGuildCommandPermissions(ulong guildId, IDictionary permissions, RequestOptions options = null) + => InteractionHelper.BatchEditGuildCommandPermissionsAsync(this, guildId, permissions, options); + public Task DeleteAllGlobalCommandsAsync(RequestOptions options = null) + => InteractionHelper.DeleteAllGlobalCommandsAsync(this, options); + + public Task AddRoleAsync(ulong guildId, ulong userId, ulong roleId, RequestOptions options = null) + => ClientHelper.AddRoleAsync(this, guildId, userId, roleId, options); + public Task RemoveRoleAsync(ulong guildId, ulong userId, ulong roleId, RequestOptions options = null) + => ClientHelper.RemoveRoleAsync(this, guildId, userId, roleId, options); + + public Task AddReactionAsync(ulong channelId, ulong messageId, IEmote emote, RequestOptions options = null) + => MessageHelper.AddReactionAsync(channelId, messageId, emote, this, options); + public Task RemoveReactionAsync(ulong channelId, ulong messageId, ulong userId, IEmote emote, RequestOptions options = null) + => MessageHelper.RemoveReactionAsync(channelId, messageId, userId, emote, this, options); + public Task RemoveAllReactionsAsync(ulong channelId, ulong messageId, RequestOptions options = null) + => MessageHelper.RemoveAllReactionsAsync(channelId, messageId, this, options); + public Task RemoveAllReactionsForEmoteAsync(ulong channelId, ulong messageId, IEmote emote, RequestOptions options = null) + => MessageHelper.RemoveAllReactionsForEmoteAsync(channelId, messageId, emote, this, options); + + public Task> GetRoleConnectionMetadataRecordsAsync(RequestOptions options = null) + => ClientHelper.GetRoleConnectionMetadataRecordsAsync(this, options); + + public Task> ModifyRoleConnectionMetadataRecordsAsync(ICollection metadata, RequestOptions options = null) + { + Preconditions.AtMost(metadata.Count, 5, nameof(metadata), "An application can have a maximum of 5 metadata records."); + return ClientHelper.ModifyRoleConnectionMetadataRecordsAsync(metadata, this, options); + } + + public Task GetUserApplicationRoleConnectionAsync(ulong applicationId, RequestOptions options = null) + => ClientHelper.GetUserRoleConnectionAsync(applicationId, this, options); + + public Task ModifyUserApplicationRoleConnectionAsync(ulong applicationId, RoleConnectionProperties roleConnection, RequestOptions options = null) + => ClientHelper.ModifyUserRoleConnectionAsync(applicationId, roleConnection, this, options); + + /// + public Task CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options = null) + => ClientHelper.CreateTestEntitlementAsync(this, skuId, ownerId, ownerType, options); + + /// + public Task DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options = null) + => ApiClient.DeleteEntitlementAsync(entitlementId, options); + + /// + public IAsyncEnumerable> GetEntitlementsAsync(int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null) + => ClientHelper.ListEntitlementsAsync(this, limit, afterId, beforeId, excludeEnded, guildId, userId, skuIds, options); + + /// + public Task> GetSKUsAsync(RequestOptions options = null) + => ClientHelper.ListSKUsAsync(this, options); + + #endregion + + #region IDiscordClient + async Task IDiscordClient.CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options) + => await CreateTestEntitlementAsync(skuId, ownerId, ownerType, options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetApplicationInfoAsync(RequestOptions options) + => await GetApplicationInfoAsync(options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + async Task> IDiscordClient.GetPrivateChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetPrivateChannelsAsync(options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + /// + async Task> IDiscordClient.GetDMChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetDMChannelsAsync(options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + /// + async Task> IDiscordClient.GetGroupChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetGroupChannelsAsync(options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + + /// + async Task> IDiscordClient.GetConnectionsAsync(RequestOptions options) + => await GetConnectionsAsync(options).ConfigureAwait(false); + + async Task IDiscordClient.GetInviteAsync(string inviteId, RequestOptions options) + => await GetInviteAsync(inviteId, options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetGuildAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetGuildAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + async Task> IDiscordClient.GetGuildsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetGuildsAsync(options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + /// + async Task IDiscordClient.CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon, RequestOptions options) + => await CreateGuildAsync(name, region, jpegIcon, options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetUserAsync(id, options).ConfigureAwait(false); + else + return null; + } + + /// + async Task> IDiscordClient.GetVoiceRegionsAsync(RequestOptions options) + => await GetVoiceRegionsAsync(options).ConfigureAwait(false); + /// + async Task IDiscordClient.GetVoiceRegionAsync(string id, RequestOptions options) + => await GetVoiceRegionAsync(id, options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetWebhookAsync(ulong id, RequestOptions options) + => await GetWebhookAsync(id, options).ConfigureAwait(false); + + /// + async Task> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) + => await GetGlobalApplicationCommands(withLocalizations, locale, options).ConfigureAwait(false); + /// + async Task IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options) + => await ClientHelper.GetGlobalApplicationCommandAsync(this, id, options).ConfigureAwait(false); + /// + async Task IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options) + => await CreateGlobalCommand(properties, options).ConfigureAwait(false); + /// + async Task> IDiscordClient.BulkOverwriteGlobalApplicationCommand(ApplicationCommandProperties[] properties, RequestOptions options) + => await BulkOverwriteGlobalCommands(properties, options).ConfigureAwait(false); + #endregion + + DiscordRestClient IRestClientProvider.RestClient => this; + } +} diff --git a/src/Discord.Net.Rest/DiscordRestConfig.cs b/src/Discord.Net.Rest/DiscordRestConfig.cs new file mode 100644 index 0000000..a09d9ee --- /dev/null +++ b/src/Discord.Net.Rest/DiscordRestConfig.cs @@ -0,0 +1,15 @@ +using Discord.Net.Rest; + +namespace Discord.Rest +{ + /// + /// Represents a configuration class for . + /// + public class DiscordRestConfig : DiscordConfig + { + /// Gets or sets the provider used to generate new REST connections. + public RestClientProvider RestClientProvider { get; set; } = DefaultRestClientProvider.Instance; + + public bool APIOnRestInteractionCreation { get; set; } = true; + } +} diff --git a/src/Discord.Net.Rest/Entities/AppSubscriptions/RestEntitlement.cs b/src/Discord.Net.Rest/Entities/AppSubscriptions/RestEntitlement.cs new file mode 100644 index 0000000..e1d2b61 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AppSubscriptions/RestEntitlement.cs @@ -0,0 +1,66 @@ +using System; + +using Model = Discord.API.Entitlement; + +namespace Discord.Rest; + +public class RestEntitlement : RestEntity, IEntitlement +{ + /// + public DateTimeOffset CreatedAt { get; private set; } + + /// + public ulong SkuId { get; private set; } + + /// + public ulong? UserId { get; private set; } + + /// + public ulong? GuildId { get; private set; } + + /// + public ulong ApplicationId { get; private set; } + + /// + public EntitlementType Type { get; private set; } + + /// + public bool IsConsumed { get; private set; } + + /// + public DateTimeOffset? StartsAt { get; private set; } + + /// + public DateTimeOffset? EndsAt { get; private set; } + + internal RestEntitlement(BaseDiscordClient discord, ulong id) : base(discord, id) + { + } + + internal static RestEntitlement Create(BaseDiscordClient discord, Model model) + { + var entity = new RestEntitlement(discord, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + SkuId = model.SkuId; + UserId = model.UserId.IsSpecified + ? model.UserId.Value + : null; + GuildId = model.GuildId.IsSpecified + ? model.GuildId.Value + : null; + ApplicationId = model.ApplicationId; + Type = model.Type; + IsConsumed = model.IsConsumed; + StartsAt = model.StartsAt.IsSpecified + ? model.StartsAt.Value + : null; + EndsAt = model.EndsAt.IsSpecified + ? model.EndsAt.Value + : null; + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs new file mode 100644 index 0000000..37121bc --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/AuditLogHelper.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using AuditLogChange = Discord.API.AuditLogChange; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +internal static class AuditLogHelper +{ + private static readonly Dictionary> CreateMapping + = new () + { + [ActionType.GuildUpdated] = GuildUpdateAuditLogData.Create, // log + [ActionType.ChannelCreated] = ChannelCreateAuditLogData.Create, + [ActionType.ChannelUpdated] = ChannelUpdateAuditLogData.Create, + [ActionType.ChannelDeleted] = ChannelDeleteAuditLogData.Create, + + [ActionType.OverwriteCreated] = OverwriteCreateAuditLogData.Create, + [ActionType.OverwriteUpdated] = OverwriteUpdateAuditLogData.Create, + [ActionType.OverwriteDeleted] = OverwriteDeleteAuditLogData.Create, + + [ActionType.Kick] = KickAuditLogData.Create, + [ActionType.Prune] = PruneAuditLogData.Create, + [ActionType.Ban] = BanAuditLogData.Create, + [ActionType.Unban] = UnbanAuditLogData.Create, + [ActionType.MemberUpdated] = MemberUpdateAuditLogData.Create, + [ActionType.MemberRoleUpdated] = MemberRoleAuditLogData.Create, + [ActionType.MemberMoved] = MemberMoveAuditLogData.Create, + [ActionType.MemberDisconnected] = MemberDisconnectAuditLogData.Create, + [ActionType.BotAdded] = BotAddAuditLogData.Create, + + [ActionType.RoleCreated] = RoleCreateAuditLogData.Create, + [ActionType.RoleUpdated] = RoleUpdateAuditLogData.Create, + [ActionType.RoleDeleted] = RoleDeleteAuditLogData.Create, + + [ActionType.InviteCreated] = InviteCreateAuditLogData.Create, + [ActionType.InviteUpdated] = InviteUpdateAuditLogData.Create, + [ActionType.InviteDeleted] = InviteDeleteAuditLogData.Create, + + [ActionType.WebhookCreated] = WebhookCreateAuditLogData.Create, + [ActionType.WebhookUpdated] = WebhookUpdateAuditLogData.Create, + [ActionType.WebhookDeleted] = WebhookDeleteAuditLogData.Create, + + [ActionType.EmojiCreated] = EmoteCreateAuditLogData.Create, + [ActionType.EmojiUpdated] = EmoteUpdateAuditLogData.Create, + [ActionType.EmojiDeleted] = EmoteDeleteAuditLogData.Create, + + [ActionType.MessageDeleted] = MessageDeleteAuditLogData.Create, + [ActionType.MessageBulkDeleted] = MessageBulkDeleteAuditLogData.Create, + [ActionType.MessagePinned] = MessagePinAuditLogData.Create, + [ActionType.MessageUnpinned] = MessageUnpinAuditLogData.Create, + + [ActionType.EventCreate] = ScheduledEventCreateAuditLogData.Create, + [ActionType.EventUpdate] = ScheduledEventUpdateAuditLogData.Create, + [ActionType.EventDelete] = ScheduledEventDeleteAuditLogData.Create, + + [ActionType.ThreadCreate] = ThreadCreateAuditLogData.Create, + [ActionType.ThreadUpdate] = ThreadUpdateAuditLogData.Create, + [ActionType.ThreadDelete] = ThreadDeleteAuditLogData.Create, + + [ActionType.ApplicationCommandPermissionUpdate] = CommandPermissionUpdateAuditLogData.Create, + + [ActionType.IntegrationCreated] = IntegrationCreatedAuditLogData.Create, + [ActionType.IntegrationUpdated] = IntegrationUpdatedAuditLogData.Create, + [ActionType.IntegrationDeleted] = IntegrationDeletedAuditLogData.Create, + + [ActionType.StageInstanceCreated] = StageInstanceCreateAuditLogData.Create, + [ActionType.StageInstanceUpdated] = StageInstanceUpdatedAuditLogData.Create, + [ActionType.StageInstanceDeleted] = StageInstanceDeleteAuditLogData.Create, + + [ActionType.StickerCreated] = StickerCreatedAuditLogData.Create, + [ActionType.StickerUpdated] = StickerUpdatedAuditLogData.Create, + [ActionType.StickerDeleted] = StickerDeletedAuditLogData.Create, + + [ActionType.AutoModerationRuleCreate] = AutoModRuleCreatedAuditLogData.Create, + [ActionType.AutoModerationRuleUpdate] = AutoModRuleUpdatedAuditLogData.Create, + [ActionType.AutoModerationRuleDelete] = AutoModRuleDeletedAuditLogData.Create, + + [ActionType.AutoModerationBlockMessage] = AutoModBlockedMessageAuditLogData.Create, + [ActionType.AutoModerationFlagToChannel] = AutoModFlaggedMessageAuditLogData.Create, + [ActionType.AutoModerationUserCommunicationDisabled] = AutoModTimeoutUserAuditLogData.Create, + + [ActionType.OnboardingQuestionCreated] = OnboardingPromptCreatedAuditLogData.Create, + [ActionType.OnboardingQuestionUpdated] = OnboardingPromptUpdatedAuditLogData.Create, + [ActionType.OnboardingUpdated] = OnboardingUpdatedAuditLogData.Create, + + [ActionType.VoiceChannelStatusUpdated] = VoiceChannelStatusUpdateAuditLogData.Create, + [ActionType.VoiceChannelStatusDeleted] = VoiceChannelStatusDeletedAuditLogData.Create + }; + + public static IAuditLogData CreateData(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + if (CreateMapping.TryGetValue(entry.Action, out var func)) + return func(discord, entry, log); + + return null; + } + + internal static (T, T) CreateAuditLogEntityInfo(AuditLogChange[] changes, BaseDiscordClient discord) where T : IAuditLogInfoModel + { + var oldModel = (T)Activator.CreateInstance(typeof(T))!; + var newModel = (T)Activator.CreateInstance(typeof(T))!; + + var props = typeof(T).GetProperties(); + + foreach (var property in props) + { + if (property.GetCustomAttributes(typeof(JsonFieldAttribute), true).FirstOrDefault() is not JsonFieldAttribute jsonAttr) + continue; + + var change = changes.FirstOrDefault(x => x.ChangedProperty == jsonAttr.FieldName); + + if (change is null) + continue; + + property.SetValue(oldModel, change.OldValue?.ToObject(property.PropertyType, discord.ApiClient.Serializer)); + property.SetValue(newModel, change.NewValue?.ToObject(property.PropertyType, discord.ApiClient.Serializer)); + } + + return (oldModel, newModel); + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModBlockedMessageAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModBlockedMessageAuditLogData.cs new file mode 100644 index 0000000..c0b4a39 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModBlockedMessageAuditLogData.cs @@ -0,0 +1,38 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to message getting blocked by automod. +/// +public class AutoModBlockedMessageAuditLogData : IAuditLogData +{ + internal AutoModBlockedMessageAuditLogData(ulong channelId, string autoModRuleName, AutoModTriggerType autoModRuleTriggerType) + { + ChannelId = channelId; + AutoModRuleName = autoModRuleName; + AutoModRuleTriggerType = autoModRuleTriggerType; + } + + internal static AutoModBlockedMessageAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + return new(entry.Options.ChannelId!.Value, entry.Options.AutoModRuleName, + entry.Options.AutoModRuleTriggerType!.Value); + } + + /// + /// Gets the channel the message was sent in. + /// + public ulong ChannelId { get; set; } + + /// + /// Gets the name of the auto moderation rule that got triggered. + /// + public string AutoModRuleName { get; set; } + + /// + /// Gets the trigger type of the auto moderation rule that got triggered. + /// + public AutoModTriggerType AutoModRuleTriggerType { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModFlaggedMessageAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModFlaggedMessageAuditLogData.cs new file mode 100644 index 0000000..160c355 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModFlaggedMessageAuditLogData.cs @@ -0,0 +1,38 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to message getting flagged by automod. +/// +public class AutoModFlaggedMessageAuditLogData : IAuditLogData +{ + internal AutoModFlaggedMessageAuditLogData(ulong? channelId, string autoModRuleName, AutoModTriggerType autoModRuleTriggerType) + { + ChannelId = channelId ?? 0; + AutoModRuleName = autoModRuleName; + AutoModRuleTriggerType = autoModRuleTriggerType; + } + + internal static AutoModFlaggedMessageAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + return new(entry.Options.ChannelId, entry.Options.AutoModRuleName, + entry.Options.AutoModRuleTriggerType!.Value); + } + + /// + /// Gets the channel the message was sent in. Will be 0 if a user profile was flagged. + /// + public ulong? ChannelId { get; set; } + + /// + /// Gets the name of the auto moderation rule that got triggered. + /// + public string AutoModRuleName { get; set; } + + /// + /// Gets the trigger type of the auto moderation rule that got triggered. + /// + public AutoModTriggerType AutoModRuleTriggerType { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleCreatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleCreatedAuditLogData.cs new file mode 100644 index 0000000..3ee382e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleCreatedAuditLogData.cs @@ -0,0 +1,31 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an auto moderation rule creation. +/// +public class AutoModRuleCreatedAuditLogData : IAuditLogData +{ + private AutoModRuleCreatedAuditLogData(AutoModRuleInfo data) + { + Data = data; + } + + internal static AutoModRuleCreatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new AutoModRuleCreatedAuditLogData(new (data)); + } + + /// + /// Gets the auto moderation rule information after the changes. + /// + public AutoModRuleInfo Data { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleDeletedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleDeletedAuditLogData.cs new file mode 100644 index 0000000..5e12764 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleDeletedAuditLogData.cs @@ -0,0 +1,31 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an auto moderation rule removal. +/// +public class AutoModRuleDeletedAuditLogData : IAuditLogData +{ + private AutoModRuleDeletedAuditLogData(AutoModRuleInfo data) + { + Data = data; + } + + internal static AutoModRuleDeletedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new AutoModRuleDeletedAuditLogData(new (data)); + } + + /// + /// Gets the auto moderation rule information before the changes. + /// + public AutoModRuleInfo Data { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleInfo.cs new file mode 100644 index 0000000..98b0d72 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleInfo.cs @@ -0,0 +1,113 @@ +using Discord.API.AuditLogs; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Rest; + +/// +/// Represents information for an auto moderation rule. +/// +public class AutoModRuleInfo +{ + internal AutoModRuleInfo(AutoModRuleInfoAuditLogModel model) + { + Actions = model.Actions?.Select(x => new AutoModRuleAction( + x.Type, + x.Metadata.GetValueOrDefault()?.ChannelId.ToNullable(), + x.Metadata.GetValueOrDefault()?.DurationSeconds.ToNullable(), + x.Metadata.IsSpecified + ? x.Metadata.Value.CustomMessage.IsSpecified + ? x.Metadata.Value.CustomMessage.Value + : null + : null + )).ToImmutableArray(); + KeywordFilter = model.TriggerMetadata?.KeywordFilter.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + Presets = model.TriggerMetadata?.Presets.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + RegexPatterns = model.TriggerMetadata?.RegexPatterns.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + AllowList = model.TriggerMetadata?.AllowList.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + MentionTotalLimit = model.TriggerMetadata?.MentionLimit.IsSpecified ?? false + ? model.TriggerMetadata?.MentionLimit.Value + : null; + Name = model.Name; + Enabled = model.Enabled; + ExemptRoles = model.ExemptRoles?.ToImmutableArray(); + ExemptChannels = model.ExemptChannels?.ToImmutableArray(); + TriggerType = model.TriggerType; + EventType = model.EventType; + } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public string Name { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public AutoModEventType? EventType { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public AutoModTriggerType? TriggerType { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public bool? Enabled { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection ExemptRoles { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection ExemptChannels { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection KeywordFilter { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection RegexPatterns { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection AllowList { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection Presets { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public int? MentionTotalLimit { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection Actions { get; private set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleUpdatedAuditLogData .cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleUpdatedAuditLogData .cs new file mode 100644 index 0000000..ef173b1 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModRuleUpdatedAuditLogData .cs @@ -0,0 +1,45 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an auto moderation rule update. +/// +public class AutoModRuleUpdatedAuditLogData : IAuditLogData +{ + private AutoModRuleUpdatedAuditLogData(AutoModRuleInfo before, AutoModRuleInfo after, IAutoModRule rule) + { + Before = before; + After = after; + Rule = rule; + } + + internal static AutoModRuleUpdatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var rule = RestAutoModRule.Create(discord, log.AutoModerationRules.FirstOrDefault(x => x.Id == entry.TargetId)); + + return new AutoModRuleUpdatedAuditLogData(new (before), new(after), rule); + } + + /// + /// Gets the auto moderation rule the changes correspond to. + /// + public IAutoModRule Rule { get; } + + /// + /// Gets the auto moderation rule information before the changes. + /// + public AutoModRuleInfo Before { get; } + + /// + /// Gets the auto moderation rule information after the changes. + /// + public AutoModRuleInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModTimeoutUserAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModTimeoutUserAuditLogData.cs new file mode 100644 index 0000000..ced0dad --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/AutoModTimeoutUserAuditLogData.cs @@ -0,0 +1,38 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to user getting in timeout by automod. +/// +public class AutoModTimeoutUserAuditLogData : IAuditLogData +{ + internal AutoModTimeoutUserAuditLogData(ulong channelId, string autoModRuleName, AutoModTriggerType autoModRuleTriggerType) + { + ChannelId = channelId; + AutoModRuleName = autoModRuleName; + AutoModRuleTriggerType = autoModRuleTriggerType; + } + + internal static AutoModTimeoutUserAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + return new(entry.Options.ChannelId!.Value, entry.Options.AutoModRuleName, + entry.Options.AutoModRuleTriggerType!.Value); + } + + /// + /// Gets the channel the message was sent in. + /// + public ulong ChannelId { get; set; } + + /// + /// Gets the name of the auto moderation rule that got triggered. + /// + public string AutoModRuleName { get; set; } + + /// + /// Gets the trigger type of the auto moderation rule that got triggered. + /// + public AutoModTriggerType AutoModRuleTriggerType { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/BanAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/BanAuditLogData.cs new file mode 100644 index 0000000..1c9bfa0 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/BanAuditLogData.cs @@ -0,0 +1,33 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a ban. +/// +public class BanAuditLogData : IAuditLogData +{ + private BanAuditLogData(IUser user) + { + Target = user; + } + + internal static BanAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + return new BanAuditLogData((userInfo != null) ? RestUser.Create(discord, userInfo) : null); + } + + /// + /// Gets the user that was banned. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the banned user. + /// + public IUser Target { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/BotAddAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/BotAddAuditLogData.cs new file mode 100644 index 0000000..2d5b21e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/BotAddAuditLogData.cs @@ -0,0 +1,33 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a adding a bot to a guild. +/// +public class BotAddAuditLogData : IAuditLogData +{ + private BotAddAuditLogData(IUser bot) + { + Target = bot; + } + + internal static BotAddAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + return new BotAddAuditLogData((userInfo != null) ? RestUser.Create(discord, userInfo) : null); + } + + /// + /// Gets the bot that was added. + /// + /// + /// Will be if the bot is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the bot. + /// + public IUser Target { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelCreateAuditLogData.cs new file mode 100644 index 0000000..23e6e15 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelCreateAuditLogData.cs @@ -0,0 +1,176 @@ +using Discord.API.AuditLogs; + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a channel creation. +/// +public class ChannelCreateAuditLogData : IAuditLogData +{ + private ChannelCreateAuditLogData(ChannelInfoAuditLogModel model, EntryModel entry) + { + ChannelId = entry.TargetId!.Value; + ChannelName = model.Name; + ChannelType = model.Type!.Value; + SlowModeInterval = model.RateLimitPerUser; + IsNsfw = model.IsNsfw; + Bitrate = model.Bitrate; + Topic = model.Topic; + AutoArchiveDuration = model.AutoArchiveDuration; + DefaultSlowModeInterval = model.DefaultThreadRateLimitPerUser; + DefaultAutoArchiveDuration = model.DefaultArchiveDuration; + + AvailableTags = model.AvailableTags?.Select(x => new ForumTag(x.Id, + x.Name, + x.EmojiId.GetValueOrDefault(null), + x.EmojiName.GetValueOrDefault(null), + x.Moderated)).ToImmutableArray(); + + + if (model.DefaultEmoji is not null) + { + if (model.DefaultEmoji.EmojiId.HasValue && model.DefaultEmoji.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultEmoji.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultEmoji.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultEmoji.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + else + DefaultReactionEmoji = null; + + VideoQualityMode = model.VideoQualityMode; + RtcRegion = model.Region; + Flags = model.Flags; + UserLimit = model.UserLimit; + Overwrites = model.Overwrites?.Select(x => new Overwrite(x.TargetId, x.TargetType, new OverwritePermissions(x.Allow, x.Deny))) + .ToImmutableArray(); + } + + internal static ChannelCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new ChannelCreateAuditLogData(data, entry); + } + + /// + /// Gets the snowflake ID of the created channel. + /// + /// + /// A representing the snowflake identifier for the created channel. + /// + public ulong ChannelId { get; } + + /// + /// Gets the name of the created channel. + /// + /// + /// A string containing the name of the created channel. + /// + public string ChannelName { get; } + + /// + /// Gets the type of the created channel. + /// + /// + /// The type of channel that was created. + /// + public ChannelType ChannelType { get; } + + /// + /// Gets the current slow-mode delay of the created channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the value that indicates whether the created channel is NSFW. + /// + /// + /// if the created channel has the NSFW flag enabled; otherwise . + /// if this is not mentioned in this entry. + /// + public bool? IsNsfw { get; } + + /// + /// Gets the bit-rate that the clients in the created voice channel are requested to use. + /// + /// + /// An representing the bit-rate (bps) that the created voice channel defines and requests the + /// client(s) to use. + /// if this is not mentioned in this entry. + /// + public int? Bitrate { get; } + + /// + /// Gets a collection of permission overwrites that was assigned to the created channel. + /// + /// + /// A collection of permission , containing the permission overwrites that were + /// assigned to the created channel. + /// + public IReadOnlyCollection Overwrites { get; } + + /// + /// Gets the thread archive duration that was set in the created channel. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// Gets the default thread archive duration that was set in the created channel. + /// + public ThreadArchiveDuration? DefaultAutoArchiveDuration { get; } + + /// + /// Gets the default slow mode interval that will be set in child threads in the channel. + /// + public int? DefaultSlowModeInterval { get; } + + /// + /// Gets the topic that was set in the created channel. + /// + public string Topic { get; } + + /// + /// Gets tags available in the created forum channel. + /// + public IReadOnlyCollection AvailableTags { get; } + + /// + /// Gets the default reaction added to posts in the created forum channel. + /// + public IEmote DefaultReactionEmoji { get; } + + /// + /// Gets the user limit configured in the created voice channel. + /// + public int? UserLimit { get; } + + /// + /// Gets the video quality mode configured in the created voice channel. + /// + public VideoQualityMode? VideoQualityMode { get; } + + /// + /// Gets the region configured in the created voice channel. + /// + public string RtcRegion { get; } + + /// + /// Gets channel flags configured for the created channel. + /// + public ChannelFlags? Flags { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelDeleteAuditLogData.cs new file mode 100644 index 0000000..c8e2190 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelDeleteAuditLogData.cs @@ -0,0 +1,175 @@ +using Discord.API.AuditLogs; + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a channel deletion. +/// +public class ChannelDeleteAuditLogData : IAuditLogData +{ + private ChannelDeleteAuditLogData(ChannelInfoAuditLogModel model, EntryModel entry) + { + ChannelId = entry.TargetId!.Value; + ChannelType = model.Type!.Value; + ChannelName = model.Name; + + Topic = model.Topic; + IsNsfw = model.IsNsfw; + Bitrate = model.Bitrate; + DefaultArchiveDuration = model.DefaultArchiveDuration; + SlowModeInterval = model.RateLimitPerUser; + + ForumTags = model.AvailableTags?.Select( + x => new ForumTag(x.Id, + x.Name, + x.EmojiId.GetValueOrDefault(null), + x.EmojiName.GetValueOrDefault(null), + x.Moderated)).ToImmutableArray(); + + if (model.DefaultEmoji is not null) + { + if (model.DefaultEmoji.EmojiId.HasValue && model.DefaultEmoji.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultEmoji.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultEmoji.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultEmoji.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + else + DefaultReactionEmoji = null; + AutoArchiveDuration = model.AutoArchiveDuration; + DefaultSlowModeInterval = model.DefaultThreadRateLimitPerUser; + + VideoQualityMode = model.VideoQualityMode; + RtcRegion = model.Region; + Flags = model.Flags; + UserLimit = model.UserLimit; + + Overwrites = model.Overwrites?.Select(x + => new Overwrite(x.TargetId, + x.TargetType, + new OverwritePermissions(x.Allow, x.Deny))).ToImmutableArray(); + } + + internal static ChannelDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new ChannelDeleteAuditLogData(data, entry); + } + + /// + /// Gets the snowflake ID of the deleted channel. + /// + /// + /// A representing the snowflake identifier for the deleted channel. + /// + public ulong ChannelId { get; } + /// + /// Gets the name of the deleted channel. + /// + /// + /// A string containing the name of the deleted channel. + /// + public string ChannelName { get; } + /// + /// Gets the type of the deleted channel. + /// + /// + /// The type of channel that was deleted. + /// + public ChannelType ChannelType { get; } + /// + /// Gets the slow-mode delay of the deleted channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + /// + /// Gets the value that indicates whether the deleted channel was NSFW. + /// + /// + /// if this channel had the NSFW flag enabled; otherwise . + /// if this is not mentioned in this entry. + /// + public bool? IsNsfw { get; } + /// + /// Gets the bit-rate of this channel if applicable. + /// + /// + /// An representing the bit-rate set of the voice channel. + /// if this is not mentioned in this entry. + /// + public int? Bitrate { get; } + /// + /// Gets a collection of permission overwrites that was assigned to the deleted channel. + /// + /// + /// A collection of permission . + /// + public IReadOnlyCollection Overwrites { get; } + /// + /// Gets the user limit configured in the created voice channel. + /// + public int? UserLimit { get; } + + /// + /// Gets the video quality mode configured in the created voice channel. + /// + public VideoQualityMode? VideoQualityMode { get; } + + /// + /// Gets the region configured in the created voice channel. + /// + public string RtcRegion { get; } + + /// + /// Gets channel flags configured for the created channel. + /// + public ChannelFlags? Flags { get; } + + /// + /// Gets the thread archive duration that was configured for the created channel. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// Gets the default slow mode interval that was configured for the channel. + /// + public int? DefaultSlowModeInterval { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public ThreadArchiveDuration? DefaultArchiveDuration { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public IReadOnlyCollection ForumTags { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public string Topic { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public IEmote DefaultReactionEmoji { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelInfo.cs new file mode 100644 index 0000000..76fcef7 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelInfo.cs @@ -0,0 +1,145 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.AuditLogs.ChannelInfoAuditLogModel; + +namespace Discord.Rest +{ + /// + /// Represents information for a channel. + /// + public struct ChannelInfo + { + internal ChannelInfo(Model model) + { + Name = model.Name; + Topic = model.Topic; + IsNsfw = model.IsNsfw; + Bitrate = model.Bitrate; + DefaultArchiveDuration = model.DefaultArchiveDuration; + ChannelType = model.Type; + SlowModeInterval = model.RateLimitPerUser; + + ForumTags = model.AvailableTags?.Select( + x => new ForumTag(x.Id, + x.Name, + x.EmojiId.GetValueOrDefault(null), + x.EmojiName.GetValueOrDefault(null), + x.Moderated)).ToImmutableArray(); + + if (model.DefaultEmoji is not null) + { + if (model.DefaultEmoji.EmojiId.HasValue && model.DefaultEmoji.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultEmoji.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultEmoji.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultEmoji.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + else + DefaultReactionEmoji = null; + AutoArchiveDuration = model.AutoArchiveDuration; + DefaultSlowModeInterval = model.DefaultThreadRateLimitPerUser; + + VideoQualityMode = model.VideoQualityMode; + RtcRegion = model.Region; + Flags = model.Flags; + UserLimit = model.UserLimit; + } + + /// + /// Gets the name of this channel. + /// + /// + /// A string containing the name of this channel. + /// + public string Name { get; } + /// + /// Gets the topic of this channel. + /// + /// + /// A string containing the topic of this channel, if any. + /// + public string Topic { get; } + /// + /// Gets the current slow-mode delay of this channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + /// + /// Gets the value that indicates whether this channel is NSFW. + /// + /// + /// if this channel has the NSFW flag enabled; otherwise . + /// if this is not mentioned in this entry. + /// + public bool? IsNsfw { get; } + /// + /// Gets the bit-rate of this channel if applicable. + /// + /// + /// An representing the bit-rate set for the voice channel; + /// if this is not mentioned in this entry. + /// + public int? Bitrate { get; } + /// + /// Gets the type of this channel. + /// + /// + /// The channel type of this channel; if not applicable. + /// + public ChannelType? ChannelType { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ThreadArchiveDuration? DefaultArchiveDuration { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public IReadOnlyCollection ForumTags { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public IEmote DefaultReactionEmoji { get; } + + /// + /// Gets the user limit configured in the created voice channel. + /// + public int? UserLimit { get; } + + /// + /// Gets the video quality mode configured in the created voice channel. + /// + public VideoQualityMode? VideoQualityMode { get; } + + /// + /// Gets the region configured in the created voice channel. + /// + public string RtcRegion { get; } + + /// + /// Gets channel flags configured for the created channel. + /// + public ChannelFlags? Flags { get; } + + /// + /// Gets the thread archive duration that was set in the created channel. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// Gets the default slow mode interval that will be set in child threads in the channel. + /// + public int? DefaultSlowModeInterval { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelUpdateAuditLogData.cs new file mode 100644 index 0000000..e97b758 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ChannelUpdateAuditLogData.cs @@ -0,0 +1,51 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest +{ + /// + /// Contains a piece of audit log data related to a channel update. + /// + public class ChannelUpdateAuditLogData : IAuditLogData + { + private ChannelUpdateAuditLogData(ulong id, ChannelInfo before, ChannelInfo after) + { + ChannelId = id; + Before = before; + After = after; + } + + internal static ChannelUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new ChannelUpdateAuditLogData(entry.TargetId!.Value, new ChannelInfo(before), new ChannelInfo(after)); + } + + /// + /// Gets the snowflake ID of the updated channel. + /// + /// + /// A representing the snowflake identifier for the updated channel. + /// + public ulong ChannelId { get; } + /// + /// Gets the channel information before the changes. + /// + /// + /// An information object containing the original channel information before the changes were made. + /// + public ChannelInfo Before { get; } + /// + /// Gets the channel information after the changes. + /// + /// + /// An information object containing the channel information after the changes were made. + /// + public ChannelInfo After { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/CommandPermissionUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/CommandPermissionUpdateAuditLogData.cs new file mode 100644 index 0000000..614235d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/CommandPermissionUpdateAuditLogData.cs @@ -0,0 +1,69 @@ +using Discord.API; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an application command permission update. +/// +public class CommandPermissionUpdateAuditLogData : IAuditLogData +{ + internal CommandPermissionUpdateAuditLogData(IReadOnlyCollection before, IReadOnlyCollection after, + IApplicationCommand command, ulong appId) + { + Before = before; + After = after; + ApplicationCommand = command; + ApplicationId = appId; + } + + internal static CommandPermissionUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var before = new List(); + var after = new List(); + + foreach (var change in changes) + { + var oldValue = change.OldValue?.ToObject(); + var newValue = change.NewValue?.ToObject(); + + if (oldValue is not null) + before.Add(new ApplicationCommandPermission(oldValue.Id, oldValue.Type, oldValue.Permission)); + + if (newValue is not null) + after.Add(new ApplicationCommandPermission(newValue.Id, newValue.Type, newValue.Permission)); + } + + var command = log.Commands.FirstOrDefault(x => x.Id == entry.TargetId); + var appCommand = RestApplicationCommand.Create(discord, command, command?.GuildId.IsSpecified ?? false ? command.GuildId.Value : null); + + return new(before.ToImmutableArray(), after.ToImmutableArray(), appCommand, entry.Options.ApplicationId!.Value); + } + + /// + /// Gets the ID of the app whose permissions were targeted. + /// + public ulong ApplicationId { get; set; } + + /// + /// Gets the application command which permissions were updated. + /// + public IApplicationCommand ApplicationCommand { get; } + + /// + /// Gets values of the permissions before the change if available. + /// + public IReadOnlyCollection Before { get; } + + /// + /// Gets values of the permissions after the change if available. + /// + public IReadOnlyCollection After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteCreateAuditLogData.cs new file mode 100644 index 0000000..7f73b6e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteCreateAuditLogData.cs @@ -0,0 +1,42 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an emoji creation. +/// +public class EmoteCreateAuditLogData : IAuditLogData +{ + private EmoteCreateAuditLogData(ulong id, string name) + { + EmoteId = id; + Name = name; + } + + internal static EmoteCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var change = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + + var emoteName = change.NewValue?.ToObject(discord.ApiClient.Serializer); + return new EmoteCreateAuditLogData(entry.TargetId.Value, emoteName); + } + + /// + /// Gets the snowflake ID of the created emoji. + /// + /// + /// A representing the snowflake identifier for the created emoji. + /// + public ulong EmoteId { get; } + + /// + /// Gets the name of the created emoji. + /// + /// + /// A string containing the name of the created emoji. + /// + public string Name { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteDeleteAuditLogData.cs new file mode 100644 index 0000000..2051586 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteDeleteAuditLogData.cs @@ -0,0 +1,42 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an emoji deletion. +/// +public class EmoteDeleteAuditLogData : IAuditLogData +{ + private EmoteDeleteAuditLogData(ulong id, string name) + { + EmoteId = id; + Name = name; + } + + internal static EmoteDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var change = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + + var emoteName = change.OldValue?.ToObject(discord.ApiClient.Serializer); + + return new EmoteDeleteAuditLogData(entry.TargetId.Value, emoteName); + } + + /// + /// Gets the snowflake ID of the deleted emoji. + /// + /// + /// A representing the snowflake identifier for the deleted emoji. + /// + public ulong EmoteId { get; } + + /// + /// Gets the name of the deleted emoji. + /// + /// + /// A string containing the name of the deleted emoji. + /// + public string Name { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteUpdateAuditLogData.cs new file mode 100644 index 0000000..9e626ed --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/EmoteUpdateAuditLogData.cs @@ -0,0 +1,53 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an emoji update. +/// +public class EmoteUpdateAuditLogData : IAuditLogData +{ + private EmoteUpdateAuditLogData(ulong id, string oldName, string newName) + { + EmoteId = id; + OldName = oldName; + NewName = newName; + } + + internal static EmoteUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var change = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + + var newName = change.NewValue?.ToObject(discord.ApiClient.Serializer); + var oldName = change.OldValue?.ToObject(discord.ApiClient.Serializer); + + return new EmoteUpdateAuditLogData(entry.TargetId.Value, oldName, newName); + } + + /// + /// Gets the snowflake ID of the updated emoji. + /// + /// + /// A representing the snowflake identifier of the updated emoji. + /// + public ulong EmoteId { get; } + + /// + /// Gets the new name of the updated emoji. + /// + /// + /// A string containing the new name of the updated emoji. + /// + public string NewName { get; } + + /// + /// Gets the old name of the updated emoji. + /// + /// + /// A string containing the old name of the updated emoji. + /// + public string OldName { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/GuildInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/GuildInfo.cs new file mode 100644 index 0000000..31a4526 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/GuildInfo.cs @@ -0,0 +1,218 @@ +using Model = Discord.API.AuditLogs.GuildInfoAuditLogModel; + +namespace Discord.Rest; + +/// +/// Represents information for a guild. +/// +public struct GuildInfo +{ + internal GuildInfo(Model model, IUser owner) + { + Owner = owner; + + Name = model.Name; + AfkTimeout = model.AfkTimeout.GetValueOrDefault(); + IsEmbeddable = model.IsEmbeddable; + DefaultMessageNotifications = model.DefaultMessageNotifications; + MfaLevel = model.MfaLevel; + Description = model.Description; + PreferredLocale = model.PreferredLocale; + IconHash = model.IconHash; + OwnerId = model.OwnerId; + AfkChannelId = model.AfkChannelId; + ApplicationId = model.ApplicationId; + BannerId = model.Banner; + DiscoverySplashId = model.DiscoverySplash; + EmbedChannelId = model.EmbeddedChannelId; + ExplicitContentFilter = model.ExplicitContentFilterLevel; + IsBoostProgressBarEnabled = model.ProgressBarEnabled; + NsfwLevel = model.NsfwLevel; + PublicUpdatesChannelId = model.PublicUpdatesChannelId; + RegionId = model.RegionId; + RulesChannelId = model.RulesChannelId; + SplashId = model.Splash; + SystemChannelFlags = model.SystemChannelFlags; + SystemChannelId = model.SystemChannelId; + VanityURLCode = model.VanityUrl; + VerificationLevel = model.VerificationLevel; + } + + /// + /// + /// if the value was not updated in this entry. + /// + public string DiscoverySplashId { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string SplashId { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? RulesChannelId { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? PublicUpdatesChannelId { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? OwnerId { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? ApplicationId { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string BannerId { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string VanityURLCode { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public SystemChannelMessageDeny? SystemChannelFlags { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string Description { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string PreferredLocale { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public NsfwLevel? NsfwLevel { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public bool? IsBoostProgressBarEnabled { get; } + + /// + /// Gets the amount of time (in seconds) a user must be inactive in a voice channel for until they are + /// automatically moved to the AFK voice channel. + /// + /// + /// An representing the amount of time in seconds for a user to be marked as inactive + /// and moved into the AFK voice channel. + /// if this is not mentioned in this entry. + /// + public int? AfkTimeout { get; } + /// + /// Gets the default message notifications for users who haven't explicitly set their notification settings. + /// + /// + /// The default message notifications setting of this guild. + /// if this is not mentioned in this entry. + /// + public DefaultMessageNotifications? DefaultMessageNotifications { get; } + /// + /// Gets the ID of the AFK voice channel for this guild. + /// + /// + /// A representing the snowflake identifier of the AFK voice channel; if + /// none is set. + /// + public ulong? AfkChannelId { get; } + /// + /// Gets the name of this guild. + /// + /// + /// A string containing the name of this guild. + /// + public string Name { get; } + /// + /// Gets the ID of the region hosting this guild's voice channels. + /// + public string RegionId { get; } + /// + /// Gets the ID of this guild's icon. + /// + /// + /// A string containing the identifier for the splash image; if none is set. + /// + public string IconHash { get; } + /// + /// Gets the level of requirements a user must fulfill before being allowed to post messages in this guild. + /// + /// + /// The level of requirements. + /// if this is not mentioned in this entry. + /// + public VerificationLevel? VerificationLevel { get; } + /// + /// Gets the owner of this guild. + /// + /// + /// A user object representing the owner of this guild. + /// + public IUser Owner { get; } + /// + /// Gets the level of Multi-Factor Authentication requirements a user must fulfill before being allowed to + /// perform administrative actions in this guild. + /// + /// + /// The level of MFA requirement. + /// if this is not mentioned in this entry. + /// + public MfaLevel? MfaLevel { get; } + /// + /// Gets the level of content filtering applied to user's content in a Guild. + /// + /// + /// The level of explicit content filtering. + /// + public ExplicitContentFilterLevel? ExplicitContentFilter { get; } + /// + /// Gets the ID of the channel where system messages are sent. + /// + /// + /// A representing the snowflake identifier of the channel where system + /// messages are sent; if none is set. + /// + public ulong? SystemChannelId { get; } + /// + /// Gets the ID of the widget embed channel of this guild. + /// + /// + /// A representing the snowflake identifier of the embedded channel found within the + /// widget settings of this guild; if none is set. + /// + public ulong? EmbedChannelId { get; } + /// + /// Gets a value that indicates whether this guild is embeddable (i.e. can use widget). + /// + /// + /// if this guild can be embedded via widgets; otherwise . + /// if this is not mentioned in this entry. + /// + public bool? IsEmbeddable { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/GuildUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/GuildUpdateAuditLogData.cs new file mode 100644 index 0000000..008b2e7 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/GuildUpdateAuditLogData.cs @@ -0,0 +1,62 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest +{ + /// + /// Contains a piece of audit log data related to a guild update. + /// + public class GuildUpdateAuditLogData : IAuditLogData + { + private GuildUpdateAuditLogData(GuildInfo before, GuildInfo after) + { + Before = before; + After = after; + } + + internal static GuildUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var ownerIdModel = changes.FirstOrDefault(x => x.ChangedProperty == "owner_id"); + + ulong? oldOwnerId = ownerIdModel?.OldValue?.ToObject(discord.ApiClient.Serializer), + newOwnerId = ownerIdModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + + IUser oldOwner = null; + if (oldOwnerId != null) + { + var oldOwnerInfo = log.Users.FirstOrDefault(x => x.Id == oldOwnerId.Value); + oldOwner = RestUser.Create(discord, oldOwnerInfo); + } + + IUser newOwner = null; + if (newOwnerId != null) + { + var newOwnerInfo = log.Users.FirstOrDefault(x => x.Id == newOwnerId.Value); + newOwner = RestUser.Create(discord, newOwnerInfo); + } + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new GuildUpdateAuditLogData(new(before, oldOwner), new(after, newOwner)); + } + + /// + /// Gets the guild information before the changes. + /// + /// + /// An information object containing the original guild information before the changes were made. + /// + public GuildInfo Before { get; } + /// + /// Gets the guild information after the changes. + /// + /// + /// An information object containing the guild information after the changes were made. + /// + public GuildInfo After { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationCreatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationCreatedAuditLogData.cs new file mode 100644 index 0000000..0f57581 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationCreatedAuditLogData.cs @@ -0,0 +1,39 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an integration authorization. +/// +public class IntegrationCreatedAuditLogData : IAuditLogData +{ + internal IntegrationCreatedAuditLogData(IntegrationInfo info, IIntegration integration) + { + Integration = integration; + Data = info; + } + + internal static IntegrationCreatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var integration = RestIntegration.Create(discord, null, log.Integrations.FirstOrDefault(x => x.Id == entry.TargetId)); + + return new(new IntegrationInfo(data), integration); + } + + /// + /// Gets the partial integration the changes correspond to. + /// + public IIntegration Integration { get; } + + /// + /// Gets the integration information after the changes. + /// + public IntegrationInfo Data { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationDeletedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationDeletedAuditLogData.cs new file mode 100644 index 0000000..894c32f --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationDeletedAuditLogData.cs @@ -0,0 +1,31 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an integration removal. +/// +public class IntegrationDeletedAuditLogData : IAuditLogData +{ + internal IntegrationDeletedAuditLogData(IntegrationInfo info) + { + Data = info; + } + + internal static IntegrationDeletedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new(new IntegrationInfo(data)); + } + + /// + /// Gets the integration information before the changes. + /// + public IntegrationInfo Data { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationInfo.cs new file mode 100644 index 0000000..55f3f64 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationInfo.cs @@ -0,0 +1,69 @@ +using Discord.API.AuditLogs; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Rest; + +/// +/// Represents information for an integration. +/// +public class IntegrationInfo +{ + internal IntegrationInfo(IntegrationInfoAuditLogModel model) + { + Name = model.Name; + Type = model.Type; + EnableEmojis = model.EnableEmojis; + Enabled = model.Enabled; + Scopes = model.Scopes?.ToImmutableArray(); + ExpireBehavior = model.ExpireBehavior; + ExpireGracePeriod = model.ExpireGracePeriod; + Syncing = model.Syncing; + RoleId = model.RoleId; + } + + /// + /// Gets the name of the integration. if the property was not mentioned in this audit log. + /// + public string Name { get; set; } + + /// + /// Gets the type of the integration. if the property was not mentioned in this audit log. + /// + public string Type { get; set; } + + /// + /// Gets if the integration is enabled. if the property was not mentioned in this audit log. + /// + public bool? Enabled { get; set; } + + /// + /// Gets if syncing is enabled for this integration. if the property was not mentioned in this audit log. + /// + public bool? Syncing { get; set; } + + /// + /// Gets the id of the role that this integration uses for subscribers. if the property was not mentioned in this audit log. + /// + public ulong? RoleId { get; set; } + + /// + /// Gets whether emoticons should be synced for this integration. if the property was not mentioned in this audit log. + /// + public bool? EnableEmojis { get; set; } + + /// + /// Gets the behavior of expiring subscribers. if the property was not mentioned in this audit log. + /// + public IntegrationExpireBehavior? ExpireBehavior { get; set; } + + /// + /// Gets the grace period (in days) before expiring subscribers. if the property was not mentioned in this audit log. + /// + public int? ExpireGracePeriod { get; set; } + + /// + /// Gets the scopes the application has been authorized for. if the property was not mentioned in this audit log. + /// + public IReadOnlyCollection Scopes { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationUpdatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationUpdatedAuditLogData.cs new file mode 100644 index 0000000..9f4665a --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/IntegrationUpdatedAuditLogData.cs @@ -0,0 +1,45 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an integration update. +/// +public class IntegrationUpdatedAuditLogData : IAuditLogData +{ + internal IntegrationUpdatedAuditLogData(IntegrationInfo before, IntegrationInfo after, IIntegration integration) + { + Before = before; + After = after; + Integration = integration; + } + + internal static IntegrationUpdatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var integration = RestIntegration.Create(discord, null, log.Integrations.FirstOrDefault(x => x.Id == entry.TargetId)); + + return new(new IntegrationInfo(before), new IntegrationInfo(after), integration); + } + + /// + /// Gets the partial integration the changes correspond to. + /// + public IIntegration Integration { get; } + + /// + /// Gets the integration information before the changes. + /// + public IntegrationInfo Before { get; } + + /// + /// Gets the integration information after the changes. + /// + public IntegrationInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteCreateAuditLogData.cs new file mode 100644 index 0000000..c58457d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteCreateAuditLogData.cs @@ -0,0 +1,102 @@ +using Discord.API.AuditLogs; +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an invite creation. +/// +public class InviteCreateAuditLogData : IAuditLogData +{ + private InviteCreateAuditLogData(InviteInfoAuditLogModel model, IUser inviter) + { + MaxAge = model.MaxAge!.Value; + Code = model.Code; + Temporary = model.Temporary!.Value; + Creator = inviter; + ChannelId = model.ChannelId!.Value; + Uses = model.Uses!.Value; + MaxUses = model.MaxUses!.Value; + } + + internal static InviteCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + RestUser inviter = null; + + if (data.InviterId is not null) + { + var inviterInfo = log.Users.FirstOrDefault(x => x.Id == data.InviterId); + inviter = (inviterInfo != null) ? RestUser.Create(discord, inviterInfo) : null; + } + + return new InviteCreateAuditLogData(data, inviter); + } + + /// + /// Gets the time (in seconds) until the invite expires. + /// + /// + /// An representing the time in seconds until this invite expires. + /// + public int MaxAge { get; } + + /// + /// Gets the unique identifier for this invite. + /// + /// + /// A string containing the invite code (e.g. FTqNnyS). + /// + public string Code { get; } + + /// + /// Gets a value that determines whether the invite is a temporary one. + /// + /// + /// if users accepting this invite will be removed from the guild when they log off; otherwise + /// . + /// + public bool Temporary { get; } + + /// + /// Gets the user that created this invite if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user that created this invite or . + /// + public IUser Creator { get; } + + /// + /// Gets the ID of the channel this invite is linked to. + /// + /// + /// A representing the channel snowflake identifier that the invite points to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the number of times this invite has been used. + /// + /// + /// An representing the number of times this invite was used. + /// + public int Uses { get; } + + /// + /// Gets the max number of uses this invite may have. + /// + /// + /// An representing the number of uses this invite may be accepted until it is removed + /// from the guild; if none is set. + /// + public int MaxUses { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteDeleteAuditLogData.cs new file mode 100644 index 0000000..c34bbd2 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteDeleteAuditLogData.cs @@ -0,0 +1,102 @@ +using Discord.API.AuditLogs; +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an invite removal. +/// +public class InviteDeleteAuditLogData : IAuditLogData +{ + private InviteDeleteAuditLogData(InviteInfoAuditLogModel model, IUser inviter) + { + MaxAge = model.MaxAge!.Value; + Code = model.Code; + Temporary = model.Temporary!.Value; + Creator = inviter; + ChannelId = model.ChannelId!.Value; + Uses = model.Uses!.Value; + MaxUses = model.MaxUses!.Value; + } + + internal static InviteDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + RestUser inviter = null; + + if (data.InviterId != null) + { + var inviterInfo = log.Users.FirstOrDefault(x => x.Id == data.InviterId); + inviter = (inviterInfo != null) ? RestUser.Create(discord, inviterInfo) : null; + } + + return new InviteDeleteAuditLogData(data, inviter); + } + + /// + /// Gets the time (in seconds) until the invite expires. + /// + /// + /// An representing the time in seconds until this invite expires. + /// + public int MaxAge { get; } + + /// + /// Gets the unique identifier for this invite. + /// + /// + /// A string containing the invite code (e.g. FTqNnyS). + /// + public string Code { get; } + + /// + /// Gets a value that indicates whether the invite is a temporary one. + /// + /// + /// if users accepting this invite will be removed from the guild when they log off; otherwise + /// . + /// + public bool Temporary { get; } + + /// + /// Gets the user that created this invite if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user that created this invite or . + /// + public IUser Creator { get; } + + /// + /// Gets the ID of the channel this invite is linked to. + /// + /// + /// A representing the channel snowflake identifier that the invite points to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the number of times this invite has been used. + /// + /// + /// An representing the number of times this invite has been used. + /// + public int Uses { get; } + + /// + /// Gets the max number of uses this invite may have. + /// + /// + /// An representing the number of uses this invite may be accepted until it is removed + /// from the guild; if none is set. + /// + public int MaxUses { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteInfo.cs new file mode 100644 index 0000000..87dadb3 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteInfo.cs @@ -0,0 +1,68 @@ +using Model = Discord.API.AuditLogs.InviteInfoAuditLogModel; + +namespace Discord.Rest; + +/// +/// Represents information for an invite. +/// +public struct InviteInfo +{ + internal InviteInfo(Model model) + { + MaxAge = model.MaxAge; + Code = model.Code; + Temporary = model.Temporary; + ChannelId = model.ChannelId; + MaxUses = model.MaxUses; + CreatorId = model.InviterId; + } + + /// + /// Gets the time (in seconds) until the invite expires. + /// + /// + /// An representing the time in seconds until this invite expires; if this + /// invite never expires or not specified. + /// + public int? MaxAge { get; } + + /// + /// Gets the unique identifier for this invite. + /// + /// + /// A string containing the invite code (e.g. FTqNnyS). + /// + public string Code { get; } + + /// + /// Gets a value that indicates whether the invite is a temporary one. + /// + /// + /// if users accepting this invite will be removed from the guild when they log off, + /// if not; if not specified. + /// + public bool? Temporary { get; } + + /// + /// Gets the ID of the channel this invite is linked to. + /// + /// + /// A representing the channel snowflake identifier that the invite points to; + /// if not specified. + /// + public ulong? ChannelId { get; } + + /// + /// Gets the max number of uses this invite may have. + /// + /// + /// An representing the number of uses this invite may be accepted until it is removed + /// from the guild; if none is specified. + /// + public int? MaxUses { get; } + + /// + /// Gets the id of the user created this invite. + /// + public ulong? CreatorId { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteUpdateAuditLogData.cs new file mode 100644 index 0000000..766a35b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/InviteUpdateAuditLogData.cs @@ -0,0 +1,44 @@ +using Discord.API.AuditLogs; + +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data relating to an invite update. +/// +public class InviteUpdateAuditLogData : IAuditLogData +{ + private InviteUpdateAuditLogData(InviteInfo before, InviteInfo after) + { + Before = before; + After = after; + } + + internal static InviteUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new InviteUpdateAuditLogData(new(before), new(after)); + } + + /// + /// Gets the invite information before the changes. + /// + /// + /// An information object containing the original invite information before the changes were made. + /// + public InviteInfo Before { get; } + + /// + /// Gets the invite information after the changes. + /// + /// + /// An information object containing the invite information after the changes were made. + /// + public InviteInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/KickAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/KickAuditLogData.cs new file mode 100644 index 0000000..610983c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/KickAuditLogData.cs @@ -0,0 +1,39 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a kick. +/// +public class KickAuditLogData : IAuditLogData +{ + private KickAuditLogData(RestUser user, string integrationType) + { + Target = user; + IntegrationType = integrationType; + } + + internal static KickAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + return new KickAuditLogData((userInfo != null) ? RestUser.Create(discord, userInfo) : null, entry.Options?.IntegrationType); + } + + /// + /// Gets the user that was kicked. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the kicked user. + /// + public IUser Target { get; } + + /// + /// Gets the type of integration which performed the action. if the action was performed by a user. + /// + public string IntegrationType { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberDisconnectAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberDisconnectAuditLogData.cs new file mode 100644 index 0000000..a04c1af --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberDisconnectAuditLogData.cs @@ -0,0 +1,28 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to disconnecting members from voice channels. +/// +public class MemberDisconnectAuditLogData : IAuditLogData +{ + private MemberDisconnectAuditLogData(int count) + { + MemberCount = count; + } + + internal static MemberDisconnectAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + return new MemberDisconnectAuditLogData(entry.Options.Count.Value); + } + + /// + /// Gets the number of members that were disconnected. + /// + /// + /// An representing the number of members that were disconnected from a voice channel. + /// + public int MemberCount { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberInfo.cs new file mode 100644 index 0000000..58c4e7b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberInfo.cs @@ -0,0 +1,57 @@ +using Discord.API.AuditLogs; +using System; + +namespace Discord.Rest; + +/// +/// Represents information for a member. +/// +public struct MemberInfo +{ + internal MemberInfo(MemberInfoAuditLogModel model) + { + Nickname = model.Nickname; + Deaf = model.IsDeafened; + Mute = model.IsMuted; + TimedOutUntil = model.TimeOutUntil; + } + + /// + /// Gets the nickname of the updated member. + /// + /// + /// A string representing the nickname of the updated member; if none is set. + /// + public string Nickname { get; } + + /// + /// Gets a value that indicates whether the updated member is deafened by the guild. + /// + /// + /// if the updated member is deafened (i.e. not permitted to listen to or speak to others) by the guild; + /// otherwise . + /// if this is not mentioned in this entry. + /// + public bool? Deaf { get; } + + /// + /// Gets a value that indicates whether the updated member is muted (i.e. not permitted to speak via voice) by the + /// guild. + /// + /// + /// if the updated member is muted by the guild; otherwise . + /// if this is not mentioned in this entry. + /// + public bool? Mute { get; } + + /// + /// Gets the date and time that indicates if and for how long the updated user has been timed out. + /// + /// + /// or a timestamp in the past if the user is not timed out. + /// + /// + /// A indicating how long the user will be timed out for. + /// + public DateTimeOffset? TimedOutUntil { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberMoveAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberMoveAuditLogData.cs new file mode 100644 index 0000000..c33019b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberMoveAuditLogData.cs @@ -0,0 +1,36 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to moving members between voice channels. +/// +public class MemberMoveAuditLogData : IAuditLogData +{ + private MemberMoveAuditLogData(ulong channelId, int count) + { + ChannelId = channelId; + MemberCount = count; + } + + internal static MemberMoveAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + return new MemberMoveAuditLogData(entry.Options.ChannelId.Value, entry.Options.Count.Value); + } + + /// + /// Gets the ID of the channel that the members were moved to. + /// + /// + /// A representing the snowflake identifier for the channel that the members were moved to. + /// + public ulong ChannelId { get; } + /// + /// Gets the number of members that were moved. + /// + /// + /// An representing the number of members that were moved to another voice channel. + /// + public int MemberCount { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberRoleAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberRoleAuditLogData.cs new file mode 100644 index 0000000..eb91ff2 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberRoleAuditLogData.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a change in a guild member's roles. +/// +public class MemberRoleAuditLogData : IAuditLogData +{ + private MemberRoleAuditLogData(IReadOnlyCollection roles, IUser target, string integrationType) + { + Roles = roles; + Target = target; + IntegrationType = integrationType; + } + + internal static MemberRoleAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var roleInfos = changes.SelectMany(x => x.NewValue.ToObject(discord.ApiClient.Serializer), + (model, role) => new { model.ChangedProperty, Role = role }) + .Select(x => new MemberRoleEditInfo(x.Role.Name, x.Role.Id, x.ChangedProperty == "$add", x.ChangedProperty == "$remove")) + .ToList(); + + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + RestUser user = (userInfo != null) ? RestUser.Create(discord, userInfo) : null; + + return new MemberRoleAuditLogData(roleInfos.ToReadOnlyCollection(), user, entry.Options?.IntegrationType); + } + + /// + /// Gets a collection of role changes that were performed on the member. + /// + /// + /// A read-only collection of , containing the roles that were changed on + /// the member. + /// + public IReadOnlyCollection Roles { get; } + + /// + /// Gets the user that the roles changes were performed on. + /// + /// + /// A user object representing the user that the role changes were performed on. + /// + public IUser Target { get; } + + /// + /// Gets the type of integration which performed the action. if the action was performed by a user. + /// + public string IntegrationType { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberRoleEditInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberRoleEditInfo.cs new file mode 100644 index 0000000..2ab7691 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberRoleEditInfo.cs @@ -0,0 +1,46 @@ +namespace Discord.Rest; + +/// +/// An information object representing a change in one of a guild member's roles. +/// +public struct MemberRoleEditInfo +{ + internal MemberRoleEditInfo(string name, ulong roleId, bool added, bool removed) + { + Name = name; + RoleId = roleId; + Added = added; + Removed = removed; + } + + /// + /// Gets the name of the role that was changed. + /// + /// + /// A string containing the name of the role that was changed. + /// + public string Name { get; } + + /// + /// Gets the ID of the role that was changed. + /// + /// + /// A representing the snowflake identifier of the role that was changed. + /// + public ulong RoleId { get; } + + /// + /// Gets a value that indicates whether the role was added to the user. + /// + /// + /// if the role was added to the user; otherwise . + /// + public bool Added { get; } + /// + /// Gets a value indicating that the user role has been removed. + /// + /// + /// true if the role has been removed from the user; otherwise false. + /// + public bool Removed { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberUpdateAuditLogData.cs new file mode 100644 index 0000000..b2da83e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MemberUpdateAuditLogData.cs @@ -0,0 +1,56 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a change in a guild member. +/// +public class MemberUpdateAuditLogData : IAuditLogData +{ + private MemberUpdateAuditLogData(IUser target, MemberInfo before, MemberInfo after) + { + Target = target; + Before = before; + After = after; + } + + internal static MemberUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var targetInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + RestUser user = (targetInfo != null) ? RestUser.Create(discord, targetInfo) : null; + + return new MemberUpdateAuditLogData(user, new MemberInfo(before), new MemberInfo(after)); + } + + /// + /// Gets the user that the changes were performed on. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user who the changes were performed on. + /// + public IUser Target { get; } + /// + /// Gets the member information before the changes. + /// + /// + /// An information object containing the original member information before the changes were made. + /// + public MemberInfo Before { get; } + /// + /// Gets the member information after the changes. + /// + /// + /// An information object containing the member information after the changes were made. + /// + public MemberInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageBulkDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageBulkDeleteAuditLogData.cs new file mode 100644 index 0000000..878cd25 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageBulkDeleteAuditLogData.cs @@ -0,0 +1,37 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to message deletion(s). +/// +public class MessageBulkDeleteAuditLogData : IAuditLogData +{ + private MessageBulkDeleteAuditLogData(ulong channelId, int count) + { + ChannelId = channelId; + MessageCount = count; + } + + internal static MessageBulkDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + return new MessageBulkDeleteAuditLogData(entry.TargetId.Value, entry.Options.Count.Value); + } + + /// + /// Gets the ID of the channel that the messages were deleted from. + /// + /// + /// A representing the snowflake identifier for the channel that the messages were + /// deleted from. + /// + public ulong ChannelId { get; } + /// + /// Gets the number of messages that were deleted. + /// + /// + /// An representing the number of messages that were deleted from the channel. + /// + public int MessageCount { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageDeleteAuditLogData.cs new file mode 100644 index 0000000..7281afe --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageDeleteAuditLogData.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to message deletion(s). +/// +public class MessageDeleteAuditLogData : IAuditLogData +{ + private MessageDeleteAuditLogData(ulong channelId, int count, IUser user) + { + ChannelId = channelId; + MessageCount = count; + Target = user; + } + + internal static MessageDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + return new MessageDeleteAuditLogData(entry.Options.ChannelId.Value, entry.Options.Count.Value, userInfo != null ? RestUser.Create(discord, userInfo) : null); + } + + /// + /// Gets the number of messages that were deleted. + /// + /// + /// An representing the number of messages that were deleted from the channel. + /// + public int MessageCount { get; } + /// + /// Gets the ID of the channel that the messages were deleted from. + /// + /// + /// A representing the snowflake identifier for the channel that the messages were + /// deleted from. + /// + public ulong ChannelId { get; } + /// + /// Gets the user of the messages that were deleted. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user that created the deleted messages. + /// + public IUser Target { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessagePinAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessagePinAuditLogData.cs new file mode 100644 index 0000000..d8cadb9 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessagePinAuditLogData.cs @@ -0,0 +1,57 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a pinned message. +/// +public class MessagePinAuditLogData : IAuditLogData +{ + private MessagePinAuditLogData(ulong messageId, ulong channelId, IUser user) + { + MessageId = messageId; + ChannelId = channelId; + Target = user; + } + + internal static MessagePinAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + RestUser user = null; + if (entry.TargetId.HasValue) + { + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + user = (userInfo != null) ? RestUser.Create(discord, userInfo) : null; + } + + return new MessagePinAuditLogData(entry.Options.MessageId!.Value, entry.Options.ChannelId!.Value, user); + } + + /// + /// Gets the ID of the messages that was pinned. + /// + /// + /// A representing the snowflake identifier for the messages that was pinned. + /// + public ulong MessageId { get; } + + /// + /// Gets the ID of the channel that the message was pinned from. + /// + /// + /// A representing the snowflake identifier for the channel that the message was pinned from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the user of the message that was pinned if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user that created the pinned message or . + /// + public IUser Target { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageUnpinAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageUnpinAuditLogData.cs new file mode 100644 index 0000000..3526980 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/MessageUnpinAuditLogData.cs @@ -0,0 +1,55 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an unpinned message. +/// +public class MessageUnpinAuditLogData : IAuditLogData +{ + private MessageUnpinAuditLogData(ulong messageId, ulong channelId, IUser user) + { + MessageId = messageId; + ChannelId = channelId; + Target = user; + } + + internal static MessageUnpinAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + RestUser user = null; + if (entry.TargetId.HasValue) + { + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + user = (userInfo != null) ? RestUser.Create(discord, userInfo) : null; + } + + return new MessageUnpinAuditLogData(entry.Options.MessageId.Value, entry.Options.ChannelId.Value, user); + } + + /// + /// Gets the ID of the messages that was unpinned. + /// + /// + /// A representing the snowflake identifier for the messages that was unpinned. + /// + public ulong MessageId { get; } + /// + /// Gets the ID of the channel that the message was unpinned from. + /// + /// + /// A representing the snowflake identifier for the channel that the message was unpinned from. + /// + public ulong ChannelId { get; } + /// + /// Gets the user of the message that was unpinned if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user that created the unpinned message or . + /// + public IUser Target { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingInfo.cs new file mode 100644 index 0000000..b7dc5db --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingInfo.cs @@ -0,0 +1,34 @@ +using Discord.API.AuditLogs; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Rest; + +public class OnboardingInfo +{ + internal OnboardingInfo(OnboardingAuditLogModel model, BaseDiscordClient discord) + { + Prompts = model.Prompts?.Select(x => new RestGuildOnboardingPrompt(discord, x.Id, x)).ToImmutableArray(); + DefaultChannelIds = model.DefaultChannelIds; + IsEnabled = model.Enabled; + } + + /// + /// + /// if this property is not mentioned in this entry. + /// + IReadOnlyCollection Prompts { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + IReadOnlyCollection DefaultChannelIds { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsEnabled { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptCreatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptCreatedAuditLogData.cs new file mode 100644 index 0000000..a6167b9 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptCreatedAuditLogData.cs @@ -0,0 +1,31 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an onboarding prompt creation. +/// +public class OnboardingPromptCreatedAuditLogData : IAuditLogData +{ + internal OnboardingPromptCreatedAuditLogData(OnboardingPromptInfo data) + { + Data = data; + } + + internal static OnboardingPromptCreatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new OnboardingPromptCreatedAuditLogData(new(data, discord)); + } + + /// + /// Gets the onboarding prompt information after the changes. + /// + OnboardingPromptInfo Data { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptInfo.cs new file mode 100644 index 0000000..a28564c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptInfo.cs @@ -0,0 +1,56 @@ +using Discord.API.AuditLogs; + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Rest; + +public class OnboardingPromptInfo +{ + internal OnboardingPromptInfo(OnboardingPromptAuditLogModel model, BaseDiscordClient discord) + { + Title = model.Title; + IsSingleSelect = model.IsSingleSelect; + IsRequired = model.IsRequired; + IsInOnboarding = model.IsInOnboarding; + Type = model.Type; + Options = model.Options?.Select(x => new RestGuildOnboardingPromptOption(discord, x.Id, x)).ToImmutableArray(); + } + + /// + /// + /// if this property is not mentioned in this entry. + /// + string Title { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsSingleSelect { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsRequired { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsInOnboarding { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + GuildOnboardingPromptType? Type { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + IReadOnlyCollection Options { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptUpdatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptUpdatedAuditLogData.cs new file mode 100644 index 0000000..899fb67 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingPromptUpdatedAuditLogData.cs @@ -0,0 +1,38 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + + +/// +/// Contains a piece of audit log data related to an onboarding prompt update. +/// +public class OnboardingPromptUpdatedAuditLogData : IAuditLogData +{ + internal OnboardingPromptUpdatedAuditLogData(OnboardingPromptInfo before, OnboardingPromptInfo after) + { + Before = before; + After = after; + } + + internal static OnboardingPromptUpdatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new OnboardingPromptUpdatedAuditLogData(new(before, discord), new(after, discord)); + } + + /// + /// Gets the onboarding prompt information after the changes. + /// + OnboardingPromptInfo After { get; set; } + + /// + /// Gets the onboarding prompt information before the changes. + /// + OnboardingPromptInfo Before { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingUpdatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingUpdatedAuditLogData.cs new file mode 100644 index 0000000..3dbc321 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OnboardingUpdatedAuditLogData.cs @@ -0,0 +1,38 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + + +/// +/// Contains a piece of audit log data related to a guild update. +/// +public class OnboardingUpdatedAuditLogData : IAuditLogData +{ + internal OnboardingUpdatedAuditLogData(OnboardingInfo before, OnboardingInfo after) + { + Before = before; + After = after; + } + + internal static OnboardingUpdatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new OnboardingUpdatedAuditLogData(new(before, discord), new(after, discord)); + } + + /// + /// Gets the onboarding information after the changes. + /// + OnboardingInfo After { get; set; } + + /// + /// Gets the onboarding information before the changes. + /// + OnboardingInfo Before { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteCreateAuditLogData.cs new file mode 100644 index 0000000..67a6d6b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteCreateAuditLogData.cs @@ -0,0 +1,52 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data for a permissions overwrite creation. +/// +public class OverwriteCreateAuditLogData : IAuditLogData +{ + private OverwriteCreateAuditLogData(ulong channelId, Overwrite overwrite) + { + ChannelId = channelId; + Overwrite = overwrite; + } + + internal static OverwriteCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var denyModel = changes.FirstOrDefault(x => x.ChangedProperty == "deny"); + var allowModel = changes.FirstOrDefault(x => x.ChangedProperty == "allow"); + + var deny = denyModel.NewValue.ToObject(discord.ApiClient.Serializer); + var allow = allowModel.NewValue.ToObject(discord.ApiClient.Serializer); + + var permissions = new OverwritePermissions(allow, deny); + + var id = entry.Options.OverwriteTargetId.Value; + var type = entry.Options.OverwriteType; + + return new OverwriteCreateAuditLogData(entry.TargetId.Value, new Overwrite(id, type, permissions)); + } + + /// + /// Gets the ID of the channel that the overwrite was created from. + /// + /// + /// A representing the snowflake identifier for the channel that the overwrite was + /// created from. + /// + public ulong ChannelId { get; } + /// + /// Gets the permission overwrite object that was created. + /// + /// + /// An object representing the overwrite that was created. + /// + public Overwrite Overwrite { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteDeleteAuditLogData.cs new file mode 100644 index 0000000..ba72c97 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteDeleteAuditLogData.cs @@ -0,0 +1,51 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to the deletion of a permission overwrite. +/// +public class OverwriteDeleteAuditLogData : IAuditLogData +{ + private OverwriteDeleteAuditLogData(ulong channelId, Overwrite deletedOverwrite) + { + ChannelId = channelId; + Overwrite = deletedOverwrite; + } + + internal static OverwriteDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var denyModel = changes.FirstOrDefault(x => x.ChangedProperty == "deny"); + var allowModel = changes.FirstOrDefault(x => x.ChangedProperty == "allow"); + + var deny = denyModel.OldValue.ToObject(discord.ApiClient.Serializer); + var allow = allowModel.OldValue.ToObject(discord.ApiClient.Serializer); + + var permissions = new OverwritePermissions(allow, deny); + + var id = entry.Options.OverwriteTargetId.Value; + var type = entry.Options.OverwriteType; + + return new OverwriteDeleteAuditLogData(entry.TargetId.Value, new Overwrite(id, type, permissions)); + } + + /// + /// Gets the ID of the channel that the overwrite was deleted from. + /// + /// + /// A representing the snowflake identifier for the channel that the overwrite was + /// deleted from. + /// + public ulong ChannelId { get; } + /// + /// Gets the permission overwrite object that was deleted. + /// + /// + /// An object representing the overwrite that was deleted. + /// + public Overwrite Overwrite { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteUpdateAuditLogData.cs new file mode 100644 index 0000000..a73b528 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/OverwriteUpdateAuditLogData.cs @@ -0,0 +1,80 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to the update of a permission overwrite. +/// +public class OverwriteUpdateAuditLogData : IAuditLogData +{ + private OverwriteUpdateAuditLogData(ulong channelId, OverwritePermissions before, OverwritePermissions after, ulong targetId, PermissionTarget targetType) + { + ChannelId = channelId; + OldPermissions = before; + NewPermissions = after; + OverwriteTargetId = targetId; + OverwriteType = targetType; + } + + internal static OverwriteUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + + var denyModel = changes.FirstOrDefault(x => x.ChangedProperty == "deny"); + var allowModel = changes.FirstOrDefault(x => x.ChangedProperty == "allow"); + + var beforeAllow = allowModel?.OldValue?.ToObject(discord.ApiClient.Serializer); + var afterAllow = allowModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + var beforeDeny = denyModel?.OldValue?.ToObject(discord.ApiClient.Serializer); + var afterDeny = denyModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + + var beforePermissions = new OverwritePermissions(beforeAllow ?? 0, beforeDeny ?? 0); + var afterPermissions = new OverwritePermissions(afterAllow ?? 0, afterDeny ?? 0); + + var type = entry.Options.OverwriteType; + + return new OverwriteUpdateAuditLogData(entry.TargetId.Value, beforePermissions, afterPermissions, entry.Options.OverwriteTargetId.Value, type); + } + + /// + /// Gets the ID of the channel that the overwrite was updated from. + /// + /// + /// A representing the snowflake identifier for the channel that the overwrite was + /// updated from. + /// + public ulong ChannelId { get; } + /// + /// Gets the overwrite permissions before the changes. + /// + /// + /// An overwrite permissions object representing the overwrite permissions that the overwrite had before + /// the changes were made. + /// + public OverwritePermissions OldPermissions { get; } + /// + /// Gets the overwrite permissions after the changes. + /// + /// + /// An overwrite permissions object representing the overwrite permissions that the overwrite had after the + /// changes. + /// + public OverwritePermissions NewPermissions { get; } + /// + /// Gets the ID of the overwrite that was updated. + /// + /// + /// A representing the snowflake identifier of the overwrite that was updated. + /// + public ulong OverwriteTargetId { get; } + /// + /// Gets the target of the updated permission overwrite. + /// + /// + /// The target of the updated permission overwrite. + /// + public PermissionTarget OverwriteType { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/PruneAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/PruneAuditLogData.cs new file mode 100644 index 0000000..2547639 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/PruneAuditLogData.cs @@ -0,0 +1,40 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a guild prune. +/// +public class PruneAuditLogData : IAuditLogData +{ + private PruneAuditLogData(int pruneDays, int membersRemoved) + { + PruneDays = pruneDays; + MembersRemoved = membersRemoved; + } + + internal static PruneAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + return new PruneAuditLogData(entry.Options.PruneDeleteMemberDays.Value, entry.Options.PruneMembersRemoved.Value); + } + + /// + /// Gets the threshold for a guild member to not be kicked. + /// + /// + /// An representing the amount of days that a member must have been seen in the server, + /// to avoid being kicked. (i.e. If a user has not been seen for more than , they will be + /// kicked from the server) + /// + public int PruneDays { get; } + + /// + /// Gets the number of members that were kicked during the purge. + /// + /// + /// An representing the number of members that were removed from this guild for having + /// not been seen within . + /// + public int MembersRemoved { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleCreateAuditLogData.cs new file mode 100644 index 0000000..c5bc2e6 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleCreateAuditLogData.cs @@ -0,0 +1,46 @@ +using Discord.API.AuditLogs; + +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a role creation. +/// +public class RoleCreateAuditLogData : IAuditLogData +{ + private RoleCreateAuditLogData(ulong id, RoleEditInfo props) + { + RoleId = id; + Properties = props; + } + + internal static RoleCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new RoleCreateAuditLogData(entry.TargetId!.Value, + new RoleEditInfo(data)); + } + + /// + /// Gets the ID of the role that was created. + /// + /// + /// A representing the snowflake identifier to the role that was created. + /// + public ulong RoleId { get; } + + /// + /// Gets the role information that was created. + /// + /// + /// An information object representing the properties of the role that was created. + /// + public RoleEditInfo Properties { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleDeleteAuditLogData.cs new file mode 100644 index 0000000..8ba448d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleDeleteAuditLogData.cs @@ -0,0 +1,43 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data relating to a role deletion. +/// +public class RoleDeleteAuditLogData : IAuditLogData +{ + private RoleDeleteAuditLogData(ulong id, RoleEditInfo props) + { + RoleId = id; + Properties = props; + } + + internal static RoleDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new RoleDeleteAuditLogData(entry.TargetId!.Value, new RoleEditInfo(data)); + } + + /// + /// Gets the ID of the role that was deleted. + /// + /// + /// A representing the snowflake identifier to the role that was deleted. + /// + public ulong RoleId { get; } + + /// + /// Gets the role information that was deleted. + /// + /// + /// An information object representing the properties of the role that was deleted. + /// + public RoleEditInfo Properties { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleEditInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleEditInfo.cs new file mode 100644 index 0000000..f765a9e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleEditInfo.cs @@ -0,0 +1,79 @@ +using Model = Discord.API.AuditLogs.RoleInfoAuditLogModel; + +namespace Discord.Rest; + +/// +/// Represents information for a role edit. +/// +public struct RoleEditInfo +{ + internal RoleEditInfo(Model model) + { + if (model.Color is not null) + Color = new Color(model.Color.Value); + else + Color = null; + + Mentionable = model.IsMentionable; + Hoist = model.Hoist; + Name = model.Name; + + if (model.Permissions is not null) + Permissions = new GuildPermissions(model.Permissions.Value); + else + Permissions = null; + + IconId = model.IconHash; + } + + /// + /// Gets the color of this role. + /// + /// + /// A color object representing the color assigned to this role; if this role does not have a + /// color. + /// + public Color? Color { get; } + + /// + /// Gets a value that indicates whether this role is mentionable. + /// + /// + /// if other members can mention this role in a text channel; otherwise ; + /// if this is not mentioned in this entry. + /// + public bool? Mentionable { get; } + + /// + /// Gets a value that indicates whether this role is hoisted (i.e. its members will appear in a separate + /// section on the user list). + /// + /// + /// if this role's members will appear in a separate section in the user list; otherwise + /// ; if this is not mentioned in this entry. + /// + public bool? Hoist { get; } + + /// + /// Gets the name of this role. + /// + /// + /// A string containing the name of this role. + /// + public string Name { get; } + + /// + /// Gets the permissions assigned to this role. + /// + /// + /// A guild permissions object representing the permissions that have been assigned to this role; + /// if no permissions have been assigned. + /// + public GuildPermissions? Permissions { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string IconId { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleUpdateAuditLogData.cs new file mode 100644 index 0000000..3b31931 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/RoleUpdateAuditLogData.cs @@ -0,0 +1,52 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a role update. +/// +public class RoleUpdateAuditLogData : IAuditLogData +{ + private RoleUpdateAuditLogData(ulong id, RoleEditInfo oldProps, RoleEditInfo newProps) + { + RoleId = id; + Before = oldProps; + After = newProps; + } + + internal static RoleUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new RoleUpdateAuditLogData(entry.TargetId!.Value, new(before), new(after)); + } + + /// + /// Gets the ID of the role that was changed. + /// + /// + /// A representing the snowflake identifier of the role that was changed. + /// + public ulong RoleId { get; } + + /// + /// Gets the role information before the changes. + /// + /// + /// A role information object containing the role information before the changes were made. + /// + public RoleEditInfo Before { get; } + + /// + /// Gets the role information after the changes. + /// + /// + /// A role information object containing the role information after the changes were made. + /// + public RoleEditInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs new file mode 100644 index 0000000..b4e4d07 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventCreateAuditLogData.cs @@ -0,0 +1,97 @@ +using Discord.API.AuditLogs; +using System; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a scheduled event creation. +/// +public class ScheduledEventCreateAuditLogData : IAuditLogData +{ + private ScheduledEventCreateAuditLogData(ulong id, ScheduledEventInfoAuditLogModel model, IGuildScheduledEvent scheduledEvent) + { + Id = id; + ChannelId = model.ChannelId; + Name = model.Name; + Description = model.Description; + ScheduledStartTime = model.StartTime; + ScheduledEndTime = model.EndTime; + PrivacyLevel = model.PrivacyLevel!.Value; + Status = model.EventStatus!.Value; + EntityType = model.EventType!.Value; + EntityId = model.EntityId; + Location = model.Location; + Image = model.Image; + ScheduledEvent = scheduledEvent; + } + + internal static ScheduledEventCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var scheduledEvent = log.GuildScheduledEvents.FirstOrDefault(x => x.Id == entry.TargetId); + + return new ScheduledEventCreateAuditLogData(entry.TargetId!.Value, data, RestGuildEvent.Create(discord, null, scheduledEvent)); + } + + /// + /// Gets the scheduled event this log corresponds to. + /// + public IGuildScheduledEvent ScheduledEvent { get; } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + /// + /// Gets name of the event. + /// + public string Name { get; } + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset? ScheduledStartTime { get; } + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel PrivacyLevel { get; } + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus Status { get; } + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType EntityType { get; } + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs new file mode 100644 index 0000000..4fa3fa5 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventDeleteAuditLogData.cs @@ -0,0 +1,97 @@ +using Discord.API.AuditLogs; +using System; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a scheduled event deletion. +/// +public class ScheduledEventDeleteAuditLogData : IAuditLogData +{ + private ScheduledEventDeleteAuditLogData(ulong id, ScheduledEventInfoAuditLogModel model) + { + Id = id; + ChannelId = model.ChannelId; + Name = model.Name; + Description = model.Description; + ScheduledStartTime = model.StartTime; + ScheduledEndTime = model.EndTime; + PrivacyLevel = model.PrivacyLevel; + Status = model.EventStatus; + EntityType = model.EventType; + EntityId = model.EntityId; + Location = model.Location; + Image = model.Image; + } + + internal static ScheduledEventDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new ScheduledEventDeleteAuditLogData(entry.TargetId!.Value, data); + } + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + + /// + /// Gets name of the event. + /// + public string Name { get; } + + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset? ScheduledStartTime { get; } + + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel? PrivacyLevel { get; } + + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus? Status { get; } + + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType? EntityType { get; } + + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs new file mode 100644 index 0000000..c334a1d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventInfo.cs @@ -0,0 +1,81 @@ +using Discord.API.AuditLogs; + +using System; + +namespace Discord.Rest; + +/// +/// Represents information for a scheduled event. +/// +public class ScheduledEventInfo +{ + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + + /// + /// Gets name of the event. + /// + public string Name { get; } + + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset? ScheduledStartTime { get; } + + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel? PrivacyLevel { get; } + + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus? Status { get; } + + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType? EntityType { get; } + + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } + + internal ScheduledEventInfo(ScheduledEventInfoAuditLogModel model) + { + ChannelId = model.ChannelId; + Name = model.Name; + Description = model.Description; + ScheduledStartTime = model.StartTime; + ScheduledEndTime = model.EndTime; + PrivacyLevel = model.PrivacyLevel; + Status = model.EventStatus; + EntityType = model.EventType; + EntityId = model.EntityId; + Location = model.Location; + Image = model.Image; + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs new file mode 100644 index 0000000..d93e72c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ScheduledEventUpdateAuditLogData.cs @@ -0,0 +1,54 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a scheduled event updates. +/// +public class ScheduledEventUpdateAuditLogData : IAuditLogData +{ + private ScheduledEventUpdateAuditLogData(ulong id, ScheduledEventInfo before, ScheduledEventInfo after, IGuildScheduledEvent scheduledEvent) + { + Id = id; + Before = before; + After = after; + ScheduledEvent = scheduledEvent; + + } + + internal static ScheduledEventUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var scheduledEvent = log.GuildScheduledEvents.FirstOrDefault(x => x.Id == entry.TargetId); + + return new ScheduledEventUpdateAuditLogData(entry.TargetId!.Value, new(before), new(after), RestGuildEvent.Create(discord, null, scheduledEvent)); + } + + /// + /// Gets the scheduled event this log corresponds to. + /// + public IGuildScheduledEvent ScheduledEvent { get; } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + + /// + /// Gets the state before the change. + /// + public ScheduledEventInfo Before { get; } + + /// + /// Gets the state after the change. + /// + public ScheduledEventInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInfo.cs new file mode 100644 index 0000000..3700796 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInfo.cs @@ -0,0 +1,30 @@ +namespace Discord.Rest +{ + /// + /// Represents information for a stage. + /// + public class StageInfo + { + /// + /// Gets the topic of the stage channel. + /// + public string Topic { get; } + + /// + /// Gets the privacy level of the stage channel. + /// + public StagePrivacyLevel? PrivacyLevel { get; } + + /// + /// Gets the user who started the stage channel. + /// + public IUser User { get; } + + internal StageInfo(IUser user, StagePrivacyLevel? level, string topic) + { + Topic = topic; + PrivacyLevel = level; + User = user; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceCreateAuditLogData.cs new file mode 100644 index 0000000..f73f71d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceCreateAuditLogData.cs @@ -0,0 +1,49 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a stage going live. +/// +public class StageInstanceCreateAuditLogData : IAuditLogData +{ + /// + /// Gets the topic of the stage channel. + /// + public string Topic { get; } + + /// + /// Gets the privacy level of the stage channel. + /// + public StagePrivacyLevel PrivacyLevel { get; } + + /// + /// Gets the user who started the stage channel. + /// + public IUser User { get; } + + /// + /// Gets the Id of the stage channel. + /// + public ulong StageChannelId { get; } + + internal StageInstanceCreateAuditLogData(string topic, StagePrivacyLevel privacyLevel, IUser user, ulong channelId) + { + Topic = topic; + PrivacyLevel = privacyLevel; + User = user; + StageChannelId = channelId; + } + + internal static StageInstanceCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var topic = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "topic").NewValue.ToObject(discord.ApiClient.Serializer); + var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level").NewValue.ToObject(discord.ApiClient.Serializer); + var user = log.Users.FirstOrDefault(x => x.Id == entry.UserId); + var channelId = entry.Options.ChannelId; + + return new StageInstanceCreateAuditLogData(topic, privacyLevel, RestUser.Create(discord, user), channelId ?? 0); + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceDeleteAuditLogData.cs new file mode 100644 index 0000000..d5136a8 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceDeleteAuditLogData.cs @@ -0,0 +1,49 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a stage instance deleted. +/// +public class StageInstanceDeleteAuditLogData : IAuditLogData +{ + /// + /// Gets the topic of the stage channel. + /// + public string Topic { get; } + + /// + /// Gets the privacy level of the stage channel. + /// + public StagePrivacyLevel PrivacyLevel { get; } + + /// + /// Gets the user who started the stage channel. + /// + public IUser User { get; } + + /// + /// Gets the Id of the stage channel. + /// + public ulong StageChannelId { get; } + + internal StageInstanceDeleteAuditLogData(string topic, StagePrivacyLevel privacyLevel, IUser user, ulong channelId) + { + Topic = topic; + PrivacyLevel = privacyLevel; + User = user; + StageChannelId = channelId; + } + + internal static StageInstanceDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var topic = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "topic").OldValue.ToObject(discord.ApiClient.Serializer); + var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level").OldValue.ToObject(discord.ApiClient.Serializer); + var user = log.Users.FirstOrDefault(x => x.Id == entry.UserId); + var channelId = entry.Options.ChannelId; + + return new StageInstanceDeleteAuditLogData(topic, privacyLevel, RestUser.Create(discord, user), channelId ?? 0); + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceUpdatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceUpdatedAuditLogData.cs new file mode 100644 index 0000000..d661a48 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StageInstanceUpdatedAuditLogData.cs @@ -0,0 +1,50 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a stage instance update. +/// +public class StageInstanceUpdatedAuditLogData : IAuditLogData +{ + /// + /// Gets the Id of the stage channel. + /// + public ulong StageChannelId { get; } + + /// + /// Gets the stage information before the changes. + /// + public StageInfo Before { get; } + + /// + /// Gets the stage information after the changes. + /// + public StageInfo After { get; } + + internal StageInstanceUpdatedAuditLogData(ulong channelId, StageInfo before, StageInfo after) + { + StageChannelId = channelId; + Before = before; + After = after; + } + + internal static StageInstanceUpdatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var channelId = entry.Options.ChannelId.Value; + + var topic = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "topic"); + var privacy = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy"); + + var user = RestUser.Create(discord, log.Users.FirstOrDefault(x => x.Id == entry.UserId)); + + var oldTopic = topic?.OldValue.ToObject(); + var newTopic = topic?.NewValue.ToObject(); + var oldPrivacy = privacy?.OldValue.ToObject(); + var newPrivacy = privacy?.NewValue.ToObject(); + + return new StageInstanceUpdatedAuditLogData(channelId, new StageInfo(user, oldPrivacy, oldTopic), new StageInfo(user, newPrivacy, newTopic)); + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerCreatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerCreatedAuditLogData.cs new file mode 100644 index 0000000..c746285 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerCreatedAuditLogData.cs @@ -0,0 +1,29 @@ +using Discord.API.AuditLogs; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a sticker creation. +/// +public class StickerCreatedAuditLogData : IAuditLogData +{ + internal StickerCreatedAuditLogData(StickerInfo data) + { + Data = data; + } + + internal static StickerCreatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new StickerCreatedAuditLogData(new(data)); + } + + /// + /// Gets the sticker information after the changes. + /// + public StickerInfo Data { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerDeletedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerDeletedAuditLogData.cs new file mode 100644 index 0000000..8616dcf --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerDeletedAuditLogData.cs @@ -0,0 +1,29 @@ +using Discord.API.AuditLogs; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a sticker removal. +/// +public class StickerDeletedAuditLogData : IAuditLogData +{ + internal StickerDeletedAuditLogData(StickerInfo data) + { + Data = data; + } + + internal static StickerDeletedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new StickerDeletedAuditLogData(new(data)); + } + + /// + /// Gets the sticker information before the changes. + /// + public StickerInfo Data { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerInfo.cs new file mode 100644 index 0000000..acba67e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerInfo.cs @@ -0,0 +1,31 @@ +using Discord.API.AuditLogs; + +namespace Discord.Rest; + +/// +/// Represents information for a guild. +/// +public class StickerInfo +{ + internal StickerInfo(StickerInfoAuditLogModel model) + { + Name = model.Name; + Tags = model.Tags; + Description = model.Description; + } + + /// + /// Gets the name of the sticker. if the value was not updated in this entry. + /// + public string Name { get; set; } + + /// + /// Gets tags of the sticker. if the value was not updated in this entry. + /// + public string Tags { get; set; } + + /// + /// Gets the description of the sticker. if the value was not updated in this entry. + /// + public string Description { get; set; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerUpdatedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerUpdatedAuditLogData.cs new file mode 100644 index 0000000..9e71850 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/StickerUpdatedAuditLogData.cs @@ -0,0 +1,35 @@ +using Discord.API.AuditLogs; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a sticker update. +/// +public class StickerUpdatedAuditLogData : IAuditLogData +{ + internal StickerUpdatedAuditLogData(StickerInfo before, StickerInfo after) + { + Before = before; + After = after; + } + + internal static StickerUpdatedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var changes = entry.Changes; + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new StickerUpdatedAuditLogData(new(before), new (after)); + } + + /// + /// Gets the sticker information before the changes. + /// + public StickerInfo Before { get; } + + /// + /// Gets the sticker information after the changes. + /// + public StickerInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadCreateAuditLogData.cs new file mode 100644 index 0000000..48952b4 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadCreateAuditLogData.cs @@ -0,0 +1,123 @@ +using Discord.API.AuditLogs; + +using System.Collections.Generic; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a thread creation. +/// +public class ThreadCreateAuditLogData : IAuditLogData +{ + private ThreadCreateAuditLogData(IThreadChannel thread, ulong id, ThreadInfoAuditLogModel model) + { + Thread = thread; + ThreadId = id; + + ThreadName = model.Name; + IsArchived = model.IsArchived!.Value; + AutoArchiveDuration = model.ArchiveDuration!.Value; + IsLocked = model.IsLocked!.Value; + SlowModeInterval = model.SlowModeInterval; + AppliedTags = model.AppliedTags; + Flags = model.ChannelFlags; + ThreadType = model.Type; + } + + internal static ThreadCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var threadInfo = log.Threads.FirstOrDefault(x => x.Id == entry.TargetId!.Value); + var threadChannel = threadInfo == null ? null : RestThreadChannel.Create(discord, (IGuild)null, threadInfo); + + return new ThreadCreateAuditLogData(threadChannel, entry.TargetId!.Value, data); + } + + /// + /// Gets the thread that was created if it still exists. + /// + /// + /// A thread object representing the thread that was created if it still exists, otherwise returns . + /// + public IThreadChannel Thread { get; } + + /// + /// Gets the snowflake ID of the thread. + /// + /// + /// A representing the snowflake identifier for the thread. + /// + public ulong ThreadId { get; } + + /// + /// Gets the name of the thread. + /// + /// + /// A string containing the name of the thread. + /// + public string ThreadName { get; } + + /// + /// Gets the type of the thread. + /// + /// + /// The type of thread. + /// + public ThreadType ThreadType { get; } + + /// + /// Gets the value that indicates whether the thread is archived. + /// + /// + /// if this thread has the Archived flag enabled; otherwise . + /// + public bool IsArchived { get; } + + /// + /// Gets the auto archive duration of the thread. + /// + /// + /// The thread auto archive duration of the thread. + /// + public ThreadArchiveDuration AutoArchiveDuration { get; } + + /// + /// Gets the value that indicates whether the thread is locked. + /// + /// + /// if this thread has the Locked flag enabled; otherwise . + /// + public bool IsLocked { get; } + + /// + /// Gets the slow-mode delay of the thread. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the applied tags of this thread. + /// + /// + /// if the property was not updated. + /// + public IReadOnlyCollection AppliedTags { get; } + + /// + /// Gets the flags of the thread channel. + /// + /// + /// if the property was not updated. + /// + public ChannelFlags? Flags { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadDeleteAuditLogData.cs new file mode 100644 index 0000000..f7a46b9 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadDeleteAuditLogData.cs @@ -0,0 +1,111 @@ +using Discord.API.AuditLogs; +using System.Collections.Generic; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a thread deletion. +/// +public class ThreadDeleteAuditLogData : IAuditLogData +{ + private ThreadDeleteAuditLogData(ulong id, ThreadInfoAuditLogModel model) + { + ThreadId = id; + + ThreadName = model.Name; + IsArchived = model.IsArchived!.Value; + AutoArchiveDuration = model.ArchiveDuration!.Value; + IsLocked = model.IsLocked!.Value; + SlowModeInterval = model.SlowModeInterval; + AppliedTags = model.AppliedTags; + Flags = model.ChannelFlags; + ThreadType = model.Type; + } + + internal static ThreadDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new ThreadDeleteAuditLogData(entry.TargetId!.Value, data); + } + + /// + /// Gets the snowflake ID of the deleted thread. + /// + /// + /// A representing the snowflake identifier for the deleted thread. + /// + /// + public ulong ThreadId { get; } + + /// + /// Gets the name of the deleted thread. + /// + /// + /// A string containing the name of the deleted thread. + /// + /// + public string ThreadName { get; } + + /// + /// Gets the type of the deleted thread. + /// + /// + /// The type of thread that was deleted. + /// + public ThreadType ThreadType { get; } + + /// + /// Gets the value that indicates whether the deleted thread was archived. + /// + /// + /// if this thread had the Archived flag enabled; otherwise . + /// + public bool IsArchived { get; } + + /// + /// Gets the thread auto archive duration of the deleted thread. + /// + /// + /// The thread auto archive duration of the thread that was deleted. + /// + public ThreadArchiveDuration AutoArchiveDuration { get; } + + /// + /// Gets the value that indicates whether the deleted thread was locked. + /// + /// + /// if this thread had the Locked flag enabled; otherwise . + /// + public bool IsLocked { get; } + + /// + /// Gets the slow-mode delay of the deleted thread. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the applied tags of this thread. + /// + /// + /// if this is not mentioned in this entry. + /// + public IReadOnlyCollection AppliedTags { get; } + + /// + /// Gets the flags of the thread channel. + /// + /// + /// if this is not mentioned in this entry. + /// + public ChannelFlags? Flags { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadInfo.cs new file mode 100644 index 0000000..ba53bd1 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadInfo.cs @@ -0,0 +1,84 @@ +using Discord.API.AuditLogs; +using System; +using System.Collections.Generic; + +namespace Discord.Rest; + +/// +/// Represents information for a thread. +/// +public class ThreadInfo +{ + /// + /// Gets the name of the thread. + /// + public string Name { get; } + + /// + /// Gets the value that indicates whether the thread is archived. + /// + /// + /// if the property was not updated. + /// + public bool? IsArchived { get; } + + /// + /// Gets the auto archive duration of thread. + /// + /// + /// if the property was not updated. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// Gets the value that indicates whether the thread is locked. + /// + /// + /// if the property was not updated. + /// + public bool? IsLocked { get; } + + /// + /// Gets the slow-mode delay of the thread. + /// + /// + /// if the property was not updated. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the applied tags of this thread. + /// + /// + /// if the property was not updated. + /// + public IReadOnlyCollection AppliedTags { get; } + + /// + /// Gets the flags of the thread channel. + /// + /// + /// if the property was not updated. + /// + public ChannelFlags? Flags { get; } + + /// + /// Gets the type of the thread. + /// + /// + /// if the property was not updated. + /// + public ThreadType Type { get; } + + internal ThreadInfo(ThreadInfoAuditLogModel model) + { + Name = model.Name; + IsArchived = model.IsArchived; + AutoArchiveDuration = model.ArchiveDuration; + IsLocked = model.IsLocked; + SlowModeInterval = model.SlowModeInterval; + AppliedTags = model.AppliedTags; + Flags = model.ChannelFlags; + Type = model.Type; + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadUpdateAuditLogData.cs new file mode 100644 index 0000000..d98ae07 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/ThreadUpdateAuditLogData.cs @@ -0,0 +1,67 @@ +using Discord.API.AuditLogs; + +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a thread update. +/// +public class ThreadUpdateAuditLogData : IAuditLogData +{ + private ThreadUpdateAuditLogData(IThreadChannel thread, ThreadType type, ThreadInfo before, ThreadInfo after) + { + Thread = thread; + ThreadType = type; + Before = before; + After = after; + } + + internal static ThreadUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var threadInfo = log.Threads.FirstOrDefault(x => x.Id == entry.TargetId!.Value); + var threadChannel = threadInfo == null ? null : RestThreadChannel.Create(discord, (IGuild)null, threadInfo); + + return new ThreadUpdateAuditLogData(threadChannel, before.Type, new(before), new (after)); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the thread that was created if it still exists. + /// + /// + /// A thread object representing the thread that was created if it still exists, otherwise returns . + /// + public IThreadChannel Thread { get; } + + /// + /// Gets the type of the thread. + /// + /// + /// The type of thread. + /// + public ThreadType ThreadType { get; } + + /// + /// Gets the thread information before the changes. + /// + /// + /// A thread information object representing the thread before the changes were made. + /// + public ThreadInfo Before { get; } + + /// + /// Gets the thread information after the changes. + /// + /// + /// A thread information object representing the thread after the changes were made. + /// + public ThreadInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/UnbanAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/UnbanAuditLogData.cs new file mode 100644 index 0000000..39f97b1 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/UnbanAuditLogData.cs @@ -0,0 +1,30 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to an unban. +/// +public class UnbanAuditLogData : IAuditLogData +{ + private UnbanAuditLogData(IUser user) + { + Target = user; + } + + internal static UnbanAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + var userInfo = log.Users.FirstOrDefault(x => x.Id == entry.TargetId); + return new UnbanAuditLogData((userInfo != null) ? RestUser.Create(discord, userInfo) : null); + } + + /// + /// Gets the user that was unbanned. + /// + /// + /// A user object representing the user that was unbanned. + /// + public IUser Target { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/VoiceChannelStatusDeletedAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/VoiceChannelStatusDeletedAuditLogData.cs new file mode 100644 index 0000000..321d3fe --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/VoiceChannelStatusDeletedAuditLogData.cs @@ -0,0 +1,25 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a voice channel status delete. +/// +public class VoiceChannelStatusDeletedAuditLogData : IAuditLogData +{ + private VoiceChannelStatusDeletedAuditLogData(ulong channelId) + { + ChannelId = channelId; + } + + internal static VoiceChannelStatusDeletedAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + return new (entry.TargetId!.Value); + } + + /// + /// Get the id of the channel status was removed in. + /// + public ulong ChannelId { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/VoiceChannelStatusUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/VoiceChannelStatusUpdateAuditLogData.cs new file mode 100644 index 0000000..339a6cc --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/VoiceChannelStatusUpdateAuditLogData.cs @@ -0,0 +1,31 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a voice channel status update. +/// +public class VoiceChannelStatusUpdateAuditLogData : IAuditLogData +{ + private VoiceChannelStatusUpdateAuditLogData(string status, ulong channelId) + { + Status = status; + ChannelId = channelId; + } + + internal static VoiceChannelStatusUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log = null) + { + return new (entry.Options.Status, entry.TargetId!.Value); + } + + /// + /// Gets the status that was set in the voice channel. + /// + public string Status { get; } + + /// + /// Get the id of the channel status was updated in. + /// + public ulong ChannelId { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookCreateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookCreateAuditLogData.cs new file mode 100644 index 0000000..5698744 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookCreateAuditLogData.cs @@ -0,0 +1,87 @@ +using Discord.API.AuditLogs; +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a webhook creation. +/// +public class WebhookCreateAuditLogData : IAuditLogData +{ + private WebhookCreateAuditLogData(IWebhook webhook, ulong webhookId, WebhookInfoAuditLogModel model) + { + Webhook = webhook; + WebhookId = webhookId; + Name = model.Name; + Type = model.Type!.Value; + ChannelId = model.ChannelId!.Value; + Avatar = model.AvatarHash; + } + + internal static WebhookCreateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var webhookInfo = log.Webhooks?.FirstOrDefault(x => x.Id == entry.TargetId); + var webhook = webhookInfo == null ? null : RestWebhook.Create(discord, (IGuild)null, webhookInfo); + + return new WebhookCreateAuditLogData(webhook, entry.TargetId!.Value, data); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the webhook that was created if it still exists. + /// + /// + /// A webhook object representing the webhook that was created if it still exists, otherwise returns . + /// + public IWebhook Webhook { get; } + + // Doc Note: Corresponds to the *audit log* data + + /// + /// Gets the webhook id. + /// + /// + /// The webhook identifier. + /// + public ulong WebhookId { get; } + + /// + /// Gets the type of webhook that was created. + /// + /// + /// The type of webhook that was created. + /// + public WebhookType Type { get; } + + /// + /// Gets the name of the webhook. + /// + /// + /// A string containing the name of the webhook. + /// + public string Name { get; } + /// + /// Gets the ID of the channel that the webhook could send to. + /// + /// + /// A representing the snowflake identifier of the channel that the webhook could send + /// to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the hash value of the webhook's avatar. + /// + /// + /// A string containing the hash of the webhook's avatar. + /// + public string Avatar { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookDeleteAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookDeleteAuditLogData.cs new file mode 100644 index 0000000..f4bb936 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookDeleteAuditLogData.cs @@ -0,0 +1,71 @@ +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a webhook deletion. +/// +public class WebhookDeleteAuditLogData : IAuditLogData +{ + private WebhookDeleteAuditLogData(ulong id, WebhookInfoAuditLogModel model) + { + WebhookId = id; + ChannelId = model.ChannelId!.Value; + Name = model.Name; + Type = model.Type!.Value; + Avatar = model.AvatarHash; + } + + internal static WebhookDeleteAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new WebhookDeleteAuditLogData(entry.TargetId!.Value, data); + } + + /// + /// Gets the ID of the webhook that was deleted. + /// + /// + /// A representing the snowflake identifier of the webhook that was deleted. + /// + public ulong WebhookId { get; } + + /// + /// Gets the ID of the channel that the webhook could send to. + /// + /// + /// A representing the snowflake identifier of the channel that the webhook could send + /// to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the type of the webhook that was deleted. + /// + /// + /// The type of webhook that was deleted. + /// + public WebhookType Type { get; } + + /// + /// Gets the name of the webhook that was deleted. + /// + /// + /// A string containing the name of the webhook that was deleted. + /// + public string Name { get; } + + /// + /// Gets the hash value of the webhook's avatar. + /// + /// + /// A string containing the hash of the webhook's avatar. + /// + public string Avatar { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookInfo.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookInfo.cs new file mode 100644 index 0000000..93ed9fa --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookInfo.cs @@ -0,0 +1,41 @@ +using Model = Discord.API.AuditLogs.WebhookInfoAuditLogModel; + +namespace Discord.Rest; + +/// +/// Represents information for a webhook. +/// +public struct WebhookInfo +{ + internal WebhookInfo(Model model) + { + Name = model.Name; + ChannelId = model.ChannelId; + Avatar = model.AvatarHash; + } + + /// + /// Gets the name of this webhook. + /// + /// + /// A string containing the name of this webhook. + /// + public string Name { get; } + + /// + /// Gets the ID of the channel that this webhook sends to. + /// + /// + /// A representing the snowflake identifier of the channel that this webhook can send + /// to. + /// + public ulong? ChannelId { get; } + + /// + /// Gets the hash value of this webhook's avatar. + /// + /// + /// A string containing the hash of this webhook's avatar. + /// + public string Avatar { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookUpdateAuditLogData.cs b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookUpdateAuditLogData.cs new file mode 100644 index 0000000..f54b78c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/DataTypes/WebhookUpdateAuditLogData.cs @@ -0,0 +1,55 @@ +using Discord.API.AuditLogs; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest; + +/// +/// Contains a piece of audit log data related to a webhook update. +/// +public class WebhookUpdateAuditLogData : IAuditLogData +{ + private WebhookUpdateAuditLogData(IWebhook webhook, WebhookInfo before, WebhookInfo after) + { + Webhook = webhook; + Before = before; + After = after; + } + + internal static WebhookUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry, Model log) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var webhookInfo = log.Webhooks?.FirstOrDefault(x => x.Id == entry.TargetId); + var webhook = webhookInfo != null ? RestWebhook.Create(discord, (IGuild)null, webhookInfo) : null; + + return new WebhookUpdateAuditLogData(webhook, new(before), new(after)); + } + + /// + /// Gets the webhook that was updated. + /// + /// + /// A webhook object representing the webhook that was updated. + /// + public IWebhook Webhook { get; } + + /// + /// Gets the webhook information before the changes. + /// + /// + /// A webhook information object representing the webhook before the changes were made. + /// + public WebhookInfo Before { get; } + + /// + /// Gets the webhook information after the changes. + /// + /// + /// A webhook information object representing the webhook after the changes were made. + /// + public WebhookInfo After { get; } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/JsonFieldAttribute.cs b/src/Discord.Net.Rest/Entities/AuditLogs/JsonFieldAttribute.cs new file mode 100644 index 0000000..0f83814 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/JsonFieldAttribute.cs @@ -0,0 +1,14 @@ +using System; + +namespace Discord.Rest; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +internal class JsonFieldAttribute : Attribute +{ + public string FieldName { get; } + + public JsonFieldAttribute(string fieldName) + { + FieldName = fieldName; + } +} diff --git a/src/Discord.Net.Rest/Entities/AuditLogs/RestAuditLogEntry.cs b/src/Discord.Net.Rest/Entities/AuditLogs/RestAuditLogEntry.cs new file mode 100644 index 0000000..88ab517 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/AuditLogs/RestAuditLogEntry.cs @@ -0,0 +1,43 @@ +using System; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based audit log entry. + /// + public class RestAuditLogEntry : RestEntity, IAuditLogEntry + { + private RestAuditLogEntry(BaseDiscordClient discord, Model fullLog, EntryModel model, IUser user) + : base(discord, model.Id) + { + Action = model.Action; + Data = AuditLogHelper.CreateData(discord, model, fullLog); + User = user; + Reason = model.Reason; + } + + internal static RestAuditLogEntry Create(BaseDiscordClient discord, Model fullLog, EntryModel model) + { + var userInfo = model.UserId != null ? fullLog.Users.FirstOrDefault(x => x.Id == model.UserId) : null; + IUser user = null; + if (userInfo != null) + user = RestUser.Create(discord, userInfo); + + return new RestAuditLogEntry(discord, fullLog, model, user); + } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public ActionType Action { get; } + /// + public IAuditLogData Data { get; } + /// + public IUser User { get; } + /// + public string Reason { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs new file mode 100644 index 0000000..f09616d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs @@ -0,0 +1,649 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; +using StageInstance = Discord.API.StageInstance; + +namespace Discord.Rest +{ + internal static class ChannelHelper + { + #region General + public static Task DeleteAsync(IChannel channel, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.DeleteChannelAsync(channel.Id, options); + + public static Task ModifyAsync(IGuildChannel channel, BaseDiscordClient client, + Action func, + RequestOptions options) + { + var args = new GuildChannelProperties(); + func(args); + var apiArgs = new API.Rest.ModifyGuildChannelParams + { + Name = args.Name, + Position = args.Position, + CategoryId = args.CategoryId, + Overwrites = args.PermissionOverwrites.IsSpecified + ? args.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + Flags = args.Flags.GetValueOrDefault(), + }; + return client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options); + } + + public static Task ModifyAsync(ITextChannel channel, BaseDiscordClient client, + Action func, + RequestOptions options) + { + var args = new TextChannelProperties(); + func(args); + var apiArgs = new API.Rest.ModifyTextChannelParams + { + Type = args.ChannelType, + Name = args.Name, + Position = args.Position, + CategoryId = args.CategoryId, + Topic = args.Topic, + IsNsfw = args.IsNsfw, + SlowModeInterval = args.SlowModeInterval, + Overwrites = args.PermissionOverwrites.IsSpecified + ? args.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + DefaultSlowModeInterval = args.DefaultSlowModeInterval + }; + return client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options); + } + + public static Task ModifyAsync(IVoiceChannel channel, BaseDiscordClient client, + Action func, + RequestOptions options) + { + var args = new VoiceChannelProperties(); + func(args); + var apiArgs = new API.Rest.ModifyVoiceChannelParams + { + Bitrate = args.Bitrate, + Name = args.Name, + RTCRegion = args.RTCRegion, + Position = args.Position, + CategoryId = args.CategoryId, + UserLimit = args.UserLimit.IsSpecified ? (args.UserLimit.Value ?? 0) : Optional.Create(), + Overwrites = args.PermissionOverwrites.IsSpecified + ? args.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + SlowModeInterval = args.SlowModeInterval, + IsNsfw = args.IsNsfw, + }; + return client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options); + } + + public static Task ModifyAsync(IStageChannel channel, BaseDiscordClient client, + Action func, RequestOptions options = null) + { + var args = new StageInstanceProperties(); + func(args); + + var apiArgs = new ModifyStageInstanceParams() + { + PrivacyLevel = args.PrivacyLevel, + Topic = args.Topic + }; + + return client.ApiClient.ModifyStageInstanceAsync(channel.Id, apiArgs, options); + } + #endregion + + #region Invites + public static async Task> GetInvitesAsync(IGuildChannel channel, BaseDiscordClient client, + RequestOptions options) + { + var models = await client.ApiClient.GetChannelInvitesAsync(channel.Id, options).ConfigureAwait(false); + return models.Select(x => RestInviteMetadata.Create(client, null, channel, x)).ToImmutableArray(); + } + /// + /// may not be equal to zero. + /// -and- + /// and must be greater than zero. + /// -and- + /// must be lesser than 86400. + /// + public static async Task CreateInviteAsync(IGuildChannel channel, BaseDiscordClient client, + int? maxAge, int? maxUses, bool isTemporary, bool isUnique, RequestOptions options) + { + var args = new API.Rest.CreateChannelInviteParams + { + IsTemporary = isTemporary, + IsUnique = isUnique, + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0 + }; + var model = await client.ApiClient.CreateChannelInviteAsync(channel.Id, args, options).ConfigureAwait(false); + return RestInviteMetadata.Create(client, null, channel, model); + } + + /// + /// may not be equal to zero. + /// -and- + /// and must be greater than zero. + /// -and- + /// must be lesser than 86400. + /// + public static async Task CreateInviteToStreamAsync(IGuildChannel channel, BaseDiscordClient client, + int? maxAge, int? maxUses, bool isTemporary, bool isUnique, IUser user, + RequestOptions options) + { + var args = new API.Rest.CreateChannelInviteParams + { + IsTemporary = isTemporary, + IsUnique = isUnique, + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0, + TargetType = TargetUserType.Stream, + TargetUserId = user.Id + }; + var model = await client.ApiClient.CreateChannelInviteAsync(channel.Id, args, options).ConfigureAwait(false); + return RestInviteMetadata.Create(client, null, channel, model); + } + + /// + /// may not be equal to zero. + /// -and- + /// and must be greater than zero. + /// -and- + /// must be lesser than 86400. + /// + public static async Task CreateInviteToApplicationAsync(IGuildChannel channel, BaseDiscordClient client, + int? maxAge, int? maxUses, bool isTemporary, bool isUnique, ulong applicationId, + RequestOptions options) + { + var args = new API.Rest.CreateChannelInviteParams + { + IsTemporary = isTemporary, + IsUnique = isUnique, + MaxAge = maxAge ?? 0, + MaxUses = maxUses ?? 0, + TargetType = TargetUserType.EmbeddedApplication, + TargetApplicationId = applicationId + }; + var model = await client.ApiClient.CreateChannelInviteAsync(channel.Id, args, options).ConfigureAwait(false); + return RestInviteMetadata.Create(client, null, channel, model); + } + #endregion + + #region Messages + public static async Task GetMessageAsync(IMessageChannel channel, BaseDiscordClient client, + ulong id, RequestOptions options) + { + var guildId = (channel as IGuildChannel)?.GuildId; + var guild = guildId != null ? await (client as IDiscordClient).GetGuildAsync(guildId.Value, CacheMode.CacheOnly).ConfigureAwait(false) : null; + var model = await client.ApiClient.GetChannelMessageAsync(channel.Id, id, options).ConfigureAwait(false); + if (model == null) + return null; + var author = MessageHelper.GetAuthor(client, guild, model.Author.Value, model.WebhookId.ToNullable()); + return RestMessage.Create(client, channel, author, model); + } + public static IAsyncEnumerable> GetMessagesAsync(IMessageChannel channel, BaseDiscordClient client, + ulong? fromMessageId, Direction dir, int limit, RequestOptions options) + { + var guildId = (channel as IGuildChannel)?.GuildId; + var guild = guildId != null ? (client as IDiscordClient).GetGuildAsync(guildId.Value, CacheMode.CacheOnly).Result : null; + + if (dir == Direction.Around && limit > DiscordConfig.MaxMessagesPerBatch) + { + int around = limit / 2; + if (fromMessageId.HasValue) + return GetMessagesAsync(channel, client, fromMessageId.Value + 1, Direction.Before, around + 1, options) //Need to include the message itself + .Concat(GetMessagesAsync(channel, client, fromMessageId, Direction.After, around, options)); + else //Shouldn't happen since there's no public overload for ulong? and Direction + return GetMessagesAsync(channel, client, null, Direction.Before, around + 1, options); + } + + return new PagedAsyncEnumerable( + DiscordConfig.MaxMessagesPerBatch, + async (info, ct) => + { + var args = new GetChannelMessagesParams + { + RelativeDirection = dir, + Limit = info.PageSize + }; + if (info.Position != null) + args.RelativeMessageId = info.Position.Value; + + var models = await client.ApiClient.GetChannelMessagesAsync(channel.Id, args, options).ConfigureAwait(false); + var builder = ImmutableArray.CreateBuilder(); + foreach (var model in models) + { + var author = MessageHelper.GetAuthor(client, guild, model.Author.Value, model.WebhookId.ToNullable()); + builder.Add(RestMessage.Create(client, channel, author, model)); + } + return builder.ToImmutable(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxMessagesPerBatch) + return false; + if (dir == Direction.Before) + info.Position = lastPage.Min(x => x.Id); + else + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: fromMessageId, + count: limit + ); + } + public static async Task> GetPinnedMessagesAsync(IMessageChannel channel, BaseDiscordClient client, + RequestOptions options) + { + var guildId = (channel as IGuildChannel)?.GuildId; + var guild = guildId != null ? await (client as IDiscordClient).GetGuildAsync(guildId.Value, CacheMode.CacheOnly).ConfigureAwait(false) : null; + var models = await client.ApiClient.GetPinsAsync(channel.Id, options).ConfigureAwait(false); + var builder = ImmutableArray.CreateBuilder(); + foreach (var model in models) + { + var author = MessageHelper.GetAuthor(client, guild, model.Author.Value, model.WebhookId.ToNullable()); + builder.Add(RestMessage.Create(client, channel, author, model)); + } + return builder.ToImmutable(); + } + + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public static async Task SendMessageAsync(IMessageChannel channel, BaseDiscordClient client, + string text, bool isTTS, Embed embed, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, RequestOptions options, Embed[] embeds, MessageFlags flags) + { + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(embeds), $"A max of {DiscordConfig.MaxEmbedsPerMessage} Embeds are allowed."); + + Preconditions.MessageAtLeastOneOf(text, components, embeds, stickers); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + if (stickers != null) + { + Preconditions.AtMost(stickers.Length, 3, nameof(stickers), "A max of 3 stickers are allowed."); + } + + if (flags is not MessageFlags.None and not MessageFlags.SuppressEmbeds and not MessageFlags.SuppressNotification) + throw new ArgumentException("The only valid MessageFlags are SuppressEmbeds and none.", nameof(flags)); + + var args = new CreateMessageParams + { + Content = text, + IsTTS = isTTS, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel(), + MessageReference = messageReference?.ToModel(), + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Stickers = stickers?.Any() ?? false ? stickers.Select(x => x.Id).ToArray() : Optional.Unspecified, + Flags = flags + }; + var model = await client.ApiClient.CreateMessageAsync(channel.Id, args, options).ConfigureAwait(false); + return RestUserMessage.Create(client, channel, client.CurrentUser, model); + } + + /// + /// is a zero-length string, contains only white space, or contains one or more + /// invalid characters as defined by . + /// + /// + /// is . + /// + /// + /// The specified path, file name, or both exceed the system-defined maximum length. For example, on + /// Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 + /// characters. + /// + /// + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// + /// + /// specified a directory.-or- The caller does not have the required permission. + /// + /// + /// The file specified in was not found. + /// + /// is in an invalid format. + /// An I/O error occurred while opening the file. + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public static async Task SendFileAsync(IMessageChannel channel, BaseDiscordClient client, + string filePath, string text, bool isTTS, Embed embed, AllowedMentions allowedMentions, + MessageReference messageReference, MessageComponent components, ISticker[] stickers, RequestOptions options, + bool isSpoiler, Embed[] embeds, MessageFlags flags = MessageFlags.None) + { + string filename = Path.GetFileName(filePath); + using (var file = File.OpenRead(filePath)) + return await SendFileAsync(channel, client, file, filename, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, isSpoiler, embeds, flags).ConfigureAwait(false); + } + + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public static async Task SendFileAsync(IMessageChannel channel, BaseDiscordClient client, + Stream stream, string filename, string text, bool isTTS, Embed embed, AllowedMentions allowedMentions, + MessageReference messageReference, MessageComponent components, ISticker[] stickers, RequestOptions options, + bool isSpoiler, Embed[] embeds, MessageFlags flags = MessageFlags.None) + { + using (var file = new FileAttachment(stream, filename, isSpoiler: isSpoiler)) + return await SendFileAsync(channel, client, file, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags).ConfigureAwait(false); + } + + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public static Task SendFileAsync(IMessageChannel channel, BaseDiscordClient client, + FileAttachment attachment, string text, bool isTTS, Embed embed, AllowedMentions allowedMentions, + MessageReference messageReference, MessageComponent components, ISticker[] stickers, RequestOptions options, + Embed[] embeds, MessageFlags flags = MessageFlags.None) + => SendFilesAsync(channel, client, new[] { attachment }, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// The only valid are , and . + public static async Task SendFilesAsync(IMessageChannel channel, BaseDiscordClient client, + IEnumerable attachments, string text, bool isTTS, Embed embed, AllowedMentions allowedMentions, + MessageReference messageReference, MessageComponent components, ISticker[] stickers, RequestOptions options, + Embed[] embeds, MessageFlags flags) + { + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, DiscordConfig.MaxEmbedsPerMessage, nameof(embeds), $"A max of {DiscordConfig.MaxEmbedsPerMessage} Embeds are allowed."); + + Preconditions.MessageAtLeastOneOf(text, components, embeds, stickers, attachments); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + if (channel is ITextChannel guildTextChannel) + { + ulong contentSize = (ulong)attachments.Where(x => x.Stream.CanSeek).Sum(x => x.Stream.Length); + + if (contentSize > guildTextChannel.Guild.MaxUploadLimit) + { + throw new ArgumentOutOfRangeException(nameof(attachments), $"Collective file size exceeds the max file size of {guildTextChannel.Guild.MaxUploadLimit} bytes in that guild!"); + } + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + if (flags is not MessageFlags.None and not MessageFlags.SuppressEmbeds and not MessageFlags.SuppressNotification) + throw new ArgumentException("The only valid MessageFlags are SuppressEmbeds, SuppressNotification and none.", nameof(flags)); + + if (stickers != null) + { + Preconditions.AtMost(stickers.Length, 3, nameof(stickers), "A max of 3 stickers are allowed."); + } + + var args = new UploadFileParams(attachments.ToArray()) + { + Content = text, + IsTTS = isTTS, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + MessageReference = messageReference?.ToModel() ?? Optional.Unspecified, + MessageComponent = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Stickers = stickers?.Any() ?? false ? stickers.Select(x => x.Id).ToArray() : Optional.Unspecified, + Flags = flags + }; + + var model = await client.ApiClient.UploadFileAsync(channel.Id, args, options).ConfigureAwait(false); + return RestUserMessage.Create(client, channel, client.CurrentUser, model); + } + + public static async Task ModifyMessageAsync(IMessageChannel channel, ulong messageId, Action func, + BaseDiscordClient client, RequestOptions options) + { + var msgModel = await MessageHelper.ModifyAsync(channel.Id, messageId, client, func, options).ConfigureAwait(false); + return RestUserMessage.Create(client, channel, msgModel.Author.IsSpecified ? RestUser.Create(client, msgModel.Author.Value) : client.CurrentUser, msgModel); + } + + public static Task DeleteMessageAsync(IMessageChannel channel, ulong messageId, BaseDiscordClient client, + RequestOptions options) + => MessageHelper.DeleteAsync(channel.Id, messageId, client, options); + + public static async Task DeleteMessagesAsync(ITextChannel channel, BaseDiscordClient client, + IEnumerable messageIds, RequestOptions options) + { + const int BATCH_SIZE = 100; + + var msgs = messageIds.ToArray(); + int batches = msgs.Length / BATCH_SIZE; + for (int i = 0; i <= batches; i++) + { + ArraySegment batch; + if (i < batches) + { + batch = new ArraySegment(msgs, i * BATCH_SIZE, BATCH_SIZE); + } + else + { + batch = new ArraySegment(msgs, i * BATCH_SIZE, msgs.Length - batches * BATCH_SIZE); + if (batch.Count == 0) + { + break; + } + } + var args = new DeleteMessagesParams(batch.ToArray()); + await client.ApiClient.DeleteMessagesAsync(channel.Id, args, options).ConfigureAwait(false); + } + } + #endregion + + #region Permission Overwrites + public static Task AddPermissionOverwriteAsync(IGuildChannel channel, BaseDiscordClient client, + IUser user, OverwritePermissions perms, RequestOptions options) + { + var args = new ModifyChannelPermissionsParams((int)PermissionTarget.User, perms.AllowValue.ToString(), perms.DenyValue.ToString()); + return client.ApiClient.ModifyChannelPermissionsAsync(channel.Id, user.Id, args, options); + } + + public static Task AddPermissionOverwriteAsync(IGuildChannel channel, BaseDiscordClient client, + IRole role, OverwritePermissions perms, RequestOptions options) + { + var args = new ModifyChannelPermissionsParams((int)PermissionTarget.Role, perms.AllowValue.ToString(), perms.DenyValue.ToString()); + return client.ApiClient.ModifyChannelPermissionsAsync(channel.Id, role.Id, args, options); + } + + public static Task RemovePermissionOverwriteAsync(IGuildChannel channel, BaseDiscordClient client, + IUser user, RequestOptions options) + => client.ApiClient.DeleteChannelPermissionAsync(channel.Id, user.Id, options); + + public static Task RemovePermissionOverwriteAsync(IGuildChannel channel, BaseDiscordClient client, + IRole role, RequestOptions options) + => client.ApiClient.DeleteChannelPermissionAsync(channel.Id, role.Id, options); + #endregion + + #region Users + /// Resolving permissions requires the parent guild to be downloaded. + public static async Task GetUserAsync(IGuildChannel channel, IGuild guild, BaseDiscordClient client, + ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetGuildMemberAsync(channel.GuildId, id, options).ConfigureAwait(false); + if (model == null) + return null; + var user = RestGuildUser.Create(client, guild, model); + if (!user.GetPermissions(channel).ViewChannel) + return null; + + return user; + } + /// Resolving permissions requires the parent guild to be downloaded. + public static IAsyncEnumerable> GetUsersAsync(IGuildChannel channel, IGuild guild, BaseDiscordClient client, + ulong? fromUserId, int? limit, RequestOptions options) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxUsersPerBatch, + async (info, ct) => + { + var args = new GetGuildMembersParams + { + Limit = info.PageSize + }; + if (info.Position != null) + args.AfterUserId = info.Position.Value; + var models = await client.ApiClient.GetGuildMembersAsync(guild.Id, args, options).ConfigureAwait(false); + return models + .Select(x => RestGuildUser.Create(client, guild, x)) + .Where(x => x.GetPermissions(channel).ViewChannel) + .ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxMessagesPerBatch) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: fromUserId, + count: limit + ); + } + #endregion + + #region Typing + public static Task TriggerTypingAsync(IMessageChannel channel, BaseDiscordClient client, RequestOptions options = null) + => client.ApiClient.TriggerTypingIndicatorAsync(channel.Id, options); + + public static IDisposable EnterTypingState(IMessageChannel channel, BaseDiscordClient client, + RequestOptions options) + => new TypingNotifier(channel, options); + #endregion + + #region Webhooks + public static async Task CreateWebhookAsync(IIntegrationChannel channel, BaseDiscordClient client, string name, Stream avatar, RequestOptions options) + { + var args = new CreateWebhookParams { Name = name }; + if (avatar != null) + args.Avatar = new API.Image(avatar); + + var model = await client.ApiClient.CreateWebhookAsync(channel.Id, args, options).ConfigureAwait(false); + return RestWebhook.Create(client, channel, model); + } + public static async Task GetWebhookAsync(IIntegrationChannel channel, BaseDiscordClient client, ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetWebhookAsync(id, options: options).ConfigureAwait(false); + if (model == null) + return null; + return RestWebhook.Create(client, channel, model); + } + public static async Task> GetWebhooksAsync(IIntegrationChannel channel, BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetChannelWebhooksAsync(channel.Id, options).ConfigureAwait(false); + return models.Select(x => RestWebhook.Create(client, channel, x)) + .ToImmutableArray(); + } + + public static async Task FollowAnnouncementChannelAsync(INewsChannel newsChannel, ulong channelId, BaseDiscordClient client, RequestOptions options) + { + var model = await client.ApiClient.FollowChannelAsync(newsChannel.Id, channelId, options); + return model.WebhookId; + } + #endregion + + #region Categories + public static async Task GetCategoryAsync(INestedChannel channel, BaseDiscordClient client, RequestOptions options) + { + // if no category id specified, return null + if (!channel.CategoryId.HasValue) + return null; + // CategoryId will contain a value here + var model = await client.ApiClient.GetChannelAsync(channel.CategoryId.Value, options).ConfigureAwait(false); + return RestCategoryChannel.Create(client, model) as ICategoryChannel; + } + /// This channel does not have a parent channel. + public static async Task SyncPermissionsAsync(INestedChannel channel, BaseDiscordClient client, RequestOptions options) + { + var category = await GetCategoryAsync(channel, client, options).ConfigureAwait(false); + if (category == null) + throw new InvalidOperationException("This channel does not have a parent channel."); + + var apiArgs = new ModifyGuildChannelParams + { + Overwrites = category.PermissionOverwrites + .Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + }; + await client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options).ConfigureAwait(false); + } + #endregion + + #region Voice + + public static async Task ModifyVoiceChannelStatusAsync(IVoiceChannel channel, string status, BaseDiscordClient client, RequestOptions options) + { + Preconditions.AtMost(status.Length, DiscordConfig.MaxVoiceChannelStatusLength, $"Voice channel status length must be less than {DiscordConfig.MaxVoiceChannelStatusLength}."); + + await client.ApiClient.ModifyVoiceChannelStatusAsync(channel.Id, status, options).ConfigureAwait(false); + } + + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/ForumHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ForumHelper.cs new file mode 100644 index 0000000..1267fed --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/ForumHelper.cs @@ -0,0 +1,64 @@ +using Discord.API; +using System; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest; + +internal static class ForumHelper +{ + public static Task ModifyAsync(IForumChannel channel, BaseDiscordClient client, + Action func, + RequestOptions options) + { + var args = new ForumChannelProperties(); + func(args); + + Preconditions.AtMost(args.Tags.IsSpecified ? args.Tags.Value.Count() : 0, 20, nameof(args.Tags), "Forum channel can have max 20 tags."); + + var apiArgs = new API.Rest.ModifyForumChannelParams() + { + Name = args.Name, + Position = args.Position, + CategoryId = args.CategoryId, + Overwrites = args.PermissionOverwrites.IsSpecified + ? args.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + DefaultSlowModeInterval = args.DefaultSlowModeInterval, + ThreadCreationInterval = args.ThreadCreationInterval, + Tags = args.Tags.IsSpecified + ? args.Tags.Value.Select(tag => new API.ModifyForumTagParams + { + Id = tag.Id ?? Optional.Unspecified, + Name = tag.Name, + EmojiId = tag.Emoji is Emote emote + ? emote.Id + : Optional.Unspecified, + EmojiName = tag.Emoji is Emoji emoji + ? emoji.Name + : Optional.Unspecified + }).ToArray() + : Optional.Create(), + Flags = args.Flags.GetValueOrDefault(), + Topic = args.Topic, + DefaultReactionEmoji = args.DefaultReactionEmoji.IsSpecified + ? new API.ModifyForumReactionEmojiParams + { + EmojiId = args.DefaultReactionEmoji.Value is Emote emote ? + emote.Id : Optional.Unspecified, + EmojiName = args.DefaultReactionEmoji.Value is Emoji emoji ? + emoji.Name : Optional.Unspecified + } + : Optional.Unspecified, + DefaultSortOrder = args.DefaultSortOrder + }; + return client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options); + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/IRestAudioChannel.cs b/src/Discord.Net.Rest/Entities/Channels/IRestAudioChannel.cs new file mode 100644 index 0000000..01eca76 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/IRestAudioChannel.cs @@ -0,0 +1,6 @@ +namespace Discord.Rest +{ + public interface IRestAudioChannel : IAudioChannel + { + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs b/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs new file mode 100644 index 0000000..2f80546 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/IRestMessageChannel.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based channel that can send and receive messages. + /// + public interface IRestMessageChannel : IMessageChannel + { + /// + new Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + /// Gets a message from this message channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The snowflake identifier of the message. + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the message. The task result contains + /// the retrieved message; if no message is found with the specified identifier. + /// + Task GetMessageAsync(ulong id, RequestOptions options = null); + /// + /// Gets the last N messages from this message channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The ID of the starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null); + /// + /// Gets a collection of pinned messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation for retrieving pinned messages in this channel. + /// The task result contains a collection of messages found in the pinned messages. + /// + new Task> GetPinnedMessagesAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/IRestPrivateChannel.cs b/src/Discord.Net.Rest/Entities/Channels/IRestPrivateChannel.cs new file mode 100644 index 0000000..f387ac2 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/IRestPrivateChannel.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based channel that is private to select recipients. + /// + public interface IRestPrivateChannel : IPrivateChannel + { + /// + /// Users that can access this channel. + /// + new IReadOnlyCollection Recipients { get; } + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestCategoryChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestCategoryChannel.cs new file mode 100644 index 0000000..8077eb9 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestCategoryChannel.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based category channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestCategoryChannel : RestGuildChannel, ICategoryChannel + { + #region RestCategoryChannel + internal RestCategoryChannel(BaseDiscordClient discord, IGuild guild, ulong id, ulong guildId) + : base(discord, guild, id, guildId) + { + } + internal new static RestCategoryChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestCategoryChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value); + entity.Update(model); + return entity; + } + + private string DebuggerDisplay => $"{Name} ({Id}, Category)"; + #endregion + + #region IChannel + /// + /// This method is not supported with category channels. + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => throw new NotSupportedException(); + /// + /// This method is not supported with category channels. + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => throw new NotSupportedException(); + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestChannel.cs new file mode 100644 index 0000000..bbf52a4 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestChannel.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a generic REST-based channel. + /// + public class RestChannel : RestEntity, IChannel, IUpdateable + { + #region RestChannel + /// + public virtual DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + internal RestChannel(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + /// Unexpected channel type. + internal static RestChannel Create(BaseDiscordClient discord, Model model) + { + return model.Type switch + { + ChannelType.News or + ChannelType.Text or + ChannelType.Voice or + ChannelType.Stage or + ChannelType.NewsThread or + ChannelType.PrivateThread or + ChannelType.PublicThread or + ChannelType.Forum + => RestGuildChannel.Create(discord, new RestGuild(discord, model.GuildId.Value), model), + ChannelType.DM or ChannelType.Group => CreatePrivate(discord, model) as RestChannel, + ChannelType.Category => RestCategoryChannel.Create(discord, new RestGuild(discord, model.GuildId.Value), model), + _ => new RestChannel(discord, model.Id), + }; + } + + internal static RestChannel Create(BaseDiscordClient discord, Model model, IGuild guild) + { + return model.Type switch + { + ChannelType.News or + ChannelType.Text or + ChannelType.Voice or + ChannelType.Stage or + ChannelType.NewsThread or + ChannelType.PrivateThread or + ChannelType.PublicThread or + ChannelType.Forum or + ChannelType.Media + => RestGuildChannel.Create(discord, guild, model), + ChannelType.DM or ChannelType.Group => CreatePrivate(discord, model) as RestChannel, + ChannelType.Category => RestCategoryChannel.Create(discord, guild, model), + _ => new RestChannel(discord, model.Id), + }; + } + /// Unexpected channel type. + internal static IRestPrivateChannel CreatePrivate(BaseDiscordClient discord, Model model) + { + return model.Type switch + { + ChannelType.DM => RestDMChannel.Create(discord, model), + ChannelType.Group => RestGroupChannel.Create(discord, model), + _ => throw new InvalidOperationException($"Unexpected channel type: {model.Type}"), + }; + } + internal virtual void Update(Model model) { } + + /// + public virtual Task UpdateAsync(RequestOptions options = null) => Task.Delay(0); + #endregion + + #region IChannel + /// + string IChannel.Name => null; + + /// + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); //Overridden + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => AsyncEnumerable.Empty>(); //Overridden + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs new file mode 100644 index 0000000..8d9af24 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestDMChannel.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based direct-message channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestDMChannel : RestChannel, IDMChannel, IRestPrivateChannel, IRestMessageChannel + { + #region RestDMChannel + /// + /// Gets the current logged-in user. + /// + public RestUser CurrentUser { get; } + + /// + /// Gets the recipient of the channel. + /// + public RestUser Recipient { get; } + + /// + /// Gets a collection that is the current logged-in user and the recipient. + /// + public IReadOnlyCollection Users => Recipient is null + ? ImmutableArray.Empty + : ImmutableArray.Create(CurrentUser, Recipient); + + internal RestDMChannel(BaseDiscordClient discord, ulong id, ulong? recipientId) + : base(discord, id) + { + Recipient = recipientId.HasValue ? new RestUser(Discord, recipientId.Value) : null; + CurrentUser = new RestUser(Discord, discord.CurrentUser.Id); + } + internal new static RestDMChannel Create(BaseDiscordClient discord, Model model) + { + var entity = new RestDMChannel(discord, model.Id, model.Recipients.GetValueOrDefault(null)?[0].Id); + entity.Update(model); + return entity; + } + internal override void Update(Model model) + { + if(model.Recipients.IsSpecified) + Recipient?.Update(model.Recipients.Value[0]); + } + + /// + public override async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetChannelAsync(Id, options).ConfigureAwait(false); + Update(model); + } + /// + public Task CloseAsync(RequestOptions options = null) + => ChannelHelper.DeleteAsync(this, Discord, options); + + + /// + /// Gets a user in this channel from the provided . + /// + /// The snowflake identifier of the user. + /// + /// A object that is a recipient of this channel; otherwise . + /// + public RestUser GetUser(ulong id) + { + if (id == Recipient?.Id) + return Recipient; + else if (id == Discord.CurrentUser.Id) + return CurrentUser; + else + return null; + } + + /// + public Task GetMessageAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetMessageAsync(this, Discord, id, options); + /// + public IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, null, Direction.Before, limit, options); + /// + public IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessageId, dir, limit, options); + /// + public IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessage.Id, dir, limit, options); + /// + public Task> GetPinnedMessagesAsync(RequestOptions options = null) + => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// + /// is a zero-length string, contains only white space, or contains one or more + /// invalid characters as defined by . + /// + /// + /// is . + /// + /// + /// The specified path, file name, or both exceed the system-defined maximum length. For example, on + /// Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 + /// characters. + /// + /// + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// + /// + /// specified a directory.-or- The caller does not have the required permission. + /// + /// + /// The file specified in was not found. + /// + /// is in an invalid format. + /// An I/O error occurred while opening the file. + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, isSpoiler, embeds, flags); + + /// + /// Message content is too long, length must be less or equal to . + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, attachment, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// Message content is too long, length must be less or equal to . + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFilesAsync(this, Discord, attachments, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); + /// + public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); + + /// + public async Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + => await ChannelHelper.ModifyMessageAsync(this, messageId, func, Discord, options).ConfigureAwait(false); + + /// + public Task TriggerTypingAsync(RequestOptions options = null) + => ChannelHelper.TriggerTypingAsync(this, Discord, options); + /// + public IDisposable EnterTypingState(RequestOptions options = null) + => ChannelHelper.EnterTypingState(this, Discord, options); + + /// + /// Gets a string that represents the Username#Discriminator of the recipient. + /// + /// + /// A string that resolves to the Recipient of this channel. + /// + public override string ToString() => $"@{Recipient}"; + private string DebuggerDisplay => $"@{Recipient} ({Id}, DM)"; + #endregion + + #region IDMChannel + /// + IUser IDMChannel.Recipient => Recipient; + #endregion + + #region IRestPrivateChannel + /// + IReadOnlyCollection IRestPrivateChannel.Recipients => ImmutableArray.Create(Recipient); + #endregion + + #region IPrivateChannel + /// + IReadOnlyCollection IPrivateChannel.Recipients => ImmutableArray.Create(Recipient); + #endregion + + #region IMessageChannel + /// + async Task IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetMessageAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(limit, options); + else + return AsyncEnumerable.Empty>(); + } + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(ulong fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(fromMessageId, dir, limit, options); + else + return AsyncEnumerable.Empty>(); + } + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(IMessage fromMessage, Direction dir, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(fromMessage, dir, limit, options); + else + return AsyncEnumerable.Empty>(); + } + /// + async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) + => await GetPinnedMessagesAsync(options).ConfigureAwait(false); + /// + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, + RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, + Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(FileAttachment attachment, string text, bool isTTS, + Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFilesAsync(IEnumerable attachments, string text, + bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, + AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, + ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + #endregion + + #region IChannel + /// + string IChannel.Name => $"@{Recipient}"; + + /// + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetUser(id)); + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => ImmutableArray.Create>(Users).ToAsyncEnumerable(); + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestForumChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestForumChannel.cs new file mode 100644 index 0000000..1ea32c0 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestForumChannel.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based forum channel in a guild. + /// + public class RestForumChannel : RestGuildChannel, IForumChannel + { + /// + public bool IsNsfw { get; private set; } + + /// + public string Topic { get; private set; } + + /// + public ThreadArchiveDuration DefaultAutoArchiveDuration { get; private set; } + + /// + public IReadOnlyCollection Tags { get; private set; } + + /// + public int ThreadCreationInterval { get; private set; } + + /// + public int DefaultSlowModeInterval { get; private set; } + + /// + public ulong? CategoryId { get; private set; } + + /// + public IEmote DefaultReactionEmoji { get; private set; } + + /// + public ForumSortOrder? DefaultSortOrder { get; private set; } + + /// + public ForumLayout DefaultLayout { get; private set; } + + /// + public string Mention => MentionUtils.MentionChannel(Id); + + internal RestForumChannel(BaseDiscordClient client, IGuild guild, ulong id, ulong guildId) + : base(client, guild, id, guildId) + { + + } + + internal new static RestForumChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestForumChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value); + entity.Update(model); + return entity; + } + + internal override void Update(Model model) + { + base.Update(model); + IsNsfw = model.Nsfw.GetValueOrDefault(false); + Topic = model.Topic.GetValueOrDefault(); + DefaultAutoArchiveDuration = model.AutoArchiveDuration.GetValueOrDefault(ThreadArchiveDuration.OneDay); + + if (model.ThreadRateLimitPerUser.IsSpecified) + DefaultSlowModeInterval = model.ThreadRateLimitPerUser.Value; + + if (model.SlowMode.IsSpecified) + ThreadCreationInterval = model.SlowMode.Value; + + DefaultSortOrder = model.DefaultSortOrder.GetValueOrDefault(); + + Tags = model.ForumTags.GetValueOrDefault(Array.Empty()).Select( + x => new ForumTag(x.Id, x.Name, x.EmojiId.GetValueOrDefault(null), x.EmojiName.GetValueOrDefault(), x.Moderated) + ).ToImmutableArray(); + + if (model.DefaultReactionEmoji.IsSpecified && model.DefaultReactionEmoji.Value is not null) + { + if (model.DefaultReactionEmoji.Value.EmojiId.HasValue && model.DefaultReactionEmoji.Value.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultReactionEmoji.Value.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultReactionEmoji.Value.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultReactionEmoji.Value.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + + CategoryId = model.CategoryId.GetValueOrDefault(); + DefaultLayout = model.DefaultForumLayout.GetValueOrDefault(); + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await ForumHelper.ModifyAsync(this, Discord, func, options); + Update(model); + } + + /// + public Task CreatePostAsync(string title, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, int? slowmode = null, + string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + => ThreadHelper.CreatePostAsync(this, Discord, title, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()); + + /// + public async Task CreatePostWithFileAsync(string title, string filePath, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, + ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + { + using var file = new FileAttachment(filePath, isSpoiler: isSpoiler); + return await ThreadHelper.CreatePostAsync(this, Discord, title, new FileAttachment[] { file }, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()).ConfigureAwait(false); + } + + /// + public async Task CreatePostWithFileAsync(string title, Stream stream, string filename, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, + ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + { + using var file = new FileAttachment(stream, filename, isSpoiler: isSpoiler); + return await ThreadHelper.CreatePostAsync(this, Discord, title, new FileAttachment[] { file }, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()).ConfigureAwait(false); + } + + /// + public Task CreatePostWithFileAsync(string title, FileAttachment attachment, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + => ThreadHelper.CreatePostAsync(this, Discord, title, new FileAttachment[] { attachment }, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()); + + /// + public Task CreatePostWithFilesAsync(string title, IEnumerable attachments, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + => ThreadHelper.CreatePostAsync(this, Discord, title, attachments, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()); + + /// + public Task> GetActiveThreadsAsync(RequestOptions options = null) + => ThreadHelper.GetActiveThreadsAsync(Guild, Id, Discord, options); + + /// + public Task> GetJoinedPrivateArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null) + => ThreadHelper.GetJoinedPrivateArchivedThreadsAsync(this, Discord, limit, before, options); + + /// + public Task> GetPrivateArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null) + => ThreadHelper.GetPrivateArchivedThreadsAsync(this, Discord, limit, before, options); + + /// + public Task> GetPublicArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null) + => ThreadHelper.GetPublicArchivedThreadsAsync(this, Discord, limit, before, options); + + /// + public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + => ChannelHelper.CreateWebhookAsync(this, Discord, name, avatar, options); + + /// + public Task GetWebhookAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetWebhookAsync(this, Discord, id, options); + + /// + public Task> GetWebhooksAsync(RequestOptions options = null) + => ChannelHelper.GetWebhooksAsync(this, Discord, options); + + #region IForumChannel + async Task> IForumChannel.GetActiveThreadsAsync(RequestOptions options) + => await GetActiveThreadsAsync(options).ConfigureAwait(false); + async Task> IForumChannel.GetPublicArchivedThreadsAsync(int? limit, DateTimeOffset? before, RequestOptions options) + => await GetPublicArchivedThreadsAsync(limit, before, options).ConfigureAwait(false); + async Task> IForumChannel.GetPrivateArchivedThreadsAsync(int? limit, DateTimeOffset? before, RequestOptions options) + => await GetPrivateArchivedThreadsAsync(limit, before, options).ConfigureAwait(false); + async Task> IForumChannel.GetJoinedPrivateArchivedThreadsAsync(int? limit, DateTimeOffset? before, RequestOptions options) + => await GetJoinedPrivateArchivedThreadsAsync(limit, before, options).ConfigureAwait(false); + async Task IForumChannel.CreatePostAsync(string title, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostAsync(title, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFileAsync(string title, string filePath, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFileAsync(title, filePath, archiveDuration, slowmode, text, embed, options, isSpoiler, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFileAsync(string title, Stream stream, string filename, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFileAsync(title, stream, filename, archiveDuration, slowmode, text, embed, options, isSpoiler, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFileAsync(string title, FileAttachment attachment, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFileAsync(title, attachment, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFilesAsync(string title, IEnumerable attachments, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFilesAsync(title, attachments, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags); + + #endregion + + #region INestedChannel + /// + public virtual async Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, options).ConfigureAwait(false); + public virtual async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options); + /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); + public virtual Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotImplementedException(); + /// + public virtual async Task> GetInvitesAsync(RequestOptions options = null) + => await ChannelHelper.GetInvitesAsync(this, Discord, options).ConfigureAwait(false); + + /// + async Task INestedChannel.GetCategoryAsync(CacheMode mode, RequestOptions options) + { + if (CategoryId.HasValue && mode == CacheMode.AllowDownload) + return (await Guild.GetChannelAsync(CategoryId.Value, mode, options).ConfigureAwait(false)) as ICategoryChannel; + return null; + } + + /// + public Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); + #endregion + + #region IIntegrationChannel + + /// + async Task IIntegrationChannel.CreateWebhookAsync(string name, Stream avatar, RequestOptions options) + => await CreateWebhookAsync(name, avatar, options).ConfigureAwait(false); + /// + async Task IIntegrationChannel.GetWebhookAsync(ulong id, RequestOptions options) + => await GetWebhookAsync(id, options).ConfigureAwait(false); + /// + async Task> IIntegrationChannel.GetWebhooksAsync(RequestOptions options) + => await GetWebhooksAsync(options).ConfigureAwait(false); + + #endregion + + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs new file mode 100644 index 0000000..c65cdfa --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestGroupChannel.cs @@ -0,0 +1,276 @@ +using Discord.Audio; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based group-message channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestGroupChannel : RestChannel, IGroupChannel, IRestPrivateChannel, IRestMessageChannel, IRestAudioChannel + { + #region RestGroupChannel + private string _iconId; + private ImmutableDictionary _users; + + /// + public string Name { get; private set; } + /// + public string RTCRegion { get; private set; } + + public IReadOnlyCollection Users => _users.ToReadOnlyCollection(); + public IReadOnlyCollection Recipients + => _users.Select(x => x.Value).Where(x => x.Id != Discord.CurrentUser.Id).ToReadOnlyCollection(() => _users.Count - 1); + + internal RestGroupChannel(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + internal new static RestGroupChannel Create(BaseDiscordClient discord, Model model) + { + var entity = new RestGroupChannel(discord, model.Id); + entity.Update(model); + return entity; + } + internal override void Update(Model model) + { + if (model.Name.IsSpecified) + Name = model.Name.Value; + if (model.Icon.IsSpecified) + _iconId = model.Icon.Value; + + if (model.Recipients.IsSpecified) + UpdateUsers(model.Recipients.Value); + + RTCRegion = model.RTCRegion.GetValueOrDefault(null); + } + internal void UpdateUsers(API.User[] models) + { + var users = ImmutableDictionary.CreateBuilder(); + for (int i = 0; i < models.Length; i++) + users[models[i].Id] = RestGroupUser.Create(Discord, models[i]); + _users = users.ToImmutable(); + } + /// + public override async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetChannelAsync(Id, options).ConfigureAwait(false); + Update(model); + } + /// + public Task LeaveAsync(RequestOptions options = null) + => ChannelHelper.DeleteAsync(this, Discord, options); + + public RestUser GetUser(ulong id) + { + if (_users.TryGetValue(id, out RestGroupUser user)) + return user; + return null; + } + + /// + public Task GetMessageAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetMessageAsync(this, Discord, id, options); + /// + public IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, null, Direction.Before, limit, options); + /// + public IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessageId, dir, limit, options); + /// + public IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessage.Id, dir, limit, options); + /// + public Task> GetPinnedMessagesAsync(RequestOptions options = null) + => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + + /// + public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); + /// + public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); + + /// + public async Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + => await ChannelHelper.ModifyMessageAsync(this, messageId, func, Discord, options).ConfigureAwait(false); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// + /// is a zero-length string, contains only white space, or contains one or more + /// invalid characters as defined by . + /// + /// + /// is . + /// + /// + /// The specified path, file name, or both exceed the system-defined maximum length. For example, on + /// Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 + /// characters. + /// + /// + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// + /// + /// specified a directory.-or- The caller does not have the required permission. + /// + /// + /// The file specified in was not found. + /// + /// is in an invalid format. + /// An I/O error occurred while opening the file. + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, attachment, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFilesAsync(this, Discord, attachments, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, embeds, flags); + /// + public Task TriggerTypingAsync(RequestOptions options = null) + => ChannelHelper.TriggerTypingAsync(this, Discord, options); + /// + public IDisposable EnterTypingState(RequestOptions options = null) + => ChannelHelper.EnterTypingState(this, Discord, options); + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id}, Group)"; + #endregion + + #region ISocketPrivateChannel + IReadOnlyCollection IRestPrivateChannel.Recipients => Recipients; + #endregion + + #region IPrivateChannel + IReadOnlyCollection IPrivateChannel.Recipients => Recipients; + #endregion + + #region IMessageChannel + async Task IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetMessageAsync(id, options).ConfigureAwait(false); + else + return null; + } + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(limit, options); + else + return AsyncEnumerable.Empty>(); + } + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(ulong fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(fromMessageId, dir, limit, options); + else + return AsyncEnumerable.Empty>(); + } + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(IMessage fromMessage, Direction dir, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(fromMessage, dir, limit, options); + else + return AsyncEnumerable.Empty>(); + } + async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) + => await GetPinnedMessagesAsync(options).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, + RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, + Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(FileAttachment attachment, string text, bool isTTS, + Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFilesAsync(IEnumerable attachments, string text, + bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, + AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, + ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + #endregion + + #region IAudioChannel + /// + /// Connecting to a group channel is not supported. + Task IAudioChannel.ConnectAsync(bool selfDeaf, bool selfMute, bool external, bool disconnect) { throw new NotSupportedException(); } + Task IAudioChannel.DisconnectAsync() { throw new NotSupportedException(); } + Task IAudioChannel.ModifyAsync(Action func, RequestOptions options) { throw new NotSupportedException(); } + #endregion + + #region IChannel + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetUser(id)); + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => ImmutableArray.Create>(Users).ToAsyncEnumerable(); + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestGuildChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestGuildChannel.cs new file mode 100644 index 0000000..250cc32 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestGuildChannel.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a private REST-based group channel. + /// + public class RestGuildChannel : RestChannel, IGuildChannel + { + #region RestGuildChannel + private ImmutableArray _overwrites; + + /// + public virtual IReadOnlyCollection PermissionOverwrites => _overwrites; + + internal IGuild Guild { get; } + /// + public string Name { get; private set; } + /// + public int Position { get; private set; } + + /// + public ulong GuildId { get; } + + /// + public ChannelFlags Flags { get; private set; } + + internal RestGuildChannel(BaseDiscordClient discord, IGuild guild, ulong id, ulong guildId) + : base(discord, id) + { + Guild = guild; + GuildId = guildId; + } + internal static RestGuildChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + return model.Type switch + { + ChannelType.News => RestNewsChannel.Create(discord, guild, model), + ChannelType.Text => RestTextChannel.Create(discord, guild, model), + ChannelType.Voice => RestVoiceChannel.Create(discord, guild, model), + ChannelType.Stage => RestStageChannel.Create(discord, guild, model), + ChannelType.Media => RestMediaChannel.Create(discord, guild, model), + ChannelType.Forum => RestForumChannel.Create(discord, guild, model), + ChannelType.Category => RestCategoryChannel.Create(discord, guild, model), + ChannelType.PublicThread or ChannelType.PrivateThread or ChannelType.NewsThread => RestThreadChannel.Create(discord, guild, model), + _ => new RestGuildChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value), + }; + } + internal override void Update(Model model) + { + Name = model.Name.Value; + + if (model.Position.IsSpecified) + { + Position = model.Position.Value; + } + + if (model.PermissionOverwrites.IsSpecified) + { + var overwrites = model.PermissionOverwrites.Value; + var newOverwrites = ImmutableArray.CreateBuilder(overwrites.Length); + for (int i = 0; i < overwrites.Length; i++) + newOverwrites.Add(overwrites[i].ToEntity()); + _overwrites = newOverwrites.ToImmutable(); + } + + Flags = model.Flags.GetValueOrDefault(ChannelFlags.None); + } + + /// + public override async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetChannelAsync(GuildId, Id, options).ConfigureAwait(false); + Update(model); + } + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await ChannelHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + /// + public Task DeleteAsync(RequestOptions options = null) + => ChannelHelper.DeleteAsync(this, Discord, options); + + /// + /// Gets the permission overwrite for a specific user. + /// + /// The user to get the overwrite from. + /// + /// An overwrite object for the targeted user; if none is set. + /// + public virtual OverwritePermissions? GetPermissionOverwrite(IUser user) + { + for (int i = 0; i < _overwrites.Length; i++) + { + if (_overwrites[i].TargetId == user.Id) + return _overwrites[i].Permissions; + } + return null; + } + + /// + /// Gets the permission overwrite for a specific role. + /// + /// The role to get the overwrite from. + /// + /// An overwrite object for the targeted role; if none is set. + /// + public virtual OverwritePermissions? GetPermissionOverwrite(IRole role) + { + for (int i = 0; i < _overwrites.Length; i++) + { + if (_overwrites[i].TargetId == role.Id) + return _overwrites[i].Permissions; + } + return null; + } + + /// + /// Adds or updates the permission overwrite for the given user. + /// + /// The user to add the overwrite to. + /// The overwrite to add to the user. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous permission operation for adding the specified permissions to the channel. + /// + public virtual async Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) + { + await ChannelHelper.AddPermissionOverwriteAsync(this, Discord, user, permissions, options).ConfigureAwait(false); + _overwrites = _overwrites.Add(new Overwrite(user.Id, PermissionTarget.User, new OverwritePermissions(permissions.AllowValue, permissions.DenyValue))); + } + /// + /// Adds or updates the permission overwrite for the given role. + /// + /// The role to add the overwrite to. + /// The overwrite to add to the role. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous permission operation for adding the specified permissions to the channel. + /// + public virtual async Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) + { + await ChannelHelper.AddPermissionOverwriteAsync(this, Discord, role, permissions, options).ConfigureAwait(false); + _overwrites = _overwrites.Add(new Overwrite(role.Id, PermissionTarget.Role, new OverwritePermissions(permissions.AllowValue, permissions.DenyValue))); + } + + /// + /// Removes the permission overwrite for the given user, if one exists. + /// + /// The user to remove the overwrite from. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous operation for removing the specified permissions from the channel. + /// + public virtual async Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) + { + await ChannelHelper.RemovePermissionOverwriteAsync(this, Discord, user, options).ConfigureAwait(false); + + for (int i = 0; i < _overwrites.Length; i++) + { + if (_overwrites[i].TargetId == user.Id) + { + _overwrites = _overwrites.RemoveAt(i); + return; + } + } + } + /// + /// Removes the permission overwrite for the given role, if one exists. + /// + /// The role to remove the overwrite from. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous operation for removing the specified permissions from the channel. + /// + public virtual async Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) + { + await ChannelHelper.RemovePermissionOverwriteAsync(this, Discord, role, options).ConfigureAwait(false); + + for (int i = 0; i < _overwrites.Length; i++) + { + if (_overwrites[i].TargetId == role.Id) + { + _overwrites = _overwrites.RemoveAt(i); + return; + } + } + } + + /// + /// Gets the name of this channel. + /// + /// + /// A string that is the name of this channel. + /// + public override string ToString() => Name; + #endregion + + #region IGuildChannel + /// + IGuild IGuildChannel.Guild + { + get + { + if (Guild != null) + return Guild; + throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + } + } + + /// + OverwritePermissions? IGuildChannel.GetPermissionOverwrite(IRole role) + => GetPermissionOverwrite(role); + /// + OverwritePermissions? IGuildChannel.GetPermissionOverwrite(IUser user) + => GetPermissionOverwrite(user); + /// + Task IGuildChannel.AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options) + => AddPermissionOverwriteAsync(role, permissions, options); + /// + Task IGuildChannel.AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options) + => AddPermissionOverwriteAsync(user, permissions, options); + /// + Task IGuildChannel.RemovePermissionOverwriteAsync(IRole role, RequestOptions options) + => RemovePermissionOverwriteAsync(role, options); + /// + Task IGuildChannel.RemovePermissionOverwriteAsync(IUser user, RequestOptions options) + => RemovePermissionOverwriteAsync(user, options); + + /// + IAsyncEnumerable> IGuildChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => AsyncEnumerable.Empty>(); //Overridden in Text/Voice + /// + Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); //Overridden in Text/Voice + #endregion + + #region IChannel + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => AsyncEnumerable.Empty>(); //Overridden in Text/Voice + /// + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); //Overridden in Text/Voice + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestMediaChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestMediaChannel.cs new file mode 100644 index 0000000..4e07878 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestMediaChannel.cs @@ -0,0 +1,24 @@ +using Model = Discord.API.Channel; + +namespace Discord.Rest; + +public class RestMediaChannel : RestForumChannel, IMediaChannel +{ + internal RestMediaChannel(BaseDiscordClient client, IGuild guild, ulong id, ulong guildId) + : base(client, guild, id, guildId) + { + + } + + internal new static RestMediaChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestMediaChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value); + entity.Update(model); + return entity; + } + + internal override void Update(Model model) + { + base.Update(model); + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestNewsChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestNewsChannel.cs new file mode 100644 index 0000000..5d33ca5 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestNewsChannel.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based news channel in a guild that has the same properties as a . + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestNewsChannel : RestTextChannel, INewsChannel + { + internal RestNewsChannel(BaseDiscordClient discord, IGuild guild, ulong id, ulong guildId) + : base(discord, guild, id, guildId) + { + } + internal new static RestNewsChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestNewsChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value); + entity.Update(model); + return entity; + } + public override int SlowModeInterval => throw new NotSupportedException("News channels do not support Slow Mode."); + + private string DebuggerDisplay => $"{Name} ({Id}, News)"; + + /// + public Task FollowAnnouncementChannelAsync(ulong channelId, RequestOptions options = null) + => ChannelHelper.FollowAnnouncementChannelAsync(this, channelId, Discord, options); + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestStageChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestStageChannel.cs new file mode 100644 index 0000000..235c0d1 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestStageChannel.cs @@ -0,0 +1,162 @@ +using Discord.API; +using Discord.API.Rest; +using System; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based stage channel in a guild. + /// + public class RestStageChannel : RestVoiceChannel, IStageChannel + { + /// + /// + /// This field is always true for stage channels. + /// + /// + [Obsolete("This property is no longer used because Discord enabled text-in-voice and text-in-stage for all channels.")] + public override bool IsTextInVoice + => true; + + /// + public StagePrivacyLevel? PrivacyLevel { get; private set; } + + /// + public bool? IsDiscoverableDisabled { get; private set; } + + /// + public bool IsLive { get; private set; } + internal RestStageChannel(BaseDiscordClient discord, IGuild guild, ulong id, ulong guildId) + : base(discord, guild, id, guildId) { } + + internal new static RestStageChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestStageChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value); + entity.Update(model); + return entity; + } + + internal void Update(StageInstance model, bool isLive = false) + { + IsLive = isLive; + if (isLive) + { + PrivacyLevel = model.PrivacyLevel; + IsDiscoverableDisabled = model.DiscoverableDisabled; + } + else + { + PrivacyLevel = null; + IsDiscoverableDisabled = null; + } + } + + /// + public async Task ModifyInstanceAsync(Action func, RequestOptions options = null) + { + var model = await ChannelHelper.ModifyAsync(this, Discord, func, options); + + Update(model, true); + } + + /// + public async Task StartStageAsync(string topic, StagePrivacyLevel privacyLevel = StagePrivacyLevel.GuildOnly, RequestOptions options = null) + { + var args = new CreateStageInstanceParams + { + ChannelId = Id, + PrivacyLevel = privacyLevel, + Topic = topic + }; + + var model = await Discord.ApiClient.CreateStageInstanceAsync(args, options); + + Update(model, true); + } + + /// + public async Task StopStageAsync(RequestOptions options = null) + { + await Discord.ApiClient.DeleteStageInstanceAsync(Id, options); + + Update(null); + } + + /// + public override async Task UpdateAsync(RequestOptions options = null) + { + await base.UpdateAsync(options); + + var model = await Discord.ApiClient.GetStageInstanceAsync(Id, options); + + Update(model, model != null); + } + + /// + public Task RequestToSpeakAsync(RequestOptions options = null) + { + var args = new ModifyVoiceStateParams + { + ChannelId = Id, + RequestToSpeakTimestamp = DateTimeOffset.UtcNow + }; + return Discord.ApiClient.ModifyMyVoiceState(Guild.Id, args, options); + } + + /// + public Task BecomeSpeakerAsync(RequestOptions options = null) + { + var args = new ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = false + }; + return Discord.ApiClient.ModifyMyVoiceState(Guild.Id, args, options); + } + + /// + public Task StopSpeakingAsync(RequestOptions options = null) + { + var args = new ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = true + }; + return Discord.ApiClient.ModifyMyVoiceState(Guild.Id, args, options); + } + + /// + public Task MoveToSpeakerAsync(IGuildUser user, RequestOptions options = null) + { + var args = new ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = false + }; + + return Discord.ApiClient.ModifyUserVoiceState(Guild.Id, user.Id, args); + } + + /// + public Task RemoveFromSpeakerAsync(IGuildUser user, RequestOptions options = null) + { + var args = new ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = true + }; + + return Discord.ApiClient.ModifyUserVoiceState(Guild.Id, user.Id, args); + } + + /// + /// + /// Setting voice channel status is not supported in stage channels. + /// + /// Setting voice channel status is not supported in stage channels. + public override Task SetStatusAsync(string status, RequestOptions options = null) + => throw new NotSupportedException("Setting voice channel status is not supported in stage channels."); + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs new file mode 100644 index 0000000..6a39423 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestTextChannel.cs @@ -0,0 +1,457 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based channel in a guild that can send and receive messages. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestTextChannel : RestGuildChannel, IRestMessageChannel, ITextChannel + { + #region RestTextChannel + /// + public string Topic { get; private set; } + /// + public virtual int SlowModeInterval { get; private set; } + /// + public ulong? CategoryId { get; private set; } + /// + public string Mention => MentionUtils.MentionChannel(Id); + /// + public bool IsNsfw { get; private set; } + /// + public ThreadArchiveDuration DefaultArchiveDuration { get; private set; } + /// + public int DefaultSlowModeInterval { get; private set; } + + internal RestTextChannel(BaseDiscordClient discord, IGuild guild, ulong id, ulong guildId) + : base(discord, guild, id, guildId) + { + } + internal new static RestTextChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestTextChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value); + entity.Update(model); + return entity; + } + /// + internal override void Update(Model model) + { + base.Update(model); + CategoryId = model.CategoryId; + Topic = model.Topic.GetValueOrDefault(); + if (model.SlowMode.IsSpecified) + SlowModeInterval = model.SlowMode.Value; + IsNsfw = model.Nsfw.GetValueOrDefault(); + + if (model.AutoArchiveDuration.IsSpecified) + DefaultArchiveDuration = model.AutoArchiveDuration.Value; + else + DefaultArchiveDuration = ThreadArchiveDuration.OneDay; + + DefaultSlowModeInterval = model.ThreadRateLimitPerUser.GetValueOrDefault(0); + // basic value at channel creation. Shouldn't be called since guild text channels always have this property + } + + /// + public virtual async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await ChannelHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + + /// + /// Gets a user in this channel. + /// + /// The snowflake identifier of the user. + /// The options to be used when sending the request. + /// + /// Resolving permissions requires the parent guild to be downloaded. + /// + /// + /// A task representing the asynchronous get operation. The task result contains a guild user object that + /// represents the user; if none is found. + /// + public Task GetUserAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetUserAsync(this, Guild, Discord, id, options); + + /// + /// Gets a collection of users that are able to view the channel. + /// + /// The options to be used when sending the request. + /// + /// Resolving permissions requires the parent guild to be downloaded. + /// + /// + /// A paged collection containing a collection of guild users that can access this channel. Flattening the + /// paginated response into a collection of users with + /// is required if you wish to access the users. + /// + public IAsyncEnumerable> GetUsersAsync(RequestOptions options = null) + => ChannelHelper.GetUsersAsync(this, Guild, Discord, null, null, options); + + /// + public virtual Task GetMessageAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetMessageAsync(this, Discord, id, options); + /// + public virtual IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, null, Direction.Before, limit, options); + /// + public virtual IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessageId, dir, limit, options); + /// + public virtual IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessage.Id, dir, limit, options); + /// + public virtual Task> GetPinnedMessagesAsync(RequestOptions options = null) + => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// + /// is a zero-length string, contains only white space, or contains one or more + /// invalid characters as defined by . + /// + /// + /// is . + /// + /// + /// The specified path, file name, or both exceed the system-defined maximum length. For example, on + /// Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 + /// characters. + /// + /// + /// The specified path is invalid, (for example, it is on an unmapped drive). + /// + /// + /// specified a directory.-or- The caller does not have the required permission. + /// + /// + /// The file specified in was not found. + /// + /// is in an invalid format. + /// An I/O error occurred while opening the file. + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, isSpoiler, embeds, flags); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, isSpoiler, embeds, flags); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, attachment, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFilesAsync(this, Discord, attachments, text, isTTS, embed, allowedMentions, messageReference, components, stickers, options, embeds, flags); + + /// + public virtual Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); + /// + public virtual Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); + + /// + public virtual Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null) + => ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); + /// + public virtual Task DeleteMessagesAsync(IEnumerable messageIds, RequestOptions options = null) + => ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); + + /// + public virtual async Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + => await ChannelHelper.ModifyMessageAsync(this, messageId, func, Discord, options).ConfigureAwait(false); + + /// + public virtual Task TriggerTypingAsync(RequestOptions options = null) + => ChannelHelper.TriggerTypingAsync(this, Discord, options); + /// + public virtual IDisposable EnterTypingState(RequestOptions options = null) + => ChannelHelper.EnterTypingState(this, Discord, options); + + /// + /// Creates a webhook in this text channel. + /// + /// The name of the webhook. + /// The avatar of the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// webhook. + /// + public virtual Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + => ChannelHelper.CreateWebhookAsync(this, Discord, name, avatar, options); + /// + /// Gets a webhook available in this text channel. + /// + /// The identifier of the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a webhook associated + /// with the identifier; if the webhook is not found. + /// + public virtual Task GetWebhookAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetWebhookAsync(this, Discord, id, options); + /// + /// Gets the webhooks available in this text channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of webhooks that is available in this channel. + /// + public virtual Task> GetWebhooksAsync(RequestOptions options = null) + => ChannelHelper.GetWebhooksAsync(this, Discord, options); + + /// + /// Creates a thread within this . + /// + /// + /// When is the thread type will be based off of the + /// channel its created in. When called on a , it creates a . + /// When called on a , it creates a . The id of the created + /// thread will be the same as the id of the message, and as such a message can only have a + /// single thread created from it. + /// + /// The name of the thread. + /// + /// The type of the thread. + /// + /// Note: This parameter is not used if the parameter is not specified. + /// + /// + /// + /// The duration on which this thread archives after. + /// + /// The message which to start the thread from. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous create operation. The task result contains a + /// + public virtual async Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, + ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) + { + var model = await ThreadHelper.CreateThreadAsync(Discord, this, name, type, autoArchiveDuration, message, invitable, slowmode, options); + return RestThreadChannel.Create(Discord, Guild, model); + } + + /// + /// Gets the parent (category) channel of this channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the category channel + /// representing the parent of this channel; if none is set. + /// + public virtual Task GetCategoryAsync(RequestOptions options = null) + => ChannelHelper.GetCategoryAsync(this, Discord, options); + /// + public Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); + + /// + public virtual Task> GetActiveThreadsAsync(RequestOptions options = null) + => ThreadHelper.GetActiveThreadsAsync(Guild, Id, Discord, options); + #endregion + + #region Invites + /// + public virtual async Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, options).ConfigureAwait(false); + public virtual async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options); + /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); + public virtual Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotImplementedException(); + /// + public virtual async Task> GetInvitesAsync(RequestOptions options = null) + => await ChannelHelper.GetInvitesAsync(this, Discord, options).ConfigureAwait(false); + + private string DebuggerDisplay => $"{Name} ({Id}, Text)"; + #endregion + + #region IIntegrationChannel + + /// + async Task IIntegrationChannel.CreateWebhookAsync(string name, Stream avatar, RequestOptions options) + => await CreateWebhookAsync(name, avatar, options).ConfigureAwait(false); + /// + async Task IIntegrationChannel.GetWebhookAsync(ulong id, RequestOptions options) + => await GetWebhookAsync(id, options).ConfigureAwait(false); + /// + async Task> IIntegrationChannel.GetWebhooksAsync(RequestOptions options) + => await GetWebhooksAsync(options).ConfigureAwait(false); + + #endregion + + #region ITextChannel + + async Task ITextChannel.CreateThreadAsync(string name, ThreadType type, ThreadArchiveDuration autoArchiveDuration, IMessage message, bool? invitable, int? slowmode, RequestOptions options) + => await CreateThreadAsync(name, type, autoArchiveDuration, message, invitable, slowmode, options); + + /// + async Task> ITextChannel.GetActiveThreadsAsync(RequestOptions options) + => await GetActiveThreadsAsync(options); + #endregion + + #region IMessageChannel + /// + async Task IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetMessageAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(limit, options); + else + return AsyncEnumerable.Empty>(); + } + + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(ulong fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(fromMessageId, dir, limit, options); + else + return AsyncEnumerable.Empty>(); + } + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(IMessage fromMessage, Direction dir, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessagesAsync(fromMessage, dir, limit, options); + else + return AsyncEnumerable.Empty>(); + } + /// + async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) + => await GetPinnedMessagesAsync(options).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, + RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, + Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(FileAttachment attachment, string text, bool isTTS, + Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFilesAsync(IEnumerable attachments, string text, + bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, + AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, + ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + #endregion + + #region IGuildChannel + /// + async Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetUserAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + IAsyncEnumerable> IGuildChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + { + return mode == CacheMode.AllowDownload + ? GetUsersAsync(options) + : AsyncEnumerable.Empty>(); + } + #endregion + + #region IChannel + /// + async Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetUserAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetUsersAsync(options); + else + return AsyncEnumerable.Empty>(); + } + #endregion + + #region INestedChannel + /// + async Task INestedChannel.GetCategoryAsync(CacheMode mode, RequestOptions options) + { + if (CategoryId.HasValue && mode == CacheMode.AllowDownload) + return (await Guild.GetChannelAsync(CategoryId.Value, mode, options).ConfigureAwait(false)) as ICategoryChannel; + return null; + } + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestThreadChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestThreadChannel.cs new file mode 100644 index 0000000..053b5fd --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestThreadChannel.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a thread channel received over REST. + /// + public class RestThreadChannel : RestTextChannel, IThreadChannel + { + public ThreadType Type { get; private set; } + /// + public bool HasJoined { get; private set; } + + /// + public bool IsArchived { get; private set; } + + /// + public ThreadArchiveDuration AutoArchiveDuration { get; private set; } + + /// + public DateTimeOffset ArchiveTimestamp { get; private set; } + + /// + public bool IsLocked { get; private set; } + + /// + public int MemberCount { get; private set; } + + /// + public int MessageCount { get; private set; } + + /// + public bool? IsInvitable { get; private set; } + + /// + public IReadOnlyCollection AppliedTags { get; private set; } + + /// + public ulong OwnerId { get; private set; } + + /// + public override DateTimeOffset CreatedAt { get; } + + /// + /// Gets the parent text channel id. + /// + public ulong ParentChannelId { get; private set; } + + internal RestThreadChannel(BaseDiscordClient discord, IGuild guild, ulong id, ulong guildId, DateTimeOffset? createdAt) + : base(discord, guild, id, guildId) + { + CreatedAt = createdAt ?? new DateTimeOffset(2022, 1, 9, 0, 0, 0, TimeSpan.Zero); + } + + internal new static RestThreadChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestThreadChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value, model.ThreadMetadata.GetValueOrDefault()?.CreatedAt.GetValueOrDefault()); + entity.Update(model); + return entity; + } + + internal override void Update(Model model) + { + base.Update(model); + + HasJoined = model.ThreadMember.IsSpecified; + + if (model.ThreadMetadata.IsSpecified) + { + IsInvitable = model.ThreadMetadata.Value.Invitable.ToNullable(); + IsArchived = model.ThreadMetadata.Value.Archived; + AutoArchiveDuration = model.ThreadMetadata.Value.AutoArchiveDuration; + ArchiveTimestamp = model.ThreadMetadata.Value.ArchiveTimestamp; + IsLocked = model.ThreadMetadata.Value.Locked.GetValueOrDefault(false); + } + + OwnerId = model.OwnerId.GetValueOrDefault(0); + + MemberCount = model.MemberCount.GetValueOrDefault(0); + MessageCount = model.MessageCount.GetValueOrDefault(0); + Type = (ThreadType)model.Type; + ParentChannelId = model.CategoryId.Value; + + AppliedTags = model.AppliedTags.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + } + + /// + /// Gets a user within this thread. + /// + /// The id of the user to fetch. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous get operation. The task returns a + /// if found, otherwise . + /// + public new Task GetUserAsync(ulong userId, RequestOptions options = null) + => ThreadHelper.GetUserAsync(userId, this, Discord, options); + + /// + /// Gets a collection of users within this thread. + /// + /// Sets the limit of the user count for each request. 100 by default. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of thread + /// users found within this thread channel. + /// + public IAsyncEnumerable> GetThreadUsersAsync(int limit = DiscordConfig.MaxThreadMembersPerBatch, RequestOptions options = null) + => ThreadHelper.GetUsersAsync(this, Discord, limit, null, options); + + /// + /// Gets a collection of users within this thread. + /// + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous get operation. The task returns a + /// of 's. + /// + [Obsolete("Please use GetThreadUsersAsync instead of this. Will be removed in next major version.", false)] + public new async Task> GetUsersAsync(RequestOptions options = null) + => (await GetThreadUsersAsync(options: options).FlattenAsync()).ToImmutableArray(); + + /// + public override async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await ThreadHelper.ModifyAsync(this, Discord, func, options); + Update(model); + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await ThreadHelper.ModifyAsync(this, Discord, func, options); + Update(model); + } + + /// + /// + /// This method is not supported in threads. + /// + public override Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task GetCategoryAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task> GetInvitesAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override OverwritePermissions? GetPermissionOverwrite(IRole role) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override OverwritePermissions? GetPermissionOverwrite(IUser user) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task GetWebhookAsync(ulong id, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task> GetWebhooksAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override IReadOnlyCollection PermissionOverwrites + => throw new NotSupportedException("This method is not supported in threads."); + + /// + public Task JoinAsync(RequestOptions options = null) + => Discord.ApiClient.JoinThreadAsync(Id, options); + + /// + public Task LeaveAsync(RequestOptions options = null) + => Discord.ApiClient.LeaveThreadAsync(Id, options); + + /// + public Task AddUserAsync(IGuildUser user, RequestOptions options = null) + => Discord.ApiClient.AddThreadMemberAsync(Id, user.Id, options); + + /// + public Task RemoveUserAsync(IGuildUser user, RequestOptions options = null) + => Discord.ApiClient.RemoveThreadMemberAsync(Id, user.Id, options); + + /// This method is not supported in threads. + public override Task> GetActiveThreadsAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs new file mode 100644 index 0000000..73dacf5 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestVoiceChannel.cs @@ -0,0 +1,116 @@ +using Discord.Audio; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based voice channel in a guild. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestVoiceChannel : RestTextChannel, IVoiceChannel, IRestAudioChannel + { + #region RestVoiceChannel + /// + /// Gets whether or not the guild has Text-In-Voice enabled and the voice channel is a TiV channel. + /// + [Obsolete("This property is no longer used because Discord enabled text-in-voice for all channels.")] + public virtual bool IsTextInVoice => true; + + /// + public int Bitrate { get; private set; } + /// + public int? UserLimit { get; private set; } + /// + public string RTCRegion { get; private set; } + /// + public VideoQualityMode VideoQualityMode { get; private set; } + + internal RestVoiceChannel(BaseDiscordClient discord, IGuild guild, ulong id, ulong guildId) + : base(discord, guild, id, guildId) + { + } + internal new static RestVoiceChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestVoiceChannel(discord, guild, model.Id, guild?.Id ?? model.GuildId.Value); + entity.Update(model); + return entity; + } + /// + internal override void Update(Model model) + { + base.Update(model); + + if (model.Bitrate.IsSpecified) + Bitrate = model.Bitrate.Value; + + if (model.UserLimit.IsSpecified) + UserLimit = model.UserLimit.Value != 0 ? model.UserLimit.Value : (int?)null; + + VideoQualityMode = model.VideoQualityMode.GetValueOrDefault(VideoQualityMode.Auto); + + RTCRegion = model.RTCRegion.GetValueOrDefault(null); + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await ChannelHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + + /// + /// Cannot create a thread within a voice channel. + public override Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) + => throw new InvalidOperationException("Cannot create a thread within a voice channel"); + + /// + public virtual Task SetStatusAsync(string status, RequestOptions options = null) + => ChannelHelper.ModifyVoiceChannelStatusAsync(this, status, Discord, options); + + #endregion + + private string DebuggerDisplay => $"{Name} ({Id}, Voice)"; + + #region TextOverrides + + /// Threads are not supported in voice channels + public override Task> GetActiveThreadsAsync(RequestOptions options = null) + => throw new NotSupportedException("Threads are not supported in voice channels"); + + #endregion + + + #region IAudioChannel + /// + /// Connecting to a REST-based channel is not supported. + Task IAudioChannel.ConnectAsync(bool selfDeaf, bool selfMute, bool external, bool disconnect) { throw new NotSupportedException(); } + Task IAudioChannel.DisconnectAsync() { throw new NotSupportedException(); } + Task IAudioChannel.ModifyAsync(Action func, RequestOptions options) { throw new NotSupportedException(); } + #endregion + + #region IGuildChannel + /// + Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); + /// + IAsyncEnumerable> IGuildChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => AsyncEnumerable.Empty>(); + #endregion + + #region INestedChannel + /// + async Task INestedChannel.GetCategoryAsync(CacheMode mode, RequestOptions options) + { + if (CategoryId.HasValue && mode == CacheMode.AllowDownload) + return (await Guild.GetChannelAsync(CategoryId.Value, mode, options).ConfigureAwait(false)) as ICategoryChannel; + return null; + } + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Channels/ThreadHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ThreadHelper.cs new file mode 100644 index 0000000..cbfd611 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/ThreadHelper.cs @@ -0,0 +1,237 @@ +using Discord.API; +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.Rest +{ + internal static class ThreadHelper + { + public static Task CreateThreadAsync(BaseDiscordClient client, ITextChannel channel, string name, ThreadType type = ThreadType.PublicThread, + ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) + { + if (channel is INewsChannel && type != ThreadType.NewsThread) + throw new ArgumentException($"{nameof(type)} must be a {ThreadType.NewsThread} in News channels"); + + var args = new StartThreadParams + { + Name = name, + Duration = autoArchiveDuration, + Type = type, + Invitable = invitable.HasValue ? invitable.Value : Optional.Unspecified, + Ratelimit = slowmode.HasValue ? slowmode.Value : Optional.Unspecified, + }; + + if (message != null) + return client.ApiClient.StartThreadAsync(channel.Id, message.Id, args, options); + else + return client.ApiClient.StartThreadAsync(channel.Id, args, options); + } + + public static Task ModifyAsync(IThreadChannel channel, BaseDiscordClient client, + Action func, + RequestOptions options) + { + var args = new ThreadChannelProperties(); + func(args); + + Preconditions.AtMost(args.AppliedTags.IsSpecified ? args.AppliedTags.Value.Count() : 0, 5, nameof(args.AppliedTags), "Forum post can have max 5 applied tags."); + + var apiArgs = new ModifyThreadParams + { + Name = args.Name, + Archived = args.Archived, + AutoArchiveDuration = args.AutoArchiveDuration, + Locked = args.Locked, + Slowmode = args.SlowModeInterval, + AppliedTags = args.AppliedTags, + Flags = args.Flags, + }; + return client.ApiClient.ModifyThreadAsync(channel.Id, apiArgs, options); + } + + public static async Task> GetActiveThreadsAsync(IGuild guild, ulong channelId, BaseDiscordClient client, RequestOptions options) + { + var result = await client.ApiClient.GetActiveThreadsAsync(guild.Id, options).ConfigureAwait(false); + return result.Threads.Where(x => x.CategoryId == channelId).Select(x => RestThreadChannel.Create(client, guild, x)).ToImmutableArray(); + } + + public static async Task> GetPublicArchivedThreadsAsync(IGuildChannel channel, BaseDiscordClient client, int? limit = null, + DateTimeOffset? before = null, RequestOptions options = null) + { + var result = await client.ApiClient.GetPublicArchivedThreadsAsync(channel.Id, before, limit, options); + return result.Threads.Select(x => RestThreadChannel.Create(client, channel.Guild, x)).ToImmutableArray(); + } + + public static async Task> GetPrivateArchivedThreadsAsync(IGuildChannel channel, BaseDiscordClient client, int? limit = null, + DateTimeOffset? before = null, RequestOptions options = null) + { + var result = await client.ApiClient.GetPrivateArchivedThreadsAsync(channel.Id, before, limit, options); + return result.Threads.Select(x => RestThreadChannel.Create(client, channel.Guild, x)).ToImmutableArray(); + } + + public static async Task> GetJoinedPrivateArchivedThreadsAsync(IGuildChannel channel, BaseDiscordClient client, int? limit = null, + DateTimeOffset? before = null, RequestOptions options = null) + { + var result = await client.ApiClient.GetJoinedPrivateArchivedThreadsAsync(channel.Id, before, limit, options); + return result.Threads.Select(x => RestThreadChannel.Create(client, channel.Guild, x)).ToImmutableArray(); + } + + public static IAsyncEnumerable> GetUsersAsync(IThreadChannel channel, BaseDiscordClient client, int limit = DiscordConfig.MaxThreadMembersPerBatch, ulong? afterId = null, RequestOptions options = null) + { + return new PagedAsyncEnumerable( + limit, + async (info, ct) => + { + if (info.Position != null) + afterId = info.Position.Value; + var users = await client.ApiClient.ListThreadMembersAsync(channel.Id, afterId, limit, options); + return users.Select(x => RestThreadUser.Create(client, channel.Guild, x, channel)).ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != limit) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: afterId, + count: limit + ); + } + + public static async Task GetUserAsync(ulong userId, IThreadChannel channel, BaseDiscordClient client, RequestOptions options = null) + { + var model = await client.ApiClient.GetThreadMemberAsync(channel.Id, userId, options).ConfigureAwait(false); + + return RestThreadUser.Create(client, channel.Guild, model, channel); + } + + public static async Task CreatePostAsync(IForumChannel channel, BaseDiscordClient client, string title, + ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, int? slowmode = null, string text = null, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ulong[] tagIds = null) + { + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + Preconditions.AtMost(tagIds?.Length ?? 0, 5, nameof(tagIds), "Forum post can have max 5 applied tags."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + if (stickers != null) + { + Preconditions.AtMost(stickers.Length, 3, nameof(stickers), "A max of 3 stickers are allowed."); + } + + if (flags is not MessageFlags.None and not MessageFlags.SuppressEmbeds) + throw new ArgumentException("The only valid MessageFlags are SuppressEmbeds and none.", nameof(flags)); + + if (channel.Flags.HasFlag(ChannelFlags.RequireTag)) + Preconditions.AtLeast(tagIds?.Length ?? 0, 1, nameof(tagIds), $"The channel {channel.Name} requires posts to have at least one tag."); + + var args = new CreatePostParams() + { + Title = title, + ArchiveDuration = archiveDuration, + Slowmode = slowmode, + Message = new() + { + AllowedMentions = allowedMentions.ToModel(), + Content = text, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + Flags = flags, + Components = components?.Components?.Any() ?? false ? components.Components.Select(x => new API.ActionRowComponent(x)).ToArray() : Optional.Unspecified, + Stickers = stickers?.Any() ?? false ? stickers.Select(x => x.Id).ToArray() : Optional.Unspecified, + }, + Tags = tagIds + }; + + var model = await client.ApiClient.CreatePostAsync(channel.Id, args, options).ConfigureAwait(false); + + return RestThreadChannel.Create(client, channel.Guild, model); + } + + public static async Task CreatePostAsync(IForumChannel channel, BaseDiscordClient client, string title, IEnumerable attachments, + ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageComponent components, + ISticker[] stickers, Embed[] embeds, MessageFlags flags, ulong[] tagIds = null) + { + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + Preconditions.AtMost(tagIds?.Length ?? 0, 5, nameof(tagIds), "Forum post can have max 5 applied tags."); + + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + if (stickers != null) + { + Preconditions.AtMost(stickers.Length, 3, nameof(stickers), "A max of 3 stickers are allowed."); + } + + if (flags is not MessageFlags.None and not MessageFlags.SuppressEmbeds) + throw new ArgumentException("The only valid MessageFlags are SuppressEmbeds and none.", nameof(flags)); + + if (channel.Flags.HasFlag(ChannelFlags.RequireTag)) + throw new ArgumentException($"The channel {channel.Name} requires posts to have at least one tag."); + + var args = new CreateMultipartPostAsync(attachments.ToArray()) + { + AllowedMentions = allowedMentions.ToModel(), + ArchiveDuration = archiveDuration, + Content = text, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + Flags = flags, + MessageComponent = components?.Components?.Any() ?? false ? components.Components.Select(x => new API.ActionRowComponent(x)).ToArray() : Optional.Unspecified, + Slowmode = slowmode, + Stickers = stickers?.Any() ?? false ? stickers.Select(x => x.Id).ToArray() : Optional.Unspecified, + Title = title, + TagIds = tagIds + }; + + var model = await client.ApiClient.CreatePostAsync(channel.Id, args, options); + + return RestThreadChannel.Create(client, channel.Guild, model); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs new file mode 100644 index 0000000..2dae8fc --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -0,0 +1,1452 @@ +using Discord.API; +using Discord.API.Rest; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Data; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using ImageModel = Discord.API.Image; +using Model = Discord.API.Guild; +using RoleModel = Discord.API.Role; +using WidgetModel = Discord.API.GuildWidget; + +namespace Discord.Rest +{ + internal static class GuildHelper + { + #region General + /// is . + public static Task ModifyAsync(IGuild guild, BaseDiscordClient client, + Action func, RequestOptions options) + { + if (func == null) + throw new ArgumentNullException(nameof(func)); + + var args = new GuildProperties(); + func(args); + + var apiArgs = new API.Rest.ModifyGuildParams + { + AfkChannelId = args.AfkChannelId, + AfkTimeout = args.AfkTimeout, + SystemChannelId = args.SystemChannelId, + DefaultMessageNotifications = args.DefaultMessageNotifications, + Icon = args.Icon.IsSpecified ? args.Icon.Value?.ToModel() : Optional.Create(), + Name = args.Name, + Splash = args.Splash.IsSpecified ? args.Splash.Value?.ToModel() : Optional.Create(), + Banner = args.Banner.IsSpecified ? args.Banner.Value?.ToModel() : Optional.Create(), + VerificationLevel = args.VerificationLevel, + ExplicitContentFilter = args.ExplicitContentFilter, + SystemChannelFlags = args.SystemChannelFlags, + IsBoostProgressBarEnabled = args.IsBoostProgressBarEnabled, + GuildFeatures = args.Features.IsSpecified ? new GuildFeatures(args.Features.Value, Array.Empty()) : Optional.Create(), + }; + + if (apiArgs.Banner.IsSpecified) + guild.Features.EnsureFeature(GuildFeature.Banner); + + if (apiArgs.Splash.IsSpecified) + guild.Features.EnsureFeature(GuildFeature.InviteSplash); + + if (args.AfkChannel.IsSpecified) + apiArgs.AfkChannelId = args.AfkChannel.Value.Id; + else if (args.AfkChannelId.IsSpecified) + apiArgs.AfkChannelId = args.AfkChannelId.Value; + + if (args.SystemChannel.IsSpecified) + apiArgs.SystemChannelId = args.SystemChannel.Value.Id; + else if (args.SystemChannelId.IsSpecified) + apiArgs.SystemChannelId = args.SystemChannelId.Value; + + if (args.Owner.IsSpecified) + apiArgs.OwnerId = args.Owner.Value.Id; + else if (args.OwnerId.IsSpecified) + apiArgs.OwnerId = args.OwnerId.Value; + + if (args.Region.IsSpecified) + apiArgs.RegionId = args.Region.Value.Id; + else if (args.RegionId.IsSpecified) + apiArgs.RegionId = args.RegionId.Value; + + if (!apiArgs.Banner.IsSpecified && guild.BannerId != null) + apiArgs.Banner = new ImageModel(guild.BannerId); + if (!apiArgs.Splash.IsSpecified && guild.SplashId != null) + apiArgs.Splash = new ImageModel(guild.SplashId); + if (!apiArgs.Icon.IsSpecified && guild.IconId != null) + apiArgs.Icon = new ImageModel(guild.IconId); + + if (args.ExplicitContentFilter.IsSpecified) + apiArgs.ExplicitContentFilter = args.ExplicitContentFilter.Value; + + if (args.SystemChannelFlags.IsSpecified) + apiArgs.SystemChannelFlags = args.SystemChannelFlags.Value; + + // PreferredLocale takes precedence over PreferredCulture + if (args.PreferredLocale.IsSpecified) + apiArgs.PreferredLocale = args.PreferredLocale.Value; + else if (args.PreferredCulture.IsSpecified) + apiArgs.PreferredLocale = args.PreferredCulture.Value.Name; + + return client.ApiClient.ModifyGuildAsync(guild.Id, apiArgs, options); + } + + /// is . + public static Task ModifyWidgetAsync(IGuild guild, BaseDiscordClient client, + Action func, RequestOptions options) + { + if (func == null) + throw new ArgumentNullException(nameof(func)); + + var args = new GuildWidgetProperties(); + func(args); + var apiArgs = new API.Rest.ModifyGuildWidgetParams + { + Enabled = args.Enabled + }; + + if (args.Channel.IsSpecified) + apiArgs.ChannelId = args.Channel.Value?.Id; + else if (args.ChannelId.IsSpecified) + apiArgs.ChannelId = args.ChannelId.Value; + + return client.ApiClient.ModifyGuildWidgetAsync(guild.Id, apiArgs, options); + } + + public static Task ReorderChannelsAsync(IGuild guild, BaseDiscordClient client, + IEnumerable args, RequestOptions options) + { + var apiArgs = args.Select(x => new API.Rest.ModifyGuildChannelsParams(x.Id, x.Position)); + return client.ApiClient.ModifyGuildChannelsAsync(guild.Id, apiArgs, options); + } + + public static Task> ReorderRolesAsync(IGuild guild, BaseDiscordClient client, + IEnumerable args, RequestOptions options) + { + var apiArgs = args.Select(x => new API.Rest.ModifyGuildRolesParams(x.Id, x.Position)); + return client.ApiClient.ModifyGuildRolesAsync(guild.Id, apiArgs, options); + } + + public static Task LeaveAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.LeaveGuildAsync(guild.Id, options); + + public static Task DeleteAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.DeleteGuildAsync(guild.Id, options); + + public static int GetMaxBitrate(PremiumTier premiumTier) + { + return premiumTier switch + { + PremiumTier.Tier1 => 128000, + PremiumTier.Tier2 => 256000, + PremiumTier.Tier3 => 384000, + _ => 96000, + }; + } + + public static ulong GetUploadLimit(PremiumTier premiumTier) + { + ulong tierFactor = premiumTier switch + { + PremiumTier.Tier2 => 50, + PremiumTier.Tier3 => 100, + _ => 25 + }; + + // 1 << 20 = 2 pow 20 + var mebibyte = 1UL << 20; + return tierFactor * mebibyte; + } + + public static async Task ModifyGuildIncidentActionsAsync(IGuild guild, BaseDiscordClient client, Action func, RequestOptions options = null) + { + var props = new GuildIncidentsDataProperties(); + func(props); + + var args = props.DmsDisabledUntil.IsSpecified || props.InvitesDisabledUntil.IsSpecified + ? new ModifyGuildIncidentsDataParams { DmsDisabledUntil = props.DmsDisabledUntil, InvitesDisabledUntil = props.InvitesDisabledUntil } + : null; + + var model = await client.ApiClient.ModifyGuildIncidentActionsAsync(guild.Id, args, options); + + return new GuildIncidentsData + { + DmsDisabledUntil = model.DmsDisabledUntil, + InvitesDisabledUntil = model.InvitesDisabledUntil + }; + } + #endregion + + #region Bans + public static IAsyncEnumerable> GetBansAsync(IGuild guild, BaseDiscordClient client, + ulong? fromUserId, Direction dir, int limit, RequestOptions options) + { + if (dir == Direction.Around && limit > DiscordConfig.MaxBansPerBatch) + { + int around = limit / 2; + if (fromUserId.HasValue) + return GetBansAsync(guild, client, fromUserId.Value + 1, Direction.Before, around + 1, options) + .Concat(GetBansAsync(guild, client, fromUserId.Value, Direction.After, around, options)); + else + return GetBansAsync(guild, client, null, Direction.Before, around + 1, options); + } + + return new PagedAsyncEnumerable( + DiscordConfig.MaxBansPerBatch, + async (info, ct) => + { + var args = new GetGuildBansParams + { + RelativeDirection = dir, + Limit = info.PageSize + }; + if (info.Position != null) + args.RelativeUserId = info.Position.Value; + + var models = await client.ApiClient.GetGuildBansAsync(guild.Id, args, options).ConfigureAwait(false); + var builder = ImmutableArray.CreateBuilder(); + + foreach (var model in models) + builder.Add(RestBan.Create(client, model)); + + return builder.ToImmutable(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxBansPerBatch) + return false; + if (dir == Direction.Before) + info.Position = lastPage.Min(x => x.User.Id); + else + info.Position = lastPage.Max(x => x.User.Id); + return true; + }, + start: fromUserId, + count: limit + ); + } + + public static async Task GetBanAsync(IGuild guild, BaseDiscordClient client, ulong userId, RequestOptions options) + { + var model = await client.ApiClient.GetGuildBanAsync(guild.Id, userId, options).ConfigureAwait(false); + return model == null ? null : RestBan.Create(client, model); + } + + public static Task AddBanAsync(IGuild guild, BaseDiscordClient client, ulong userId, int pruneDays, string reason, RequestOptions options) + { + Preconditions.AtLeast(pruneDays, 0, nameof(pruneDays), "Prune length must be within [0, 7]"); + return client.ApiClient.CreateGuildBanAsync(guild.Id, userId, (uint)pruneDays * 86400, reason, options); + } + + public static Task AddBanAsync(IGuild guild, BaseDiscordClient client, ulong userId, uint pruneSeconds, RequestOptions options) + { + return client.ApiClient.CreateGuildBanAsync(guild.Id, userId, pruneSeconds, null, options); + } + + public static Task RemoveBanAsync(IGuild guild, BaseDiscordClient client, ulong userId, RequestOptions options) + => client.ApiClient.RemoveGuildBanAsync(guild.Id, userId, options); + + public static async Task BulkBanAsync(IGuild guild, BaseDiscordClient client, ulong[] userIds, int? deleteMessageSeconds, RequestOptions options) + { + var model = await client.ApiClient.BulkBanAsync(guild.Id, userIds, deleteMessageSeconds, options); + return new(model.BannedUsers?.ToImmutableArray() ?? ImmutableArray.Empty, + model.FailedUsers?.ToImmutableArray() ?? ImmutableArray.Empty); + } + #endregion + + #region Channels + public static async Task GetChannelAsync(IGuild guild, BaseDiscordClient client, + ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetChannelAsync(guild.Id, id, options).ConfigureAwait(false); + if (model != null) + return RestGuildChannel.Create(client, guild, model); + return null; + } + public static async Task> GetChannelsAsync(IGuild guild, BaseDiscordClient client, + RequestOptions options) + { + var models = await client.ApiClient.GetGuildChannelsAsync(guild.Id, options).ConfigureAwait(false); + return models.Select(x => RestGuildChannel.Create(client, guild, x)).ToImmutableArray(); + } + /// is . + public static async Task CreateTextChannelAsync(IGuild guild, BaseDiscordClient client, + string name, RequestOptions options, Action func = null) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + var props = new TextChannelProperties(); + func?.Invoke(props); + + var args = new CreateGuildChannelParams(name, ChannelType.Text) + { + CategoryId = props.CategoryId, + Topic = props.Topic, + IsNsfw = props.IsNsfw, + Position = props.Position, + SlowModeInterval = props.SlowModeInterval, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + DefaultAutoArchiveDuration = props.AutoArchiveDuration + }; + var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); + return RestTextChannel.Create(client, guild, model); + } + + /// is . + public static async Task CreateNewsChannelAsync(IGuild guild, BaseDiscordClient client, + string name, RequestOptions options, Action func = null) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + var props = new TextChannelProperties(); + func?.Invoke(props); + + var args = new CreateGuildChannelParams(name, ChannelType.News) + { + CategoryId = props.CategoryId, + Topic = props.Topic, + IsNsfw = props.IsNsfw, + Position = props.Position, + SlowModeInterval = props.SlowModeInterval, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + DefaultAutoArchiveDuration = props.AutoArchiveDuration + }; + var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); + return RestNewsChannel.Create(client, guild, model); + } + + /// is . + public static async Task CreateVoiceChannelAsync(IGuild guild, BaseDiscordClient client, + string name, RequestOptions options, Action func = null) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + var props = new VoiceChannelProperties(); + func?.Invoke(props); + + var args = new CreateGuildChannelParams(name, ChannelType.Voice) + { + CategoryId = props.CategoryId, + Bitrate = props.Bitrate, + UserLimit = props.UserLimit, + Position = props.Position, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + VideoQuality = props.VideoQualityMode, + RtcRegion = props.RTCRegion + }; + var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); + return RestVoiceChannel.Create(client, guild, model); + } + public static async Task CreateStageChannelAsync(IGuild guild, BaseDiscordClient client, + string name, RequestOptions options, Action func = null) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + var props = new VoiceChannelProperties(); + func?.Invoke(props); + + var args = new CreateGuildChannelParams(name, ChannelType.Stage) + { + CategoryId = props.CategoryId, + Bitrate = props.Bitrate, + UserLimit = props.UserLimit, + Position = props.Position, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + }; + var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); + return RestStageChannel.Create(client, guild, model); + } + /// is . + public static async Task CreateCategoryChannelAsync(IGuild guild, BaseDiscordClient client, + string name, RequestOptions options, Action func = null) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + var props = new GuildChannelProperties(); + func?.Invoke(props); + + var args = new CreateGuildChannelParams(name, ChannelType.Category) + { + Position = props.Position, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + }; + + var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); + return RestCategoryChannel.Create(client, guild, model); + } + + /// is . + public static async Task CreateForumChannelAsync(IGuild guild, BaseDiscordClient client, + string name, RequestOptions options, Action func = null) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + var props = new ForumChannelProperties(); + func?.Invoke(props); + + Preconditions.AtMost(props.Tags.IsSpecified ? props.Tags.Value.Count() : 0, 5, nameof(props.Tags), "Forum channel can have max 20 tags."); + + var args = new CreateGuildChannelParams(name, ChannelType.Forum) + { + Position = props.Position, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + SlowModeInterval = props.ThreadCreationInterval, + AvailableTags = props.Tags.GetValueOrDefault(Array.Empty()).Select( + x => new ModifyForumTagParams + { + Id = x.Id ?? Optional.Unspecified, + Name = x.Name, + EmojiId = x.Emoji is Emote emote + ? emote.Id + : Optional.Unspecified, + EmojiName = x.Emoji is Emoji emoji + ? emoji.Name + : Optional.Unspecified, + Moderated = x.IsModerated + }).ToArray(), + DefaultReactionEmoji = props.DefaultReactionEmoji.IsSpecified + ? new API.ModifyForumReactionEmojiParams + { + EmojiId = props.DefaultReactionEmoji.Value is Emote emote ? + emote.Id : Optional.Unspecified, + EmojiName = props.DefaultReactionEmoji.Value is Emoji emoji ? + emoji.Name : Optional.Unspecified + } + : Optional.Unspecified, + ThreadRateLimitPerUser = props.DefaultSlowModeInterval, + CategoryId = props.CategoryId, + IsNsfw = props.IsNsfw, + Topic = props.Topic, + DefaultAutoArchiveDuration = props.AutoArchiveDuration, + DefaultSortOrder = props.DefaultSortOrder.GetValueOrDefault(ForumSortOrder.LatestActivity), + DefaultLayout = props.DefaultLayout, + }; + + var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); + return RestForumChannel.Create(client, guild, model); + } + + /// is . + public static async Task CreateMediaChannelAsync(IGuild guild, BaseDiscordClient client, + string name, RequestOptions options, Action func = null) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + var props = new ForumChannelProperties(); + func?.Invoke(props); + + Preconditions.AtMost(props.Tags.IsSpecified ? props.Tags.Value.Count() : 0, 20, nameof(props.Tags), "Media channel can have max 20 tags."); + + var args = new CreateGuildChannelParams(name, ChannelType.Media) + { + Position = props.Position, + Overwrites = props.PermissionOverwrites.IsSpecified + ? props.PermissionOverwrites.Value.Select(overwrite => new API.Overwrite + { + TargetId = overwrite.TargetId, + TargetType = overwrite.TargetType, + Allow = overwrite.Permissions.AllowValue.ToString(), + Deny = overwrite.Permissions.DenyValue.ToString() + }).ToArray() + : Optional.Create(), + SlowModeInterval = props.ThreadCreationInterval, + AvailableTags = props.Tags.GetValueOrDefault(Array.Empty()).Select( + x => new ModifyForumTagParams + { + Id = x.Id ?? Optional.Unspecified, + Name = x.Name, + EmojiId = x.Emoji is Emote emote + ? emote.Id + : Optional.Unspecified, + EmojiName = x.Emoji is Emoji emoji + ? emoji.Name + : Optional.Unspecified, + Moderated = x.IsModerated + }).ToArray(), + DefaultReactionEmoji = props.DefaultReactionEmoji.IsSpecified + ? new API.ModifyForumReactionEmojiParams + { + EmojiId = props.DefaultReactionEmoji.Value is Emote emote ? + emote.Id : Optional.Unspecified, + EmojiName = props.DefaultReactionEmoji.Value is Emoji emoji ? + emoji.Name : Optional.Unspecified + } + : Optional.Unspecified, + ThreadRateLimitPerUser = props.DefaultSlowModeInterval, + CategoryId = props.CategoryId, + IsNsfw = props.IsNsfw, + Topic = props.Topic, + DefaultAutoArchiveDuration = props.AutoArchiveDuration, + DefaultSortOrder = props.DefaultSortOrder.GetValueOrDefault(ForumSortOrder.LatestActivity), + }; + + var model = await client.ApiClient.CreateGuildChannelAsync(guild.Id, args, options).ConfigureAwait(false); + return RestMediaChannel.Create(client, guild, model); + } + + #endregion + + #region Voice Regions + public static async Task> GetVoiceRegionsAsync(IGuild guild, BaseDiscordClient client, + RequestOptions options) + { + var models = await client.ApiClient.GetGuildVoiceRegionsAsync(guild.Id, options).ConfigureAwait(false); + return models.Select(x => RestVoiceRegion.Create(client, x)).ToImmutableArray(); + } + #endregion + + #region Integrations + public static async Task> GetIntegrationsAsync(IGuild guild, BaseDiscordClient client, + RequestOptions options) + { + var models = await client.ApiClient.GetIntegrationsAsync(guild.Id, options).ConfigureAwait(false); + return models.Select(x => RestIntegration.Create(client, guild, x)).ToImmutableArray(); + } + + public static Task DeleteIntegrationAsync(IGuild guild, BaseDiscordClient client, ulong id, RequestOptions options) + => client.ApiClient.DeleteIntegrationAsync(guild.Id, id, options); + #endregion + + #region Interactions + public static async Task> GetSlashCommandsAsync(IGuild guild, BaseDiscordClient client, bool withLocalizations, + string locale, RequestOptions options) + { + var models = await client.ApiClient.GetGuildApplicationCommandsAsync(guild.Id, withLocalizations, locale, options); + return models.Select(x => RestGuildCommand.Create(client, x, guild.Id)).ToImmutableArray(); + } + public static async Task GetSlashCommandAsync(IGuild guild, ulong id, BaseDiscordClient client, + RequestOptions options) + { + var model = await client.ApiClient.GetGuildApplicationCommandAsync(guild.Id, id, options); + return RestGuildCommand.Create(client, model, guild.Id); + } + #endregion + + #region Invites + public static async Task> GetInvitesAsync(IGuild guild, BaseDiscordClient client, + RequestOptions options) + { + var models = await client.ApiClient.GetGuildInvitesAsync(guild.Id, options).ConfigureAwait(false); + return models.Select(x => RestInviteMetadata.Create(client, guild, null, x)).ToImmutableArray(); + } + public static async Task GetVanityInviteAsync(IGuild guild, BaseDiscordClient client, + RequestOptions options) + { + var vanityModel = await client.ApiClient.GetVanityInviteAsync(guild.Id, options).ConfigureAwait(false); + if (vanityModel == null) + throw new InvalidOperationException("This guild does not have a vanity URL."); + var inviteModel = await client.ApiClient.GetInviteAsync(vanityModel.Code, options).ConfigureAwait(false); + inviteModel.Uses = vanityModel.Uses; + return RestInviteMetadata.Create(client, guild, null, inviteModel); + } + + #endregion + + #region Roles + /// is . + public static async Task CreateRoleAsync(IGuild guild, BaseDiscordClient client, + string name, GuildPermissions? permissions, Color? color, bool isHoisted, bool isMentionable, RequestOptions options, Image? icon, Emoji emoji) + { + if (name == null) + throw new ArgumentNullException(paramName: nameof(name)); + + if (icon is not null || emoji is not null) + { + guild.Features.EnsureFeature(GuildFeature.RoleIcons); + + if (icon is not null && emoji is not null) + { + throw new ArgumentException("Emoji and Icon properties cannot be present on a role at the same time."); + } + } + + var createGuildRoleParams = new API.Rest.ModifyGuildRoleParams + { + Color = color?.RawValue ?? Optional.Create(), + Hoist = isHoisted, + Mentionable = isMentionable, + Name = name, + Permissions = permissions?.RawValue.ToString() ?? Optional.Create(), + Icon = icon?.ToModel(), + Emoji = emoji?.Name + }; + + var model = await client.ApiClient.CreateGuildRoleAsync(guild.Id, createGuildRoleParams, options).ConfigureAwait(false); + + return RestRole.Create(client, guild, model); + } + #endregion + + #region Users + public static async Task AddGuildUserAsync(IGuild guild, BaseDiscordClient client, ulong userId, string accessToken, + Action func, RequestOptions options) + { + var args = new AddGuildUserProperties(); + func?.Invoke(args); + + if (args.Roles.IsSpecified) + { + var ids = args.Roles.Value.Select(r => r.Id); + + if (args.RoleIds.IsSpecified) + args.RoleIds = Optional.Create(args.RoleIds.Value.Concat(ids)); + else + args.RoleIds = Optional.Create(ids); + } + var apiArgs = new AddGuildMemberParams + { + AccessToken = accessToken, + Nickname = args.Nickname, + IsDeafened = args.Deaf, + IsMuted = args.Mute, + RoleIds = args.RoleIds.IsSpecified ? args.RoleIds.Value.Distinct().ToArray() : Optional.Create() + }; + + var model = await client.ApiClient.AddGuildMemberAsync(guild.Id, userId, apiArgs, options); + + return model is null ? null : RestGuildUser.Create(client, guild, model); + } + + public static Task AddGuildUserAsync(ulong guildId, BaseDiscordClient client, ulong userId, string accessToken, + Action func, RequestOptions options) + { + var args = new AddGuildUserProperties(); + func?.Invoke(args); + + if (args.Roles.IsSpecified) + { + var ids = args.Roles.Value.Select(r => r.Id); + + if (args.RoleIds.IsSpecified) + args.RoleIds.Value.Concat(ids); + else + args.RoleIds = Optional.Create(ids); + } + var apiArgs = new AddGuildMemberParams + { + AccessToken = accessToken, + Nickname = args.Nickname, + IsDeafened = args.Deaf, + IsMuted = args.Mute, + RoleIds = args.RoleIds.IsSpecified ? args.RoleIds.Value.Distinct().ToArray() : Optional.Create() + }; + + return client.ApiClient.AddGuildMemberAsync(guildId, userId, apiArgs, options); + } + + public static async Task GetUserAsync(IGuild guild, BaseDiscordClient client, + ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetGuildMemberAsync(guild.Id, id, options).ConfigureAwait(false); + if (model != null) + return RestGuildUser.Create(client, guild, model); + return null; + } + public static Task GetCurrentUserAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + => GetUserAsync(guild, client, client.CurrentUser.Id, options); + + public static IAsyncEnumerable> GetUsersAsync(IGuild guild, BaseDiscordClient client, + ulong? fromUserId, int? limit, RequestOptions options) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxUsersPerBatch, + async (info, ct) => + { + var args = new GetGuildMembersParams + { + Limit = info.PageSize + }; + if (info.Position != null) + args.AfterUserId = info.Position.Value; + var models = await client.ApiClient.GetGuildMembersAsync(guild.Id, args, options).ConfigureAwait(false); + return models.Select(x => RestGuildUser.Create(client, guild, x)).ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxUsersPerBatch) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: fromUserId, + count: limit + ); + } + public static async Task PruneUsersAsync(IGuild guild, BaseDiscordClient client, + int days, bool simulate, RequestOptions options, IEnumerable includeRoleIds) + { + var args = new GuildPruneParams(days, includeRoleIds?.ToArray()); + GetGuildPruneCountResponse model; + if (simulate) + model = await client.ApiClient.GetGuildPruneCountAsync(guild.Id, args, options).ConfigureAwait(false); + else + model = await client.ApiClient.BeginGuildPruneAsync(guild.Id, args, options).ConfigureAwait(false); + return model.Pruned; + } + public static async Task> SearchUsersAsync(IGuild guild, BaseDiscordClient client, + string query, int? limit, RequestOptions options) + { + var apiArgs = new SearchGuildMembersParams + { + Query = query, + Limit = limit ?? Optional.Create() + }; + var models = await client.ApiClient.SearchGuildMembersAsync(guild.Id, apiArgs, options).ConfigureAwait(false); + return models.Select(x => RestGuildUser.Create(client, guild, x)).ToImmutableArray(); + } + #endregion + + #region Audit logs + public static IAsyncEnumerable> GetAuditLogsAsync(IGuild guild, BaseDiscordClient client, + ulong? from, int? limit, RequestOptions options, ulong? userId = null, ActionType? actionType = null, ulong? afterId = null) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxAuditLogEntriesPerBatch, + async (info, ct) => + { + var args = new GetAuditLogsParams + { + Limit = info.PageSize + }; + if (info.Position != null) + args.BeforeEntryId = info.Position.Value; + if (userId.HasValue) + args.UserId = userId.Value; + if (actionType.HasValue) + args.ActionType = (int)actionType.Value; + if (afterId.HasValue) + args.AfterEntryId = afterId.Value; + var model = await client.ApiClient.GetAuditLogsAsync(guild.Id, args, options); + return model.Entries.Select((x) => RestAuditLogEntry.Create(client, model, x)).ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxAuditLogEntriesPerBatch) + return false; + info.Position = lastPage.Min(x => x.Id); + return true; + }, + start: from, + count: limit + ); + } + #endregion + + #region Webhooks + public static async Task GetWebhookAsync(IGuild guild, BaseDiscordClient client, ulong id, RequestOptions options) + { + var model = await client.ApiClient.GetWebhookAsync(id, options: options).ConfigureAwait(false); + if (model == null) + return null; + return RestWebhook.Create(client, guild, model); + } + public static async Task> GetWebhooksAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetGuildWebhooksAsync(guild.Id, options).ConfigureAwait(false); + return models.Select(x => RestWebhook.Create(client, guild, x)).ToImmutableArray(); + } + #endregion + + #region Emotes + public static async Task> GetEmotesAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + { + var models = await client.ApiClient.GetGuildEmotesAsync(guild.Id, options).ConfigureAwait(false); + return models.Select(x => x.ToEntity()).ToImmutableArray(); + } + public static async Task GetEmoteAsync(IGuild guild, BaseDiscordClient client, ulong id, RequestOptions options) + { + var emote = await client.ApiClient.GetGuildEmoteAsync(guild.Id, id, options).ConfigureAwait(false); + return emote.ToEntity(); + } + public static async Task CreateEmoteAsync(IGuild guild, BaseDiscordClient client, string name, Image image, Optional> roles, + RequestOptions options) + { + var apiargs = new CreateGuildEmoteParams + { + Name = name, + Image = image.ToModel() + }; + if (roles.IsSpecified) + apiargs.RoleIds = roles.Value?.Select(xr => xr.Id).ToArray(); + + var emote = await client.ApiClient.CreateGuildEmoteAsync(guild.Id, apiargs, options).ConfigureAwait(false); + return emote.ToEntity(); + } + /// is . + public static async Task ModifyEmoteAsync(IGuild guild, BaseDiscordClient client, ulong id, Action func, + RequestOptions options) + { + if (func == null) + throw new ArgumentNullException(paramName: nameof(func)); + + var props = new EmoteProperties(); + func(props); + + var apiargs = new ModifyGuildEmoteParams + { + Name = props.Name + }; + if (props.Roles.IsSpecified) + apiargs.RoleIds = props.Roles.Value?.Select(xr => xr.Id).ToArray(); + + var emote = await client.ApiClient.ModifyGuildEmoteAsync(guild.Id, id, apiargs, options).ConfigureAwait(false); + return emote.ToEntity(); + } + public static Task DeleteEmoteAsync(IGuild guild, BaseDiscordClient client, ulong id, RequestOptions options) + => client.ApiClient.DeleteGuildEmoteAsync(guild.Id, id, options); + + public static async Task CreateStickerAsync(BaseDiscordClient client, IGuild guild, string name, Image image, IEnumerable tags, + string description = null, RequestOptions options = null) + { + Preconditions.NotNull(name, nameof(name)); + + if (description is not null) + { + Preconditions.AtLeast(description.Length, 2, nameof(description)); + Preconditions.AtMost(description.Length, 100, nameof(description)); + } + + var tagString = string.Join(", ", tags); + + Preconditions.AtLeast(tagString.Length, 1, nameof(tags)); + Preconditions.AtMost(tagString.Length, 200, nameof(tags)); + + + Preconditions.AtLeast(name.Length, 2, nameof(name)); + + Preconditions.AtMost(name.Length, 30, nameof(name)); + + var apiArgs = new CreateStickerParams() + { + Name = name, + Description = description, + File = image.Stream, + Tags = tagString + }; + + return await client.ApiClient.CreateGuildStickerAsync(apiArgs, guild.Id, options).ConfigureAwait(false); + } + + public static Task CreateStickerAsync(BaseDiscordClient client, IGuild guild, string name, Stream file, string filename, IEnumerable tags, + string description = null, RequestOptions options = null) + { + Preconditions.NotNull(name, nameof(name)); + Preconditions.NotNull(file, nameof(file)); + Preconditions.NotNull(filename, nameof(filename)); + + Preconditions.AtLeast(name.Length, 2, nameof(name)); + + Preconditions.AtMost(name.Length, 30, nameof(name)); + + + if (description is not null) + { + Preconditions.AtLeast(description.Length, 2, nameof(description)); + Preconditions.AtMost(description.Length, 100, nameof(description)); + } + + var tagString = string.Join(", ", tags); + + Preconditions.AtLeast(tagString.Length, 1, nameof(tags)); + Preconditions.AtMost(tagString.Length, 200, nameof(tags)); + + var apiArgs = new CreateStickerParams() + { + Name = name, + Description = description, + File = file, + Tags = tagString, + FileName = filename + }; + + return client.ApiClient.CreateGuildStickerAsync(apiArgs, guild.Id, options); + } + + public static Task ModifyStickerAsync(BaseDiscordClient client, ulong guildId, ISticker sticker, Action func, + RequestOptions options = null) + { + if (func == null) + throw new ArgumentNullException(paramName: nameof(func)); + + var props = new StickerProperties(); + func(props); + + var apiArgs = new ModifyStickerParams() + { + Description = props.Description, + Name = props.Name, + Tags = props.Tags.IsSpecified ? + string.Join(", ", props.Tags.Value) : + Optional.Unspecified + }; + + return client.ApiClient.ModifyStickerAsync(apiArgs, guildId, sticker.Id, options); + } + + public static Task DeleteStickerAsync(BaseDiscordClient client, ulong guildId, ISticker sticker, RequestOptions options = null) + => client.ApiClient.DeleteStickerAsync(guildId, sticker.Id, options); + #endregion + + #region Events + + public static async Task> GetEventUsersAsync(BaseDiscordClient client, IGuildScheduledEvent guildEvent, int limit = 100, RequestOptions options = null) + { + var models = await client.ApiClient.GetGuildScheduledEventUsersAsync(guildEvent.Id, guildEvent.Guild.Id, limit, options).ConfigureAwait(false); + + return models.Select(x => RestUser.Create(client, guildEvent.Guild, x)).ToImmutableArray(); + } + + public static IAsyncEnumerable> GetEventUsersAsync(BaseDiscordClient client, IGuildScheduledEvent guildEvent, + ulong? fromUserId, int? limit, RequestOptions options) + { + return new PagedAsyncEnumerable( + DiscordConfig.MaxGuildEventUsersPerBatch, + async (info, ct) => + { + var args = new GetEventUsersParams + { + Limit = info.PageSize, + RelativeDirection = Direction.After, + }; + if (info.Position != null) + args.RelativeUserId = info.Position.Value; + var models = await client.ApiClient.GetGuildScheduledEventUsersAsync(guildEvent.Id, guildEvent.Guild.Id, args, options).ConfigureAwait(false); + return models + .Select(x => RestUser.Create(client, guildEvent.Guild, x)) + .ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxGuildEventUsersPerBatch) + return false; + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: fromUserId, + count: limit + ); + } + + public static IAsyncEnumerable> GetEventUsersAsync(BaseDiscordClient client, IGuildScheduledEvent guildEvent, + ulong? fromUserId, Direction dir, int limit, RequestOptions options = null) + { + if (dir == Direction.Around && limit > DiscordConfig.MaxMessagesPerBatch) + { + int around = limit / 2; + if (fromUserId.HasValue) + return GetEventUsersAsync(client, guildEvent, fromUserId.Value + 1, Direction.Before, around + 1, options) //Need to include the message itself + .Concat(GetEventUsersAsync(client, guildEvent, fromUserId, Direction.After, around, options)); + else //Shouldn't happen since there's no public overload for ulong? and Direction + return GetEventUsersAsync(client, guildEvent, null, Direction.Before, around + 1, options); + } + + return new PagedAsyncEnumerable( + DiscordConfig.MaxGuildEventUsersPerBatch, + async (info, ct) => + { + var args = new GetEventUsersParams + { + RelativeDirection = dir, + Limit = info.PageSize + }; + if (info.Position != null) + args.RelativeUserId = info.Position.Value; + + var models = await client.ApiClient.GetGuildScheduledEventUsersAsync(guildEvent.Id, guildEvent.Guild.Id, args, options).ConfigureAwait(false); + var builder = ImmutableArray.CreateBuilder(); + foreach (var model in models) + { + builder.Add(RestUser.Create(client, guildEvent.Guild, model)); + } + return builder.ToImmutable(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxGuildEventUsersPerBatch) + return false; + if (dir == Direction.Before) + info.Position = lastPage.Min(x => x.Id); + else + info.Position = lastPage.Max(x => x.Id); + return true; + }, + start: fromUserId, + count: limit + ); + } + + public static Task ModifyGuildEventAsync(BaseDiscordClient client, Action func, + IGuildScheduledEvent guildEvent, RequestOptions options = null) + { + var args = new GuildScheduledEventsProperties(); + + func(args); + + if (args.Status.IsSpecified) + { + switch (args.Status.Value) + { + case GuildScheduledEventStatus.Active when guildEvent.Status != GuildScheduledEventStatus.Scheduled: + case GuildScheduledEventStatus.Completed when guildEvent.Status != GuildScheduledEventStatus.Active: + case GuildScheduledEventStatus.Cancelled when guildEvent.Status != GuildScheduledEventStatus.Scheduled: + throw new ArgumentException($"Cannot set event to {args.Status.Value} when events status is {guildEvent.Status}"); + } + } + + if (args.Type.IsSpecified) + { + // taken from https://discord.com/developers/docs/resources/guild-scheduled-event#modify-guild-scheduled-event + switch (args.Type.Value) + { + case GuildScheduledEventType.External: + if (!args.Location.IsSpecified) + throw new ArgumentException("Location must be specified for external events."); + if (!args.EndTime.IsSpecified) + throw new ArgumentException("End time must be specified for external events."); + if (!args.ChannelId.IsSpecified) + throw new ArgumentException("Channel id must be set to null!"); + if (args.ChannelId.Value != null) + throw new ArgumentException("Channel id must be set to null!"); + break; + } + } + + var apiArgs = new ModifyGuildScheduledEventParams() + { + ChannelId = args.ChannelId, + Description = args.Description, + EndTime = args.EndTime, + Name = args.Name, + PrivacyLevel = args.PrivacyLevel, + StartTime = args.StartTime, + Status = args.Status, + Type = args.Type, + Image = args.CoverImage.IsSpecified + ? args.CoverImage.Value.HasValue + ? args.CoverImage.Value.Value.ToModel() + : null + : Optional.Unspecified + }; + + if (args.Location.IsSpecified) + { + apiArgs.EntityMetadata = new API.GuildScheduledEventEntityMetadata() + { + Location = args.Location, + }; + } + + return client.ApiClient.ModifyGuildScheduledEventAsync(apiArgs, guildEvent.Id, guildEvent.Guild.Id, options); + } + + public static async Task GetGuildEventAsync(BaseDiscordClient client, ulong id, IGuild guild, RequestOptions options = null) + { + var model = await client.ApiClient.GetGuildScheduledEventAsync(id, guild.Id, options).ConfigureAwait(false); + + if (model == null) + return null; + + return RestGuildEvent.Create(client, guild, model); + } + + public static async Task> GetGuildEventsAsync(BaseDiscordClient client, IGuild guild, RequestOptions options = null) + { + var models = await client.ApiClient.ListGuildScheduledEventsAsync(guild.Id, options).ConfigureAwait(false); + + return models.Select(x => RestGuildEvent.Create(client, guild, x)).ToImmutableArray(); + } + + public static async Task CreateGuildEventAsync(BaseDiscordClient client, IGuild guild, + string name, + GuildScheduledEventPrivacyLevel privacyLevel, + DateTimeOffset startTime, + GuildScheduledEventType type, + string description = null, + DateTimeOffset? endTime = null, + ulong? channelId = null, + string location = null, + Image? bannerImage = null, + RequestOptions options = null) + { + if (location != null) + { + Preconditions.AtMost(location.Length, 100, nameof(location)); + } + + switch (type) + { + case GuildScheduledEventType.Stage or GuildScheduledEventType.Voice when channelId == null: + throw new ArgumentException($"{nameof(channelId)} must not be null when type is {type}", nameof(channelId)); + case GuildScheduledEventType.External when channelId != null: + throw new ArgumentException($"{nameof(channelId)} must be null when using external event type", nameof(channelId)); + case GuildScheduledEventType.External when location == null: + throw new ArgumentException($"{nameof(location)} must not be null when using external event type", nameof(location)); + case GuildScheduledEventType.External when endTime == null: + throw new ArgumentException($"{nameof(endTime)} must not be null when using external event type", nameof(endTime)); + } + + if (startTime <= DateTimeOffset.Now) + throw new ArgumentOutOfRangeException(nameof(startTime), "The start time for an event cannot be in the past"); + + if (endTime != null && endTime <= startTime) + throw new ArgumentOutOfRangeException(nameof(endTime), $"{nameof(endTime)} cannot be before the start time"); + + + var apiArgs = new CreateGuildScheduledEventParams() + { + ChannelId = channelId ?? Optional.Unspecified, + Description = description ?? Optional.Unspecified, + EndTime = endTime ?? Optional.Unspecified, + Name = name, + PrivacyLevel = privacyLevel, + StartTime = startTime, + Type = type, + Image = bannerImage.HasValue ? bannerImage.Value.ToModel() : Optional.Unspecified + }; + + if (location != null) + { + apiArgs.EntityMetadata = new API.GuildScheduledEventEntityMetadata() + { + Location = location + }; + } + + var model = await client.ApiClient.CreateGuildScheduledEventAsync(apiArgs, guild.Id, options).ConfigureAwait(false); + + return RestGuildEvent.Create(client, guild, client.CurrentUser, model); + } + + public static Task DeleteEventAsync(BaseDiscordClient client, IGuildScheduledEvent guildEvent, RequestOptions options = null) + => client.ApiClient.DeleteGuildScheduledEventAsync(guildEvent.Id, guildEvent.Guild.Id, options); + + #endregion + + #region Welcome Screen + + public static async Task GetWelcomeScreenAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + { + var model = await client.ApiClient.GetGuildWelcomeScreenAsync(guild.Id, options); + + if (model.WelcomeChannels.Length == 0) + return null; + + return new WelcomeScreen(model.Description.GetValueOrDefault(null), model.WelcomeChannels.Select( + x => new WelcomeScreenChannel( + x.ChannelId, x.Description, + x.EmojiName.GetValueOrDefault(null), + x.EmojiId.GetValueOrDefault(0))).ToList()); + } + + public static async Task ModifyWelcomeScreenAsync(bool enabled, string description, WelcomeScreenChannelProperties[] channels, IGuild guild, BaseDiscordClient client, RequestOptions options) + { + if (!guild.Features.HasFeature(GuildFeature.Community)) + throw new InvalidOperationException("Cannot update welcome screen in a non-community guild."); + + var args = new ModifyGuildWelcomeScreenParams + { + Enabled = enabled, + Description = description, + WelcomeChannels = channels?.Select(ch => new API.WelcomeScreenChannel + { + ChannelId = ch.Id, + Description = ch.Description, + EmojiName = ch.Emoji is Emoji emoj ? emoj.Name : Optional.Unspecified, + EmojiId = ch.Emoji is Emote emote ? emote.Id : Optional.Unspecified + }).ToArray() + }; + + var model = await client.ApiClient.ModifyGuildWelcomeScreenAsync(args, guild.Id, options); + + if (model.WelcomeChannels.Length == 0) + return null; + + return new WelcomeScreen(model.Description.GetValueOrDefault(null), model.WelcomeChannels.Select( + x => new WelcomeScreenChannel( + x.ChannelId, x.Description, + x.EmojiName.GetValueOrDefault(null), + x.EmojiId.GetValueOrDefault(0))).ToList()); + } + + #endregion + + #region Auto Mod + + public static Task CreateAutoModRuleAsync(IGuild guild, Action func, BaseDiscordClient client, RequestOptions options) + { + var args = new AutoModRuleProperties(); + func(args); + + #region Validations + + if (!args.TriggerType.IsSpecified) + throw new ArgumentException(message: $"AutoMod rule must have a specified type.", paramName: nameof(args.TriggerType)); + + if (!args.Name.IsSpecified || string.IsNullOrWhiteSpace(args.Name.Value)) + throw new ArgumentException("Name of the rule must not be empty", paramName: nameof(args.Name)); + + Preconditions.AtLeast(args.Actions.GetValueOrDefault(Array.Empty()).Length, 1, nameof(args.Actions), "Auto moderation rule must have at least 1 action"); + + if (args.RegexPatterns.IsSpecified) + { + if (args.TriggerType.Value is not AutoModTriggerType.Keyword and not AutoModTriggerType.MemberProfile) + throw new ArgumentException(message: $"Regex patterns can only be used with 'Keyword' or 'MemberProfile' trigger type.", paramName: nameof(args.RegexPatterns)); + + Preconditions.AtMost(args.RegexPatterns.Value.Length, AutoModRuleProperties.MaxRegexPatternCount, nameof(args.RegexPatterns), $"Regex pattern count must be less than or equal to {AutoModRuleProperties.MaxRegexPatternCount}."); + + if (args.RegexPatterns.Value.Any(x => x.Length > AutoModRuleProperties.MaxRegexPatternLength)) + throw new ArgumentException(message: $"Regex pattern must be less than or equal to {AutoModRuleProperties.MaxRegexPatternLength}.", paramName: nameof(args.RegexPatterns)); + } + + if (args.KeywordFilter.IsSpecified) + { + if (args.TriggerType.Value is not AutoModTriggerType.Keyword and not AutoModTriggerType.MemberProfile) + throw new ArgumentException(message: $"Keyword filter can only be used with 'Keyword' or 'MemberProfile' trigger type.", paramName: nameof(args.KeywordFilter)); + + Preconditions.AtMost(args.KeywordFilter.Value.Length, AutoModRuleProperties.MaxKeywordCount, nameof(args.KeywordFilter), $"Keyword count must be less than or equal to {AutoModRuleProperties.MaxKeywordCount}"); + + if (args.KeywordFilter.Value.Any(x => x.Length > AutoModRuleProperties.MaxKeywordLength)) + throw new ArgumentException(message: $"Keyword length must be less than or equal to {AutoModRuleProperties.MaxKeywordLength}.", paramName: nameof(args.KeywordFilter)); + } + + if (args.TriggerType.Value is AutoModTriggerType.Keyword) + Preconditions.AtLeast(args.KeywordFilter.GetValueOrDefault(Array.Empty()).Length + args.RegexPatterns.GetValueOrDefault(Array.Empty()).Length, 1, "KeywordFilter & RegexPatterns", "Auto moderation rule must have at least 1 keyword or regex pattern"); + + if (args.AllowList.IsSpecified) + { + if (args.TriggerType.Value is not AutoModTriggerType.Keyword and not AutoModTriggerType.KeywordPreset and not AutoModTriggerType.MemberProfile) + throw new ArgumentException(message: $"Allow list can only be used with 'Keyword', 'KeywordPreset' or 'MemberProfile' trigger type.", paramName: nameof(args.AllowList)); + + if (args.TriggerType.Value is AutoModTriggerType.Keyword) + Preconditions.AtMost(args.AllowList.Value.Length, AutoModRuleProperties.MaxAllowListCountKeyword, nameof(args.AllowList), $"Allow list entry count must be less than or equal to {AutoModRuleProperties.MaxAllowListCountKeyword}."); + + if (args.TriggerType.Value is AutoModTriggerType.KeywordPreset) + Preconditions.AtMost(args.AllowList.Value.Length, AutoModRuleProperties.MaxAllowListCountKeywordPreset, nameof(args.AllowList), $"Allow list entry count must be less than or equal to {AutoModRuleProperties.MaxAllowListCountKeywordPreset}."); + + if (args.AllowList.Value.Any(x => x.Length > AutoModRuleProperties.MaxAllowListEntryLength)) + throw new ArgumentException(message: $"Allow list entry length must be less than or equal to {AutoModRuleProperties.MaxAllowListEntryLength}.", paramName: nameof(args.AllowList)); + + } + + if (args.TriggerType.Value is not AutoModTriggerType.KeywordPreset && args.Presets.IsSpecified) + throw new ArgumentException(message: $"Keyword presets scan only be used with 'KeywordPreset' trigger type.", paramName: nameof(args.Presets)); + + if (args.MentionLimit.IsSpecified) + { + if (args.TriggerType.Value is AutoModTriggerType.MentionSpam) + { + Preconditions.AtMost(args.MentionLimit.Value, AutoModRuleProperties.MaxMentionLimit, nameof(args.MentionLimit), $"Mention limit must be less or equal to {AutoModRuleProperties.MaxMentionLimit}"); + Preconditions.AtLeast(args.MentionLimit.Value, 1, nameof(args.MentionLimit), $"Mention limit must be greater or equal to 1"); + } + else + { + throw new ArgumentException(message: $"MentionLimit can only be used with 'MentionSpam' trigger type.", paramName: nameof(args.MentionLimit)); + } + } + + if (args.ExemptRoles.IsSpecified) + Preconditions.AtMost(args.ExemptRoles.Value.Length, AutoModRuleProperties.MaxExemptRoles, nameof(args.ExemptRoles), $"Exempt roles count must be less than or equal to {AutoModRuleProperties.MaxExemptRoles}."); + + if (args.ExemptChannels.IsSpecified) + Preconditions.AtMost(args.ExemptChannels.Value.Length, AutoModRuleProperties.MaxExemptChannels, nameof(args.ExemptChannels), $"Exempt channels count must be less than or equal to {AutoModRuleProperties.MaxExemptChannels}."); + + if (!args.Actions.IsSpecified || args.Actions.Value.Length == 0) + { + throw new ArgumentException(message: $"At least 1 action must be set for an auto moderation rule.", paramName: nameof(args.Actions)); + } + + if (args.Actions.Value.Any(x => x.TimeoutDuration.GetValueOrDefault().TotalSeconds > AutoModRuleProperties.MaxTimeoutSeconds)) + throw new ArgumentException(message: $"Field count must be less than or equal to {AutoModRuleProperties.MaxTimeoutSeconds}.", paramName: nameof(AutoModRuleActionProperties.TimeoutDuration)); + + if (args.Actions.Value.Any(x => x.CustomMessage.IsSpecified && x.CustomMessage.Value.Length > AutoModRuleProperties.MaxCustomBlockMessageLength)) + throw new ArgumentException(message: $"Custom message length must be less than or equal to {AutoModRuleProperties.MaxCustomBlockMessageLength}.", paramName: nameof(AutoModRuleActionProperties.CustomMessage)); + + #endregion + + var props = new CreateAutoModRuleParams + { + EventType = args.EventType.GetValueOrDefault(AutoModEventType.MessageSend), + Enabled = args.Enabled.GetValueOrDefault(true), + ExemptRoles = args.ExemptRoles.GetValueOrDefault(), + ExemptChannels = args.ExemptChannels.GetValueOrDefault(), + Name = args.Name.Value, + TriggerType = args.TriggerType.Value, + Actions = args.Actions.Value.Select(x => new AutoModAction + { + Metadata = new ActionMetadata + { + ChannelId = x.ChannelId ?? Optional.Unspecified, + DurationSeconds = (int?)x.TimeoutDuration?.TotalSeconds ?? Optional.Unspecified, + CustomMessage = x.CustomMessage, + }, + Type = x.Type + }).ToArray(), + TriggerMetadata = new TriggerMetadata + { + AllowList = args.AllowList, + KeywordFilter = args.KeywordFilter, + MentionLimit = args.MentionLimit, + Presets = args.Presets, + RegexPatterns = args.RegexPatterns, + }, + }; + + return client.ApiClient.CreateGuildAutoModRuleAsync(guild.Id, props, options); + } + + public static Task GetAutoModRuleAsync(ulong ruleId, IGuild guild, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.GetGuildAutoModRuleAsync(guild.Id, ruleId, options); + + public static Task GetAutoModRulesAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.GetGuildAutoModRulesAsync(guild.Id, options); + + public static Task ModifyRuleAsync(BaseDiscordClient client, IAutoModRule rule, Action func, RequestOptions options) + { + var args = new AutoModRuleProperties(); + func(args); + + var apiArgs = new API.Rest.ModifyAutoModRuleParams + { + Actions = args.Actions.IsSpecified ? args.Actions.Value.Select(x => new API.AutoModAction() + { + Type = x.Type, + Metadata = x.ChannelId.HasValue || x.TimeoutDuration.HasValue ? new API.ActionMetadata + { + ChannelId = x.ChannelId ?? Optional.Unspecified, + DurationSeconds = x.TimeoutDuration.HasValue ? (int)Math.Floor(x.TimeoutDuration.Value.TotalSeconds) : Optional.Unspecified, + CustomMessage = x.CustomMessage, + } : Optional.Unspecified + }).ToArray() : Optional.Unspecified, + Enabled = args.Enabled, + EventType = args.EventType, + ExemptChannels = args.ExemptChannels, + ExemptRoles = args.ExemptRoles, + Name = args.Name, + TriggerType = args.TriggerType, + TriggerMetadata = args.KeywordFilter.IsSpecified + || args.Presets.IsSpecified + || args.MentionLimit.IsSpecified + || args.RegexPatterns.IsSpecified + || args.AllowList.IsSpecified ? new API.TriggerMetadata + { + KeywordFilter = args.KeywordFilter.IsSpecified ? args.KeywordFilter : rule.KeywordFilter.ToArray(), + RegexPatterns = args.RegexPatterns.IsSpecified ? args.RegexPatterns : rule.RegexPatterns.ToArray(), + AllowList = args.AllowList.IsSpecified ? args.AllowList : rule.AllowList.ToArray(), + MentionLimit = args.MentionLimit.IsSpecified ? args.MentionLimit : rule.MentionTotalLimit ?? Optional.Unspecified, + Presets = args.Presets.IsSpecified ? args.Presets : rule.Presets.ToArray(), + } : Optional.Unspecified + }; + + return client.ApiClient.ModifyGuildAutoModRuleAsync(rule.GuildId, rule.Id, apiArgs, options); + } + + public static Task DeleteRuleAsync(BaseDiscordClient client, IAutoModRule rule, RequestOptions options) + => client.ApiClient.DeleteGuildAutoModRuleAsync(rule.GuildId, rule.Id, options); + #endregion + + #region Onboarding + + public static Task GetGuildOnboardingAsync(IGuild guild, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.GetGuildOnboardingAsync(guild.Id, options); + + public static Task ModifyGuildOnboardingAsync(IGuild guild, Action func, BaseDiscordClient client, RequestOptions options) + { + var props = new GuildOnboardingProperties(); + func(props); + + var args = new ModifyGuildOnboardingParams + { + DefaultChannelIds = props.ChannelIds.IsSpecified + ? props.ChannelIds.Value.ToArray() + : Optional.Unspecified, + Enabled = props.IsEnabled, + Mode = props.Mode, + Prompts = props.Prompts.IsSpecified ? props.Prompts.Value? + .Select(prompt => new GuildOnboardingPromptParams + { + Id = prompt.Id ?? 0, + Type = prompt.Type, + IsInOnboarding = prompt.IsInOnboarding, + IsRequired = prompt.IsRequired, + IsSingleSelect = prompt.IsSingleSelect, + Title = prompt.Title, + Options = prompt.Options? + .Select(option => new GuildOnboardingPromptOptionParams + { + Title = option.Title, + ChannelIds = option.ChannelIds?.ToArray(), + RoleIds = option.RoleIds?.ToArray(), + Description = option.Description, + EmojiName = option.Emoji.GetValueOrDefault(null)?.Name, + EmojiId = option.Emoji.GetValueOrDefault(null) is Emote emote ? emote.Id : null, + EmojiAnimated = option.Emoji.GetValueOrDefault(null) is Emote emt ? emt.Animated : null, + Id = option.Id ?? Optional.Unspecified, + }).ToArray(), + }).ToArray() + : Optional.Unspecified, + }; + + return client.ApiClient.ModifyGuildOnboardingAsync(guild.Id, args, options); + } + + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboarding.cs b/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboarding.cs new file mode 100644 index 0000000..7b44624 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboarding.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.GuildOnboarding; + +namespace Discord.Rest; + +/// +public class RestGuildOnboarding : IGuildOnboarding +{ + internal BaseDiscordClient Discord; + + /// + public ulong GuildId { get; private set; } + + /// + public RestGuild Guild { get; private set; } + + /// + public IReadOnlyCollection DefaultChannelIds { get; private set; } + + /// + public bool IsEnabled { get; private set; } + + /// + public GuildOnboardingMode Mode { get; private set; } + + /// + public bool IsBelowRequirements { get; private set; } + + /// + public IReadOnlyCollection Prompts { get; private set; } + + internal RestGuildOnboarding(BaseDiscordClient discord, Model model, RestGuild guild = null) + { + Discord = discord; + Guild = guild; + Update(model); + } + + internal void Update(Model model) + { + GuildId = model.GuildId; + DefaultChannelIds = model.DefaultChannelIds.ToImmutableArray(); + IsEnabled = model.Enabled; + Mode = model.Mode; + IsBelowRequirements = model.IsBelowRequirements; + Prompts = model.Prompts.Select(prompt => new RestGuildOnboardingPrompt(Discord, prompt.Id, prompt)).ToImmutableArray(); + } + + /// + public async Task ModifyAsync(Action props, RequestOptions options = null) + { + var model = await GuildHelper.ModifyGuildOnboardingAsync(Guild, props, Discord, options); + + Update(model); + } + + #region IGuildOnboarding + + /// + IReadOnlyCollection IGuildOnboarding.Prompts => Prompts; + + /// + IGuild IGuildOnboarding.Guild => Guild; + + #endregion +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboardingPrompt.cs b/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboardingPrompt.cs new file mode 100644 index 0000000..62308a8 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboardingPrompt.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.GuildOnboardingPrompt; + +namespace Discord.Rest; + +/// +public class RestGuildOnboardingPrompt : RestEntity, IGuildOnboardingPrompt +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public IReadOnlyCollection Options { get; private set; } + + /// + public string Title { get; private set; } + + /// + public bool IsSingleSelect { get; private set; } + + /// + public bool IsRequired { get; private set; } + + /// + public bool IsInOnboarding { get; private set; } + + /// + public GuildOnboardingPromptType Type { get; private set; } + + internal RestGuildOnboardingPrompt(BaseDiscordClient discord, ulong id, Model model) : base(discord, id) + { + Title = model.Title; + IsSingleSelect = model.IsSingleSelect; + IsInOnboarding = model.IsInOnboarding; + IsRequired = model.IsRequired; + Type = model.Type; + + Options = model.Options.Select(option => new RestGuildOnboardingPromptOption(discord, option.Id, option)).ToImmutableArray(); + } + + #region IGuildOnboardingPrompt + + /// + IReadOnlyCollection IGuildOnboardingPrompt.Options => Options; + + #endregion +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboardingPromptOption.cs b/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboardingPromptOption.cs new file mode 100644 index 0000000..513c836 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/Onboarding/RestGuildOnboardingPromptOption.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.GuildOnboardingPromptOption; + +namespace Discord.Rest; + +/// +public class RestGuildOnboardingPromptOption : RestEntity, IGuildOnboardingPromptOption +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public IReadOnlyCollection ChannelIds { get; private set; } + + /// + public IReadOnlyCollection RoleIds { get; private set; } + + /// + public IEmote Emoji { get; private set; } + + /// + public string Title { get; private set; } + + /// + public string Description { get; private set; } + + internal RestGuildOnboardingPromptOption(BaseDiscordClient discord, ulong id, Model model) : base(discord, id) + { + ChannelIds = model.ChannelIds.ToImmutableArray(); + RoleIds = model.RoleIds.ToImmutableArray(); + Title = model.Title; + Description = model.Description; + + if (model.Emoji.Id.HasValue) + { + Emoji = new Emote(model.Emoji.Id.Value, model.Emoji.Name, model.Emoji.Animated ?? false); + } + else if (!string.IsNullOrWhiteSpace(model.Emoji.Name)) + { + Emoji = new Emoji(model.Emoji.Name); + } + else + { + Emoji = null; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestAutoModRule.cs b/src/Discord.Net.Rest/Entities/Guilds/RestAutoModRule.cs new file mode 100644 index 0000000..2b7b07b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/RestAutoModRule.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +using Model = Discord.API.AutoModerationRule; + +namespace Discord.Rest; + +public class RestAutoModRule : RestEntity, IAutoModRule +{ + /// + public DateTimeOffset CreatedAt { get; private set; } + + /// + public ulong GuildId { get; private set; } + + /// + public string Name { get; private set; } + + /// + public ulong CreatorId { get; private set; } + + /// + public AutoModEventType EventType { get; private set; } + + /// + public AutoModTriggerType TriggerType { get; private set; } + + /// + public IReadOnlyCollection KeywordFilter { get; private set; } + + /// + public IReadOnlyCollection RegexPatterns { get; private set; } + + /// + public IReadOnlyCollection AllowList { get; private set; } + + /// + public IReadOnlyCollection Presets { get; private set; } + + /// + public int? MentionTotalLimit { get; private set; } + + /// + public IReadOnlyCollection Actions { get; private set; } + + /// + public bool Enabled { get; private set; } + + /// + public IReadOnlyCollection ExemptRoles { get; private set; } + + /// + public IReadOnlyCollection ExemptChannels { get; private set; } + + internal RestAutoModRule(BaseDiscordClient discord, ulong id) : base(discord, id) + { + + } + + internal static RestAutoModRule Create(BaseDiscordClient discord, Model model) + { + var entity = new RestAutoModRule(discord, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Name = model.Name; + CreatorId = model.CreatorId; + GuildId = model.GuildId; + + EventType = model.EventType; + TriggerType = model.TriggerType; + KeywordFilter = model.TriggerMetadata.KeywordFilter.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + Presets = model.TriggerMetadata.Presets.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + RegexPatterns = model.TriggerMetadata.RegexPatterns.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + AllowList = model.TriggerMetadata.AllowList.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + MentionTotalLimit = model.TriggerMetadata.MentionLimit.IsSpecified + ? model.TriggerMetadata.MentionLimit.Value + : null; + Actions = model.Actions.Select(x => new AutoModRuleAction( + x.Type, + x.Metadata.GetValueOrDefault()?.ChannelId.ToNullable(), + x.Metadata.GetValueOrDefault()?.DurationSeconds.ToNullable(), + x.Metadata.IsSpecified + ? x.Metadata.Value.CustomMessage.IsSpecified + ? x.Metadata.Value.CustomMessage.Value + : null + : null + )).ToImmutableArray(); + Enabled = model.Enabled; + ExemptRoles = model.ExemptRoles.ToImmutableArray(); + ExemptChannels = model.ExemptChannels.ToImmutableArray(); + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await GuildHelper.ModifyRuleAsync(Discord, this, func, options); + Update(model); + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => GuildHelper.DeleteRuleAsync(Discord, this, options); +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestBan.cs b/src/Discord.Net.Rest/Entities/Guilds/RestBan.cs new file mode 100644 index 0000000..a0cba62 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/RestBan.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using Model = Discord.API.Ban; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based ban object. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestBan : IBan + { + #region RestBan + /// + /// Gets the banned user. + /// + /// + /// A generic object that was banned. + /// + public RestUser User { get; } + /// + public string Reason { get; } + + internal RestBan(RestUser user, string reason) + { + User = user; + Reason = reason; + } + internal static RestBan Create(BaseDiscordClient client, Model model) + { + return new RestBan(RestUser.Create(client, model.User), model.Reason); + } + + /// + /// Gets the name of the banned user. + /// + /// + /// A string containing the name of the user that was banned. + /// + public override string ToString() => User.ToString(); + private string DebuggerDisplay => $"{User}: {Reason}"; + #endregion + + #region IBan + /// + IUser IBan.User => User; + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs new file mode 100644 index 0000000..6818ecd --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -0,0 +1,1754 @@ +using Discord.Audio; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Guild; +using WidgetModel = Discord.API.GuildWidget; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based guild/server. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestGuild : RestEntity, IGuild, IUpdateable + { + #region RestGuild + private ImmutableDictionary _roles; + private ImmutableArray _emotes; + private ImmutableArray _stickers; + + /// + public string Name { get; private set; } + /// + public int AFKTimeout { get; private set; } + /// + public bool IsWidgetEnabled { get; private set; } + /// + public VerificationLevel VerificationLevel { get; private set; } + /// + public MfaLevel MfaLevel { get; private set; } + /// + public DefaultMessageNotifications DefaultMessageNotifications { get; private set; } + /// + public ExplicitContentFilterLevel ExplicitContentFilter { get; private set; } + + /// + public ulong? AFKChannelId { get; private set; } + /// + public ulong? WidgetChannelId { get; private set; } + /// + public ulong? SafetyAlertsChannelId { get; private set; } + /// + public ulong? SystemChannelId { get; private set; } + /// + public ulong? RulesChannelId { get; private set; } + /// + public ulong? PublicUpdatesChannelId { get; private set; } + /// + public ulong OwnerId { get; private set; } + /// + public string VoiceRegionId { get; private set; } + /// + public string IconId { get; private set; } + /// + public string SplashId { get; private set; } + /// + public string DiscoverySplashId { get; private set; } + internal bool Available { get; private set; } + /// + public ulong? ApplicationId { get; private set; } + /// + public PremiumTier PremiumTier { get; private set; } + /// + public string BannerId { get; private set; } + /// + public string VanityURLCode { get; private set; } + /// + public SystemChannelMessageDeny SystemChannelFlags { get; private set; } + /// + public string Description { get; private set; } + /// + public int PremiumSubscriptionCount { get; private set; } + /// + public string PreferredLocale { get; private set; } + /// + public int? MaxPresences { get; private set; } + /// + public int? MaxMembers { get; private set; } + /// + public int? MaxVideoChannelUsers { get; private set; } + /// + public int? MaxStageVideoChannelUsers { get; private set; } + /// + public int? ApproximateMemberCount { get; private set; } + /// + public int? ApproximatePresenceCount { get; private set; } + /// + public int MaxBitrate + => GuildHelper.GetMaxBitrate(PremiumTier); + /// + public ulong MaxUploadLimit + => GuildHelper.GetUploadLimit(PremiumTier); + /// + public NsfwLevel NsfwLevel { get; private set; } + /// + public bool IsBoostProgressBarEnabled { get; private set; } + /// + public CultureInfo PreferredCulture { get; private set; } + /// + public GuildFeatures Features { get; private set; } + + /// + public GuildIncidentsData IncidentsData { get; private set; } + + /// + public GuildInventorySettings? InventorySettings { get; private set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public string IconUrl => CDN.GetGuildIconUrl(Id, IconId); + /// + public string SplashUrl => CDN.GetGuildSplashUrl(Id, SplashId); + /// + public string DiscoverySplashUrl => CDN.GetGuildDiscoverySplashUrl(Id, DiscoverySplashId); + /// + public string BannerUrl => CDN.GetGuildBannerUrl(Id, BannerId, ImageFormat.Auto); + + /// + /// Gets the built-in role containing all users in this guild. + /// + public RestRole EveryoneRole => GetRole(Id); + + /// + /// Gets a collection of all roles in this guild. + /// + public IReadOnlyCollection Roles => _roles.ToReadOnlyCollection(); + /// + public IReadOnlyCollection Emotes => _emotes; + public IReadOnlyCollection Stickers => _stickers; + + internal RestGuild(BaseDiscordClient client, ulong id) + : base(client, id) + { + } + internal static RestGuild Create(BaseDiscordClient discord, Model model) + { + var entity = new RestGuild(discord, model.Id); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + AFKChannelId = model.AFKChannelId; + if (model.WidgetChannelId.IsSpecified) + WidgetChannelId = model.WidgetChannelId.Value; + if (model.SafetyAlertsChannelId.IsSpecified) + SafetyAlertsChannelId = model.SafetyAlertsChannelId.Value; + SystemChannelId = model.SystemChannelId; + RulesChannelId = model.RulesChannelId; + PublicUpdatesChannelId = model.PublicUpdatesChannelId; + AFKTimeout = model.AFKTimeout; + if (model.WidgetEnabled.IsSpecified) + IsWidgetEnabled = model.WidgetEnabled.Value; + IconId = model.Icon; + Name = model.Name; + OwnerId = model.OwnerId; + VoiceRegionId = model.Region; + SplashId = model.Splash; + DiscoverySplashId = model.DiscoverySplash; + VerificationLevel = model.VerificationLevel; + MfaLevel = model.MfaLevel; + DefaultMessageNotifications = model.DefaultMessageNotifications; + ExplicitContentFilter = model.ExplicitContentFilter; + ApplicationId = model.ApplicationId; + PremiumTier = model.PremiumTier; + VanityURLCode = model.VanityURLCode; + BannerId = model.Banner; + SystemChannelFlags = model.SystemChannelFlags; + Description = model.Description; + PremiumSubscriptionCount = model.PremiumSubscriptionCount.GetValueOrDefault(); + NsfwLevel = model.NsfwLevel; + IncidentsData = model.IncidentsData is not null + ? new GuildIncidentsData { DmsDisabledUntil = model.IncidentsData.DmsDisabledUntil, InvitesDisabledUntil = model.IncidentsData.InvitesDisabledUntil } + : new GuildIncidentsData(); + + if (model.MaxPresences.IsSpecified) + MaxPresences = model.MaxPresences.Value ?? 25000; + if (model.MaxMembers.IsSpecified) + MaxMembers = model.MaxMembers.Value; + if (model.MaxVideoChannelUsers.IsSpecified) + MaxVideoChannelUsers = model.MaxVideoChannelUsers.Value; + if (model.MaxStageVideoChannelUsers.IsSpecified) + MaxStageVideoChannelUsers = model.MaxStageVideoChannelUsers.Value; + PreferredLocale = model.PreferredLocale; + PreferredCulture = new CultureInfo(PreferredLocale); + if (model.ApproximateMemberCount.IsSpecified) + ApproximateMemberCount = model.ApproximateMemberCount.Value; + if (model.ApproximatePresenceCount.IsSpecified) + ApproximatePresenceCount = model.ApproximatePresenceCount.Value; + if (model.IsBoostProgressBarEnabled.IsSpecified) + IsBoostProgressBarEnabled = model.IsBoostProgressBarEnabled.Value; + if (model.InventorySettings.IsSpecified) + InventorySettings = model.InventorySettings.Value is null ? null : new (model.InventorySettings.Value.IsEmojiPackCollectible.GetValueOrDefault(false)); + + if (model.Emojis != null) + { + var emotes = ImmutableArray.CreateBuilder(model.Emojis.Length); + for (int i = 0; i < model.Emojis.Length; i++) + emotes.Add(model.Emojis[i].ToEntity()); + _emotes = emotes.ToImmutableArray(); + } + else + _emotes = ImmutableArray.Create(); + + Features = model.Features; + + var roles = ImmutableDictionary.CreateBuilder(); + if (model.Roles != null) + { + for (int i = 0; i < model.Roles.Length; i++) + roles[model.Roles[i].Id] = RestRole.Create(Discord, this, model.Roles[i]); + } + _roles = roles.ToImmutable(); + + if (model.Stickers != null) + { + var stickers = ImmutableArray.CreateBuilder(); + for (int i = 0; i < model.Stickers.Length; i++) + { + var sticker = model.Stickers[i]; + + var entity = CustomSticker.Create(Discord, sticker, this, sticker.User.IsSpecified ? sticker.User.Value.Id : null); + + stickers.Add(entity); + } + + _stickers = stickers.ToImmutable(); + } + else + _stickers = ImmutableArray.Create(); + + Available = true; + } + internal void Update(WidgetModel model) + { + WidgetChannelId = model.ChannelId; + IsWidgetEnabled = model.Enabled; + } + #endregion + + #region General + /// + public async Task UpdateAsync(RequestOptions options = null) + => Update(await Discord.ApiClient.GetGuildAsync(Id, false, options).ConfigureAwait(false)); + /// + /// Updates this object's properties with its current state. + /// + /// + /// If true, and + /// will be updated as well. + /// + /// The options to be used when sending the request. + /// + /// If is true, and + /// will be updated as well. + /// + public async Task UpdateAsync(bool withCounts, RequestOptions options = null) + => Update(await Discord.ApiClient.GetGuildAsync(Id, withCounts, options).ConfigureAwait(false)); + /// + public Task DeleteAsync(RequestOptions options = null) + => GuildHelper.DeleteAsync(this, Discord, options); + + /// + /// is . + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await GuildHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + + /// + /// is . + public async Task ModifyWidgetAsync(Action func, RequestOptions options = null) + { + var model = await GuildHelper.ModifyWidgetAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + + /// + /// is . + public Task ReorderChannelsAsync(IEnumerable args, RequestOptions options = null) + { + var arr = args.ToArray(); + return GuildHelper.ReorderChannelsAsync(this, Discord, arr, options); + } + /// + public async Task ReorderRolesAsync(IEnumerable args, RequestOptions options = null) + { + var models = await GuildHelper.ReorderRolesAsync(this, Discord, args, options).ConfigureAwait(false); + foreach (var model in models) + { + var role = GetRole(model.Id); + role?.Update(model); + } + } + + /// + public Task LeaveAsync(RequestOptions options = null) + => GuildHelper.LeaveAsync(this, Discord, options); + + /// + public async Task ModifyIncidentActionsAsync(Action props, RequestOptions options = null) + { + IncidentsData = await GuildHelper.ModifyGuildIncidentActionsAsync(this, Discord, props, options); + + return IncidentsData; + } + + #endregion + + #region Interactions + /// + /// Deletes all slash commands in the current guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous delete operation. + /// + public Task DeleteSlashCommandsAsync(RequestOptions options = null) + => InteractionHelper.DeleteAllGuildCommandsAsync(Discord, Id, options); + + /// + /// Gets a collection of slash commands created by the current user in this guild. + /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// slash commands created by the current user. + /// + public Task> GetSlashCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null) + => GuildHelper.GetSlashCommandsAsync(this, Discord, withLocalizations, locale, options); + + /// + /// Gets a slash command in the current guild. + /// + /// The unique identifier of the slash command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a + /// slash command created by the current user. + /// + public Task GetSlashCommandAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetSlashCommandAsync(this, id, Discord, options); + #endregion + + #region Bans + + /// + public IAsyncEnumerable> GetBansAsync(int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null) + => GuildHelper.GetBansAsync(this, Discord, null, Direction.Before, limit, options); + + /// + public IAsyncEnumerable> GetBansAsync(ulong fromUserId, Direction dir, int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null) + => GuildHelper.GetBansAsync(this, Discord, fromUserId, dir, limit, options); + + /// + public IAsyncEnumerable> GetBansAsync(IUser fromUser, Direction dir, int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null) + => GuildHelper.GetBansAsync(this, Discord, fromUser.Id, dir, limit, options); + /// + /// Gets a ban object for a banned user. + /// + /// The banned user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a ban object, which + /// contains the user information and the reason for the ban; if the ban entry cannot be found. + /// + public Task GetBanAsync(IUser user, RequestOptions options = null) + => GuildHelper.GetBanAsync(this, Discord, user.Id, options); + /// + /// Gets a ban object for a banned user. + /// + /// The snowflake identifier for the banned user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a ban object, which + /// contains the user information and the reason for the ban; if the ban entry cannot be found. + /// + public Task GetBanAsync(ulong userId, RequestOptions options = null) + => GuildHelper.GetBanAsync(this, Discord, userId, options); + + /// + public Task AddBanAsync(IUser user, int pruneDays = 0, string reason = null, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, user.Id, pruneDays, reason, options); + /// + public Task AddBanAsync(ulong userId, int pruneDays = 0, string reason = null, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, userId, pruneDays, reason, options); + + /// + public Task BanUserAsync(IUser user, uint pruneSeconds = 0, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, user.Id, pruneSeconds, options); + /// + public Task BanUserAsync(ulong userId, uint pruneSeconds = 0, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, userId, pruneSeconds, options); + + /// + public Task RemoveBanAsync(IUser user, RequestOptions options = null) + => GuildHelper.RemoveBanAsync(this, Discord, user.Id, options); + /// + public Task RemoveBanAsync(ulong userId, RequestOptions options = null) + => GuildHelper.RemoveBanAsync(this, Discord, userId, options); + + /// + public Task BulkBanAsync(IEnumerable userIds, int? deleteMessageSeconds = null, RequestOptions options = null) + => GuildHelper.BulkBanAsync(this, Discord, userIds.ToArray(), deleteMessageSeconds, options); + #endregion + + #region Channels + /// + /// Gets a collection of all channels in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// generic channels found within this guild. + /// + public Task> GetChannelsAsync(RequestOptions options = null) + => GuildHelper.GetChannelsAsync(this, Discord, options); + + /// + /// Gets a channel in this guild. + /// + /// The snowflake identifier for the channel. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the generic channel + /// associated with the specified ; if none is found. + /// + public Task GetChannelAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetChannelAsync(this, Discord, id, options); + + /// + /// Gets a text channel in this guild. + /// + /// The snowflake identifier for the text channel. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel + /// associated with the specified ; if none is found. + /// + public async Task GetTextChannelAsync(ulong id, RequestOptions options = null) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, id, options).ConfigureAwait(false); + return channel as RestTextChannel; + } + + /// + /// Gets a collection of all text channels in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// message channels found within this guild. + /// + public async Task> GetTextChannelsAsync(RequestOptions options = null) + { + var channels = await GuildHelper.GetChannelsAsync(this, Discord, options).ConfigureAwait(false); + return channels.OfType().ToImmutableArray(); + } + + /// + /// Gets a forum channel in this guild. + /// + /// The snowflake identifier for the forum channel. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel + /// associated with the specified ; if none is found. + /// + public async Task GetForumChannelAsync(ulong id, RequestOptions options = null) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, id, options).ConfigureAwait(false); + return channel as RestForumChannel; + } + + /// + /// Gets a collection of all forum channels in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// forum channels found within this guild. + /// + public async Task> GetForumChannelsAsync(RequestOptions options = null) + { + var channels = await GuildHelper.GetChannelsAsync(this, Discord, options).ConfigureAwait(false); + return channels.OfType().ToImmutableArray(); + } + + /// + /// Gets a media channel in this guild. + /// + /// The snowflake identifier for the text channel. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the media channel + /// associated with the specified ; if none is found. + /// + public async Task GetMediaChannelAsync(ulong id, RequestOptions options = null) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, id, options).ConfigureAwait(false); + return channel as RestMediaChannel; + } + + /// + /// Gets a collection of all media channels in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// media channels found within this guild. + /// + public async Task> GetMediaChannelsAsync(RequestOptions options = null) + { + var channels = await GuildHelper.GetChannelsAsync(this, Discord, options).ConfigureAwait(false); + return channels.OfType().ToImmutableArray(); + } + + /// + /// Gets a thread channel in this guild. + /// + /// The snowflake identifier for the thread channel. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the thread channel associated + /// with the specified ; if none is found. + /// + public async Task GetThreadChannelAsync(ulong id, RequestOptions options = null) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, id, options).ConfigureAwait(false); + return channel as RestThreadChannel; + } + + /// + /// Gets a collection of all thread in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// threads found within this guild. + /// + public async Task> GetThreadChannelsAsync(RequestOptions options = null) + { + var channels = await GuildHelper.GetChannelsAsync(this, Discord, options).ConfigureAwait(false); + return channels.OfType().ToImmutableArray(); + } + + /// + /// Gets a voice channel in this guild. + /// + /// The snowflake identifier for the voice channel. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the voice channel associated + /// with the specified ; if none is found. + /// + public async Task GetVoiceChannelAsync(ulong id, RequestOptions options = null) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, id, options).ConfigureAwait(false); + return channel as RestVoiceChannel; + } + + /// + /// Gets a collection of all voice channels in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// voice channels found within this guild. + /// + public async Task> GetVoiceChannelsAsync(RequestOptions options = null) + { + var channels = await GuildHelper.GetChannelsAsync(this, Discord, options).ConfigureAwait(false); + return channels.OfType().ToImmutableArray(); + } + /// + /// Gets a stage channel in this guild + /// + /// The snowflake identifier for the stage channel. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the stage channel associated + /// with the specified ; if none is found. + /// + public async Task GetStageChannelAsync(ulong id, RequestOptions options = null) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, id, options).ConfigureAwait(false); + return channel as RestStageChannel; + } + + /// + /// Gets a collection of all stage channels in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// stage channels found within this guild. + /// + public async Task> GetStageChannelsAsync(RequestOptions options = null) + { + var channels = await GuildHelper.GetChannelsAsync(this, Discord, options).ConfigureAwait(false); + return channels.OfType().ToImmutableArray(); + } + + /// + /// Gets a collection of all category channels in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// category channels found within this guild. + /// + public async Task> GetCategoryChannelsAsync(RequestOptions options = null) + { + var channels = await GuildHelper.GetChannelsAsync(this, Discord, options).ConfigureAwait(false); + return channels.OfType().ToImmutableArray(); + } + + /// + /// Gets the AFK voice channel in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the voice channel that the + /// AFK users will be moved to after they have idled for too long; if none is set. + /// + public async Task GetAFKChannelAsync(RequestOptions options = null) + { + var afkId = AFKChannelId; + if (afkId.HasValue) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, afkId.Value, options).ConfigureAwait(false); + return channel as RestVoiceChannel; + } + return null; + } + + /// + /// Gets the first viewable text channel in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the first viewable text + /// channel in this guild; if none is found. + /// + public async Task GetDefaultChannelAsync(RequestOptions options = null) + { + var channels = await GetTextChannelsAsync(options).ConfigureAwait(false); + var user = await GetCurrentUserAsync(options).ConfigureAwait(false); + return channels + .Where(c => user.GetPermissions(c).ViewChannel) + .OrderBy(c => c.Position) + .FirstOrDefault(); + } + + /// + /// Gets the widget channel (i.e. the channel set in the guild's widget settings) in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the widget channel set + /// within the server's widget settings; if none is set. + /// + public Task GetWidgetChannelAsync(RequestOptions options = null) + { + var widgetChannelId = WidgetChannelId; + if (widgetChannelId.HasValue) + return GuildHelper.GetChannelAsync(this, Discord, widgetChannelId.Value, options); + return Task.FromResult(null); + } + + /// + /// Gets the text channel where guild notices such as welcome messages and boost events are posted. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel + /// where guild notices such as welcome messages and boost events are post; if none is found. + /// + public async Task GetSystemChannelAsync(RequestOptions options = null) + { + var systemId = SystemChannelId; + if (systemId.HasValue) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, systemId.Value, options).ConfigureAwait(false); + return channel as RestTextChannel; + } + return null; + } + + /// + /// Gets the text channel where Community guilds can display rules and/or guidelines. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel + /// where Community guilds can display rules and/or guidelines; if none is set. + /// + public async Task GetRulesChannelAsync(RequestOptions options = null) + { + var rulesChannelId = RulesChannelId; + if (rulesChannelId.HasValue) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, rulesChannelId.Value, options).ConfigureAwait(false); + return channel as RestTextChannel; + } + return null; + } + + /// + /// Gets the text channel where admins and moderators of Community guilds receive notices from Discord. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the text channel where + /// admins and moderators of Community guilds receive notices from Discord; if none is set. + /// + public async Task GetPublicUpdatesChannelAsync(RequestOptions options = null) + { + var publicUpdatesChannelId = PublicUpdatesChannelId; + if (publicUpdatesChannelId.HasValue) + { + var channel = await GuildHelper.GetChannelAsync(this, Discord, publicUpdatesChannelId.Value, options).ConfigureAwait(false); + return channel as RestTextChannel; + } + return null; + } + + /// + /// Creates a new text channel in this guild. + /// + /// + /// The following example creates a new text channel under an existing category named Wumpus with a set topic. + /// + /// var categories = await guild.GetCategoriesAsync(); + /// var targetCategory = categories.FirstOrDefault(x => x.Name == "wumpus"); + /// if (targetCategory == null) return; + /// await Context.Guild.CreateTextChannelAsync(name, x => + /// { + /// x.CategoryId = targetCategory.Id; + /// x.Topic = $"This channel was created at {DateTimeOffset.UtcNow} by {user}."; + /// }); + /// + /// + /// The new name for the text channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// text channel. + /// + public Task CreateTextChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateTextChannelAsync(this, Discord, name, options, func); + + /// + public Task CreateNewsChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateNewsChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a voice channel with the provided name. + /// + /// The name of the new channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// is . + /// + /// The created voice channel. + /// + public Task CreateVoiceChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateVoiceChannelAsync(this, Discord, name, options, func); + /// + /// Creates a new stage channel in this guild. + /// + /// The new name for the stage channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// stage channel. + /// + public Task CreateStageChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateStageChannelAsync(this, Discord, name, options, func); + /// + /// Creates a category channel with the provided name. + /// + /// The name of the new channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// is . + /// + /// The created category channel. + /// + public Task CreateCategoryChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateCategoryChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a new forum channel with the provided name. + /// + /// The name of the new channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// is . + /// + /// The created forum channel. + /// + public Task CreateForumChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateForumChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a new media channel in this guild. + /// + /// The new name for the media channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// media channel. + /// + public Task CreateMediaChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateMediaChannelAsync(this, Discord, name, options, func); + + /// + /// Gets a collection of all the voice regions this guild can access. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// voice regions the guild can access. + /// + public Task> GetVoiceRegionsAsync(RequestOptions options = null) + => GuildHelper.GetVoiceRegionsAsync(this, Discord, options); + #endregion + + #region Integrations + public Task> GetIntegrationsAsync(RequestOptions options = null) + => GuildHelper.GetIntegrationsAsync(this, Discord, options); + public Task DeleteIntegrationAsync(ulong id, RequestOptions options = null) + => GuildHelper.DeleteIntegrationAsync(this, Discord, id, options); + #endregion + + #region Invites + /// + /// Gets a collection of all invites in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// invite metadata, each representing information for an invite found within this guild. + /// + public Task> GetInvitesAsync(RequestOptions options = null) + => GuildHelper.GetInvitesAsync(this, Discord, options); + /// + /// Gets the vanity invite URL of this guild. + /// + /// The options to be used when sending the request. + /// + /// A partial metadata of the vanity invite found within this guild. + /// + public Task GetVanityInviteAsync(RequestOptions options = null) + => GuildHelper.GetVanityInviteAsync(this, Discord, options); + #endregion + + #region Roles + /// + /// Gets a role in this guild. + /// + /// The snowflake identifier for the role. + /// + /// A role that is associated with the specified ; if none is found. + /// + public RestRole GetRole(ulong id) + { + if (_roles.TryGetValue(id, out RestRole value)) + return value; + return null; + } + + /// + /// Creates a new role with the provided name. + /// + /// The new name for the role. + /// The guild permission that the role should possess. + /// The color of the role. + /// Whether the role is separated from others on the sidebar. + /// The options to be used when sending the request. + /// Whether the role can be mentioned. + /// The icon for the role. + /// The unicode emoji to be used as an icon for the role. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// role. + /// + public async Task CreateRoleAsync(string name, GuildPermissions? permissions = default(GuildPermissions?), Color? color = default(Color?), + bool isHoisted = false, bool isMentionable = false, RequestOptions options = null, Image? icon = null, Emoji emoji = null) + { + var role = await GuildHelper.CreateRoleAsync(this, Discord, name, permissions, color, isHoisted, isMentionable, options, icon, emoji).ConfigureAwait(false); + _roles = _roles.Add(role.Id, role); + return role; + } + #endregion + + #region Users + /// + /// Gets a collection of all users in this guild. + /// + /// + /// This method retrieves all users found within this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users found within this guild. + /// + public IAsyncEnumerable> GetUsersAsync(RequestOptions options = null) + => GuildHelper.GetUsersAsync(this, Discord, null, null, options); + + /// + public Task AddGuildUserAsync(ulong id, string accessToken, Action func = null, RequestOptions options = null) + => GuildHelper.AddGuildUserAsync(this, Discord, id, accessToken, func, options); + + /// + /// Gets a user from this guild. + /// + /// + /// This method retrieves a user found within this guild. + /// + /// The snowflake identifier of the user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the guild user + /// associated with the specified ; if none is found. + /// + public Task GetUserAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetUserAsync(this, Discord, id, options); + + /// + /// Gets the current user for this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the currently logged-in + /// user within this guild. + /// + public Task GetCurrentUserAsync(RequestOptions options = null) + => GuildHelper.GetUserAsync(this, Discord, Discord.CurrentUser.Id, options); + + /// + /// Gets the owner of this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the owner of this guild. + /// + public Task GetOwnerAsync(RequestOptions options = null) + => GuildHelper.GetUserAsync(this, Discord, OwnerId, options); + + /// + public Task PruneUsersAsync(int days = 30, bool simulate = false, RequestOptions options = null, IEnumerable includeRoleIds = null) + => GuildHelper.PruneUsersAsync(this, Discord, days, simulate, options, includeRoleIds); + + /// + /// Gets a collection of users in this guild that the name or nickname starts with the + /// provided at . + /// + /// + /// The can not be higher than . + /// + /// The partial name or nickname to search. + /// The maximum number of users to be gotten. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users that the name or nickname starts with the provided at . + /// + public Task> SearchUsersAsync(string query, int limit = DiscordConfig.MaxUsersPerBatch, RequestOptions options = null) + => GuildHelper.SearchUsersAsync(this, Discord, query, limit, options); + #endregion + + #region Audit logs + /// + /// Gets the specified number of audit log entries for this guild. + /// + /// The number of audit log entries to fetch. + /// The options to be used when sending the request. + /// The audit log entry ID to get entries before. + /// The type of actions to filter. + /// The user ID to filter entries for. + /// The audit log entry ID to get entries after. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of the requested audit log entries. + /// + public IAsyncEnumerable> GetAuditLogsAsync(int limit, RequestOptions options = null, ulong? beforeId = null, ulong? userId = null, ActionType? actionType = null, ulong? afterId = null) + => GuildHelper.GetAuditLogsAsync(this, Discord, beforeId, limit, options, userId: userId, actionType: actionType, afterId: afterId); + #endregion + + #region Webhooks + /// + /// Gets a webhook found within this guild. + /// + /// The identifier for the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the webhook with the + /// specified ; if none is found. + /// + public Task GetWebhookAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetWebhookAsync(this, Discord, id, options); + + /// + /// Gets a collection of all webhook from this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of webhooks found within the guild. + /// + public Task> GetWebhooksAsync(RequestOptions options = null) + => GuildHelper.GetWebhooksAsync(this, Discord, options); + #endregion + + #region Interactions + /// + /// Gets this guilds slash commands + /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of application commands found within the guild. + /// + public Task> GetApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null) + => ClientHelper.GetGuildApplicationCommandsAsync(Discord, Id, withLocalizations, locale, options); + + /// + /// Gets an application command within this guild with the specified id. + /// + /// The id of the application command to get. + /// The options to be used when sending the request. + /// + /// A ValueTask that represents the asynchronous get operation. The task result contains a + /// if found, otherwise . + /// + public Task GetApplicationCommandAsync(ulong id, RequestOptions options = null) + => ClientHelper.GetGuildApplicationCommandAsync(Discord, id, Id, options); + + /// + /// Creates an application command within this guild. + /// + /// The properties to use when creating the command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the command that was created. + /// + public async Task CreateApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options = null) + { + var model = await InteractionHelper.CreateGuildCommandAsync(Discord, Id, properties, options); + + return RestGuildCommand.Create(Discord, model, Id); + } + /// + /// Overwrites the application commands within this guild. + /// + /// A collection of properties to use when creating the commands. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains a collection of commands that was created. + /// + public async Task> BulkOverwriteApplicationCommandsAsync(ApplicationCommandProperties[] properties, + RequestOptions options = null) + { + var models = await InteractionHelper.BulkOverwriteGuildCommandsAsync(Discord, Id, properties, options); + + return models.Select(x => RestGuildCommand.Create(Discord, x, Id)).ToImmutableArray(); + } + + /// + /// Returns the name of the guild. + /// + /// + /// The name of the guild. + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id})"; + #endregion + + #region Emotes + /// + public Task> GetEmotesAsync(RequestOptions options = null) + => GuildHelper.GetEmotesAsync(this, Discord, options); + /// + public Task GetEmoteAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetEmoteAsync(this, Discord, id, options); + /// + public Task CreateEmoteAsync(string name, Image image, Optional> roles = default(Optional>), RequestOptions options = null) + => GuildHelper.CreateEmoteAsync(this, Discord, name, image, roles, options); + /// + /// is . + public Task ModifyEmoteAsync(GuildEmote emote, Action func, RequestOptions options = null) + => GuildHelper.ModifyEmoteAsync(this, Discord, emote.Id, func, options); + /// + /// Moves the user to the voice channel. + /// + /// The user to move. + /// the channel where the user gets moved to. + /// A task that represents the asynchronous operation for moving a user. + public Task MoveAsync(IGuildUser user, IVoiceChannel targetChannel) + => user.ModifyAsync(x => x.Channel = new Optional(targetChannel)); + /// + public Task DeleteEmoteAsync(GuildEmote emote, RequestOptions options = null) + => GuildHelper.DeleteEmoteAsync(this, Discord, emote.Id, options); + #endregion + + #region Stickers + /// + /// Creates a new sticker in this guild. + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The image of the new emote. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + public async Task CreateStickerAsync(string name, Image image, IEnumerable tags, string description = null, RequestOptions options = null) + { + var model = await GuildHelper.CreateStickerAsync(Discord, this, name, image, tags, description, options).ConfigureAwait(false); + + return CustomSticker.Create(Discord, model, this, model.User.IsSpecified ? model.User.Value.Id : null); + } + /// + /// Creates a new sticker in this guild + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The path of the file to upload. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + public async Task CreateStickerAsync(string name, string path, IEnumerable tags, string description = null, + RequestOptions options = null) + { + using var fs = File.OpenRead(path); + return await CreateStickerAsync(name, fs, Path.GetFileName(fs.Name), tags, description,options); + } + + /// + /// Creates a new sticker in this guild + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The stream containing the file data. + /// The name of the file with the extension, ex: image.png. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + public async Task CreateStickerAsync(string name, Stream stream, string filename, IEnumerable tags, + string description = null, RequestOptions options = null) + { + var model = await GuildHelper.CreateStickerAsync(Discord, this, name, stream, filename, tags, description, options).ConfigureAwait(false); + + return CustomSticker.Create(Discord, model, this, model.User.IsSpecified ? model.User.Value.Id : null); + } + /// + /// Gets a specific sticker within this guild. + /// + /// The id of the sticker to get. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the sticker found with the + /// specified ; if none is found. + /// + public async Task GetStickerAsync(ulong id, RequestOptions options = null) + { + var model = await Discord.ApiClient.GetGuildStickerAsync(Id, id, options).ConfigureAwait(false); + + if (model == null) + return null; + + return CustomSticker.Create(Discord, model, this, model.User.IsSpecified ? model.User.Value.Id : null); + } + /// + /// Gets a collection of all stickers within this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of stickers found within the guild. + /// + public async Task> GetStickersAsync(RequestOptions options = null) + { + var models = await Discord.ApiClient.ListGuildStickersAsync(Id, options).ConfigureAwait(false); + + if (models.Length == 0) + return null; + + List stickers = new List(); + + foreach (var model in models) + { + var entity = CustomSticker.Create(Discord, model, this, model.User.IsSpecified ? model.User.Value.Id : null); + stickers.Add(entity); + } + + return stickers.ToImmutableArray(); + } + /// + /// Deletes a sticker within this guild. + /// + /// The sticker to delete. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + public Task DeleteStickerAsync(CustomSticker sticker, RequestOptions options = null) + => sticker.DeleteAsync(options); + #endregion + + #region Guild Events + + /// + /// Gets an event within this guild. + /// + /// The snowflake identifier for the event. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. + /// + public Task GetEventAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetGuildEventAsync(Discord, id, this, options); + + /// + /// Gets all active events within this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. + /// + public Task> GetEventsAsync(RequestOptions options = null) + => GuildHelper.GetGuildEventsAsync(Discord, this, options); + + /// + /// Creates an event within this guild. + /// + /// The name of the event. + /// The privacy level of the event. + /// The start time of the event. + /// The type of the event. + /// The description of the event. + /// The end time of the event. + /// + /// The channel id of the event. + /// + /// The event must have a type of or + /// in order to use this property. + /// + /// + /// The location of the event; links are supported + /// The optional banner image for the event. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous create operation. + /// + public Task CreateEventAsync( + string name, + DateTimeOffset startTime, + GuildScheduledEventType type, + GuildScheduledEventPrivacyLevel privacyLevel = GuildScheduledEventPrivacyLevel.Private, + string description = null, + DateTimeOffset? endTime = null, + ulong? channelId = null, + string location = null, + Image? coverImage = null, + RequestOptions options = null) + => GuildHelper.CreateGuildEventAsync(Discord, this, name, privacyLevel, startTime, type, description, endTime, channelId, location, coverImage, options); + + #endregion + + #region AutoMod + + + /// + public async Task GetAutoModRuleAsync(ulong ruleId, RequestOptions options = null) + { + var rule = await GuildHelper.GetAutoModRuleAsync(ruleId, this, Discord, options); + return RestAutoModRule.Create(Discord, rule); + } + + /// + public async Task GetAutoModRulesAsync(RequestOptions options = null) + { + var rules = await GuildHelper.GetAutoModRulesAsync(this, Discord, options); + return rules.Select(x => RestAutoModRule.Create(Discord, x)).ToArray(); + } + + /// + public async Task CreateAutoModRuleAsync(Action props, RequestOptions options = null) + { + var rule = await GuildHelper.CreateAutoModRuleAsync(this, props, Discord, options); + + return RestAutoModRule.Create(Discord, rule); + } + + + #endregion + + #region Onboarding + + /// + public async Task GetOnboardingAsync(RequestOptions options = null) + { + var model = await GuildHelper.GetGuildOnboardingAsync(this, Discord, options); + + return new RestGuildOnboarding(Discord, model, this); + } + + /// + public async Task ModifyOnboardingAsync(Action props, RequestOptions options = null) + { + var model = await GuildHelper.ModifyGuildOnboardingAsync(this, props, Discord, options); + + return new RestGuildOnboarding(Discord, model, this); + } + + #endregion + + #region IGuild + /// + bool IGuild.Available => Available; + /// + IAudioClient IGuild.AudioClient => null; + /// + IRole IGuild.EveryoneRole => EveryoneRole; + /// + IReadOnlyCollection IGuild.Roles => Roles; + + IReadOnlyCollection IGuild.Stickers => Stickers; + /// + async Task IGuild.CreateEventAsync(string name, DateTimeOffset startTime, GuildScheduledEventType type, GuildScheduledEventPrivacyLevel privacyLevel, string description, DateTimeOffset? endTime, ulong? channelId, string location, Image? coverImage, RequestOptions options) + => await CreateEventAsync(name, startTime, type, privacyLevel, description, endTime, channelId, location, coverImage, options).ConfigureAwait(false); + /// + async Task IGuild.GetEventAsync(ulong id, RequestOptions options) + => await GetEventAsync(id, options).ConfigureAwait(false); + /// + async Task> IGuild.GetEventsAsync(RequestOptions options) + => await GetEventsAsync(options).ConfigureAwait(false); + /// + IAsyncEnumerable> IGuild.GetBansAsync(int limit, RequestOptions options) + => GetBansAsync(limit, options); + /// + IAsyncEnumerable> IGuild.GetBansAsync(ulong fromUserId, Direction dir, int limit, RequestOptions options) + => GetBansAsync(fromUserId, dir, limit, options); + /// + IAsyncEnumerable> IGuild.GetBansAsync(IUser fromUser, Direction dir, int limit, RequestOptions options) + => GetBansAsync(fromUser, dir, limit, options); + /// + async Task IGuild.GetBanAsync(IUser user, RequestOptions options) + => await GetBanAsync(user, options).ConfigureAwait(false); + /// + async Task IGuild.GetBanAsync(ulong userId, RequestOptions options) + => await GetBanAsync(userId, options).ConfigureAwait(false); + + /// + async Task> IGuild.GetChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetChannelsAsync(options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + /// + async Task IGuild.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + async Task> IGuild.GetTextChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetTextChannelsAsync(options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + /// + async Task IGuild.GetTextChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetTextChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + + /// + async Task IGuild.GetForumChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetForumChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + + /// + async Task> IGuild.GetForumChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetForumChannelsAsync(options).ConfigureAwait(false); + else + return null; + } + + /// + async Task IGuild.GetMediaChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetMediaChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + + /// + async Task> IGuild.GetMediaChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetMediaChannelsAsync(options).ConfigureAwait(false); + else + return null; + } + + /// + async Task IGuild.GetThreadChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetThreadChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + async Task> IGuild.GetThreadChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetThreadChannelsAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task> IGuild.GetVoiceChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetVoiceChannelsAsync(options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + /// + async Task> IGuild.GetCategoriesAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetCategoryChannelsAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetStageChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetStageChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + async Task> IGuild.GetStageChannelsAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetStageChannelsAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetVoiceChannelAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetVoiceChannelAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetAFKChannelAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetAFKChannelAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetDefaultChannelAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetDefaultChannelAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetWidgetChannelAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetWidgetChannelAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetSystemChannelAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetSystemChannelAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetRulesChannelAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetRulesChannelAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetPublicUpdatesChannelAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetPublicUpdatesChannelAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.CreateTextChannelAsync(string name, Action func, RequestOptions options) + => await CreateTextChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateNewsChannelAsync(string name, Action func, RequestOptions options) + => await CreateNewsChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateVoiceChannelAsync(string name, Action func, RequestOptions options) + => await CreateVoiceChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateStageChannelAsync(string name, Action func, RequestOptions options) + => await CreateStageChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateCategoryAsync(string name, Action func, RequestOptions options) + => await CreateCategoryChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateForumChannelAsync(string name, Action func, RequestOptions options) + => await CreateForumChannelAsync(name, func, options).ConfigureAwait(false); + + /// + async Task IGuild.CreateMediaChannelAsync(string name, Action func, RequestOptions options) + => await CreateMediaChannelAsync(name, func, options).ConfigureAwait(false); + + /// + async Task> IGuild.GetVoiceRegionsAsync(RequestOptions options) + => await GetVoiceRegionsAsync(options).ConfigureAwait(false); + + /// + async Task> IGuild.GetIntegrationsAsync(RequestOptions options) + => await GetIntegrationsAsync(options).ConfigureAwait(false); + /// + Task IGuild.DeleteIntegrationAsync(ulong id, RequestOptions options) + => DeleteIntegrationAsync(id, options); + + /// + async Task> IGuild.GetInvitesAsync(RequestOptions options) + => await GetInvitesAsync(options).ConfigureAwait(false); + /// + async Task IGuild.GetVanityInviteAsync(RequestOptions options) + => await GetVanityInviteAsync(options).ConfigureAwait(false); + + /// + IRole IGuild.GetRole(ulong id) + => GetRole(id); + /// + async Task IGuild.CreateRoleAsync(string name, GuildPermissions? permissions, Color? color, bool isHoisted, RequestOptions options) + => await CreateRoleAsync(name, permissions, color, isHoisted, false, options).ConfigureAwait(false); + /// + async Task IGuild.CreateRoleAsync(string name, GuildPermissions? permissions, Color? color, bool isHoisted, bool isMentionable, RequestOptions options, Image? icon, Emoji emoji) + => await CreateRoleAsync(name, permissions, color, isHoisted, isMentionable, options, icon, emoji).ConfigureAwait(false); + + /// + async Task IGuild.AddGuildUserAsync(ulong userId, string accessToken, Action func, RequestOptions options) + => await AddGuildUserAsync(userId, accessToken, func, options); + + /// + /// Disconnects the user from its current voice channel + /// + /// The user to disconnect. + /// A task that represents the asynchronous operation for disconnecting a user. + Task IGuild.DisconnectAsync(IGuildUser user) + => user.ModifyAsync(x => x.Channel = null); + + /// + async Task IGuild.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetUserAsync(id, options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetCurrentUserAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetCurrentUserAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task IGuild.GetOwnerAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await GetOwnerAsync(options).ConfigureAwait(false); + else + return null; + } + /// + async Task> IGuild.GetUsersAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return (await GetUsersAsync(options).FlattenAsync().ConfigureAwait(false)).ToImmutableArray(); + else + return ImmutableArray.Create(); + } + /// + /// Downloading users is not supported for a REST-based guild. + Task IGuild.DownloadUsersAsync() => + throw new NotSupportedException(); + /// + async Task> IGuild.SearchUsersAsync(string query, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await SearchUsersAsync(query, limit, options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + + async Task> IGuild.GetAuditLogsAsync(int limit, CacheMode cacheMode, RequestOptions options, + ulong? beforeId, ulong? userId, ActionType? actionType, ulong? afterId) + { + if (cacheMode == CacheMode.AllowDownload) + return (await GetAuditLogsAsync(limit, options, beforeId: beforeId, userId: userId, actionType: actionType, afterId: afterId).FlattenAsync().ConfigureAwait(false)).ToImmutableArray(); + else + return ImmutableArray.Create(); + } + + /// + async Task IGuild.GetWebhookAsync(ulong id, RequestOptions options) + => await GetWebhookAsync(id, options).ConfigureAwait(false); + /// + async Task> IGuild.GetWebhooksAsync(RequestOptions options) + => await GetWebhooksAsync(options).ConfigureAwait(false); + /// + async Task> IGuild.GetApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) + => await GetApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false); + /// + async Task IGuild.CreateStickerAsync(string name, Image image, IEnumerable tags, string description, RequestOptions options) + => await CreateStickerAsync(name, image, tags, description, options); + /// + async Task IGuild.CreateStickerAsync(string name, Stream stream, string filename, IEnumerable tags, string description, RequestOptions options) + => await CreateStickerAsync(name, stream, filename, tags, description, options); + /// + async Task IGuild.CreateStickerAsync(string name, string path, IEnumerable tags, string description, RequestOptions options) + => await CreateStickerAsync(name, path, tags, description, options); + /// + async Task IGuild.GetStickerAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode != CacheMode.AllowDownload) + return null; + + return await GetStickerAsync(id, options); + } + /// + async Task> IGuild.GetStickersAsync(CacheMode mode, RequestOptions options) + { + if (mode != CacheMode.AllowDownload) + return null; + + return await GetStickersAsync(options); + } + /// + Task IGuild.DeleteStickerAsync(ICustomSticker sticker, RequestOptions options) + => sticker.DeleteAsync(); + /// + async Task IGuild.CreateApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options) + => await CreateApplicationCommandAsync(properties, options); + /// + async Task> IGuild.BulkOverwriteApplicationCommandsAsync(ApplicationCommandProperties[] properties, + RequestOptions options) + => await BulkOverwriteApplicationCommandsAsync(properties, options); + /// + async Task IGuild.GetApplicationCommandAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + { + return await GetApplicationCommandAsync(id, options); + } + else + return null; + } + + /// + public Task GetWelcomeScreenAsync(RequestOptions options = null) + => GuildHelper.GetWelcomeScreenAsync(this, Discord, options); + + /// + public Task ModifyWelcomeScreenAsync(bool enabled, WelcomeScreenChannelProperties[] channels, string description = null, RequestOptions options = null) + => GuildHelper.ModifyWelcomeScreenAsync(enabled, description, channels, this, Discord, options); + + + /// + async Task IGuild.GetAutoModRuleAsync(ulong ruleId, RequestOptions options) + => await GetAutoModRuleAsync(ruleId, options).ConfigureAwait(false); + + /// + async Task IGuild.GetAutoModRulesAsync(RequestOptions options) + => await GetAutoModRulesAsync(options).ConfigureAwait(false); + + /// + async Task IGuild.CreateAutoModRuleAsync(Action props, RequestOptions options) + => await CreateAutoModRuleAsync(props, options).ConfigureAwait(false); + + /// + async Task IGuild.GetOnboardingAsync(RequestOptions options) + => await GetOnboardingAsync(options); + + /// + async Task IGuild.ModifyOnboardingAsync(Action props, RequestOptions options) + => await ModifyOnboardingAsync(props, options); + + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuildEvent.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuildEvent.cs new file mode 100644 index 0000000..5979198 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuildEvent.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.GuildScheduledEvent; + +namespace Discord.Rest +{ + public class RestGuildEvent : RestEntity, IGuildScheduledEvent + { + /// + public IGuild Guild { get; private set; } + + /// + public ulong GuildId { get; private set; } + + /// + public ulong? ChannelId { get; private set; } + + /// + public IUser Creator { get; private set; } + + /// + public ulong CreatorId { get; private set; } + + /// + public string Name { get; private set; } + + /// + public string Description { get; private set; } + + /// + public string CoverImageId { get; private set; } + + /// + public DateTimeOffset StartTime { get; private set; } + + /// + public DateTimeOffset? EndTime { get; private set; } + + /// + public GuildScheduledEventPrivacyLevel PrivacyLevel { get; private set; } + + /// + public GuildScheduledEventStatus Status { get; private set; } + + /// + public GuildScheduledEventType Type { get; private set; } + + /// + public ulong? EntityId { get; private set; } + + /// + public string Location { get; private set; } + + /// + public int? UserCount { get; private set; } + + internal RestGuildEvent(BaseDiscordClient client, IGuild guild, ulong id) + : base(client, id) + { + Guild = guild; + } + + internal static RestGuildEvent Create(BaseDiscordClient client, IGuild guild, Model model) + { + var entity = new RestGuildEvent(client, guild, model.Id); + entity.Update(model); + return entity; + } + + internal static RestGuildEvent Create(BaseDiscordClient client, IGuild guild, IUser creator, Model model) + { + var entity = new RestGuildEvent(client, guild, model.Id); + entity.Update(model, creator); + return entity; + } + + internal void Update(Model model, IUser creator) + { + Update(model); + Creator = creator; + CreatorId = creator.Id; + } + + internal void Update(Model model) + { + if (model.Creator.IsSpecified) + { + Creator = RestUser.Create(Discord, model.Creator.Value); + } + + CreatorId = model.CreatorId.GetValueOrDefault(null) ?? 0; // should be changed? + ChannelId = model.ChannelId.IsSpecified ? model.ChannelId.Value : null; + Name = model.Name; + Description = model.Description.GetValueOrDefault(); + StartTime = model.ScheduledStartTime; + EndTime = model.ScheduledEndTime; + PrivacyLevel = model.PrivacyLevel; + Status = model.Status; + Type = model.EntityType; + EntityId = model.EntityId; + Location = model.EntityMetadata?.Location.GetValueOrDefault(); + UserCount = model.UserCount.ToNullable(); + CoverImageId = model.Image; + GuildId = model.GuildId; + } + + /// + public string GetCoverImageUrl(ImageFormat format = ImageFormat.Auto, ushort size = 1024) + => CDN.GetEventCoverImageUrl(GuildId, Id, CoverImageId, format, size); + + /// + public Task StartAsync(RequestOptions options = null) + => ModifyAsync(x => x.Status = GuildScheduledEventStatus.Active); + + /// + public Task EndAsync(RequestOptions options = null) + => ModifyAsync(x => x.Status = Status == GuildScheduledEventStatus.Scheduled + ? GuildScheduledEventStatus.Cancelled + : GuildScheduledEventStatus.Completed); + + /// + public Task DeleteAsync(RequestOptions options = null) + => GuildHelper.DeleteEventAsync(Discord, this, options); + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await GuildHelper.ModifyGuildEventAsync(Discord, func, this, options).ConfigureAwait(false); + Update(model); + } + + /// + /// Gets a collection of N users interested in the event. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// This method will attempt to fetch all users that are interested in the event. + /// The library will attempt to split up the requests according to and . + /// In other words, if there are 300 users, and the constant + /// is 100, the request will be split into 3 individual requests; thus returning 3 individual asynchronous + /// responses, hence the need of flattening. + /// + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + public IAsyncEnumerable> GetUsersAsync(RequestOptions options = null) + => GuildHelper.GetEventUsersAsync(Discord, this, null, null, options); + + /// + /// Gets a collection of N users interested in the event. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual users as a + /// collection. + /// + /// + /// Do not fetch too many users at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// This method will attempt to fetch the number of users specified under around + /// the user depending on the . The library will + /// attempt to split up the requests according to your and + /// . In other words, should the user request 500 users, + /// and the constant is 100, the request will + /// be split into 5 individual requests; thus returning 5 individual asynchronous responses, hence the need + /// of flattening. + /// + /// The ID of the starting user to get the users from. + /// The direction of the users to be gotten from. + /// The numbers of users to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + public IAsyncEnumerable> GetUsersAsync(ulong fromUserId, Direction dir, int limit = DiscordConfig.MaxGuildEventUsersPerBatch, RequestOptions options = null) + => GuildHelper.GetEventUsersAsync(Discord, this, fromUserId, dir, limit, options); + + #region IGuildScheduledEvent + + /// + IAsyncEnumerable> IGuildScheduledEvent.GetUsersAsync(RequestOptions options) + => GetUsersAsync(options); + /// + IAsyncEnumerable> IGuildScheduledEvent.GetUsersAsync(ulong fromUserId, Direction dir, int limit, RequestOptions options) + => GetUsersAsync(fromUserId, dir, limit, options); + + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuildWidget.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuildWidget.cs new file mode 100644 index 0000000..065739c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuildWidget.cs @@ -0,0 +1,25 @@ +using System.Diagnostics; +using Model = Discord.API.GuildWidget; + +namespace Discord.Rest +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct RestGuildWidget + { + public bool IsEnabled { get; private set; } + public ulong? ChannelId { get; private set; } + + internal RestGuildWidget(bool isEnabled, ulong? channelId) + { + ChannelId = channelId; + IsEnabled = isEnabled; + } + internal static RestGuildWidget Create(Model model) + { + return new RestGuildWidget(model.Enabled, model.ChannelId); + } + + public override string ToString() => ChannelId?.ToString() ?? "Unknown"; + private string DebuggerDisplay => $"{ChannelId} ({(IsEnabled ? "Enabled" : "Disabled")})"; + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestUserGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestUserGuild.cs new file mode 100644 index 0000000..605319c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/RestUserGuild.cs @@ -0,0 +1,71 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.UserGuild; + +namespace Discord.Rest +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestUserGuild : RestEntity, IUserGuild + { + private string _iconId; + + /// + public string Name { get; private set; } + /// + public bool IsOwner { get; private set; } + /// + public GuildPermissions Permissions { get; private set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public string IconUrl => CDN.GetGuildIconUrl(Id, _iconId); + /// + public GuildFeatures Features { get; private set; } + + /// + public int? ApproximateMemberCount { get; private set; } + + /// + public int? ApproximatePresenceCount { get; private set; } + + internal RestUserGuild(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + internal static RestUserGuild Create(BaseDiscordClient discord, Model model) + { + var entity = new RestUserGuild(discord, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + _iconId = model.Icon; + IsOwner = model.Owner; + Name = model.Name; + Permissions = new GuildPermissions(model.Permissions); + Features = model.Features; + ApproximateMemberCount = model.ApproximateMemberCount.IsSpecified ? model.ApproximateMemberCount.Value : null; + ApproximatePresenceCount = model.ApproximatePresenceCount.IsSpecified ? model.ApproximatePresenceCount.Value : null; + } + + public Task LeaveAsync(RequestOptions options = null) + => Discord.ApiClient.LeaveGuildAsync(Id, options); + + public async Task GetCurrentUserGuildMemberAsync(RequestOptions options = null) + { + var user = await Discord.ApiClient.GetCurrentUserGuildMember(Id, options); + return RestGuildUser.Create(Discord, null, user, Id); + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => Discord.ApiClient.DeleteGuildAsync(Id, options); + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id}{(IsOwner ? ", Owned" : "")})"; + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestVoiceRegion.cs b/src/Discord.Net.Rest/Entities/Guilds/RestVoiceRegion.cs new file mode 100644 index 0000000..a363f05 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Guilds/RestVoiceRegion.cs @@ -0,0 +1,46 @@ +using Discord.Rest; +using System.Diagnostics; +using Model = Discord.API.VoiceRegion; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based voice region. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class RestVoiceRegion : RestEntity, IVoiceRegion + { + /// + public string Name { get; private set; } + /// + public bool IsVip { get; private set; } + /// + public bool IsOptimal { get; private set; } + /// + public bool IsDeprecated { get; private set; } + /// + public bool IsCustom { get; private set; } + + internal RestVoiceRegion(BaseDiscordClient client, string id) + : base(client, id) + { + } + internal static RestVoiceRegion Create(BaseDiscordClient client, Model model) + { + var entity = new RestVoiceRegion(client, model.Id); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + Name = model.Name; + IsVip = model.IsVip; + IsOptimal = model.IsOptimal; + IsDeprecated = model.IsDeprecated; + IsCustom = model.IsCustom; + } + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id}{(IsVip ? ", VIP" : "")}{(IsOptimal ? ", Optimal" : "")})"; + } +} diff --git a/src/Discord.Net.Rest/Entities/Integrations/RestIntegration.cs b/src/Discord.Net.Rest/Entities/Integrations/RestIntegration.cs new file mode 100644 index 0000000..efc98e0 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Integrations/RestIntegration.cs @@ -0,0 +1,100 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Integration; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based implementation of . + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestIntegration : RestEntity, IIntegration + { + private long? _syncedAtTicks; + + /// + public string Name { get; private set; } + /// + public string Type { get; private set; } + /// + public bool IsEnabled { get; private set; } + /// + public bool? IsSyncing { get; private set; } + /// + public ulong? RoleId { get; private set; } + /// + public bool? HasEnabledEmoticons { get; private set; } + /// + public IntegrationExpireBehavior? ExpireBehavior { get; private set; } + /// + public int? ExpireGracePeriod { get; private set; } + /// + IUser IIntegration.User => User; + /// + public IIntegrationAccount Account { get; private set; } + /// + public DateTimeOffset? SyncedAt => DateTimeUtils.FromTicks(_syncedAtTicks); + /// + public int? SubscriberCount { get; private set; } + /// + public bool? IsRevoked { get; private set; } + /// + public IIntegrationApplication Application { get; private set; } + + internal IGuild Guild { get; private set; } + public RestUser User { get; private set; } + + internal RestIntegration(BaseDiscordClient discord, IGuild guild, ulong id) + : base(discord, id) + { + Guild = guild; + } + internal static RestIntegration Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestIntegration(discord, guild, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Name = model.Name; + Type = model.Type; + IsEnabled = model.Enabled; + + IsSyncing = model.Syncing.IsSpecified ? model.Syncing.Value : null; + RoleId = model.RoleId.IsSpecified ? model.RoleId.Value : null; + HasEnabledEmoticons = model.EnableEmoticons.IsSpecified ? model.EnableEmoticons.Value : null; + ExpireBehavior = model.ExpireBehavior.IsSpecified ? model.ExpireBehavior.Value : null; + ExpireGracePeriod = model.ExpireGracePeriod.IsSpecified ? model.ExpireGracePeriod.Value : null; + User = model.User.IsSpecified ? RestUser.Create(Discord, model.User.Value) : null; + Account = model.Account.IsSpecified ? RestIntegrationAccount.Create(model.Account.Value) : null; + SubscriberCount = model.SubscriberAccount.IsSpecified ? model.SubscriberAccount.Value : null; + IsRevoked = model.Revoked.IsSpecified ? model.Revoked.Value : null; + Application = model.Application.IsSpecified ? RestIntegrationApplication.Create(Discord, model.Application.Value) : null; + + _syncedAtTicks = model.SyncedAt.IsSpecified ? model.SyncedAt.Value.UtcTicks : null; + } + + public Task DeleteAsync() + => Discord.ApiClient.DeleteIntegrationAsync(GuildId, Id); + + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id}{(IsEnabled ? ", Enabled" : "")})"; + + /// + public ulong GuildId { get; private set; } + + /// + IGuild IIntegration.Guild + { + get + { + if (Guild != null) + return Guild; + throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + } + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Integrations/RestIntegrationAccount.cs b/src/Discord.Net.Rest/Entities/Integrations/RestIntegrationAccount.cs new file mode 100644 index 0000000..6d83aa1 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Integrations/RestIntegrationAccount.cs @@ -0,0 +1,29 @@ +using Model = Discord.API.IntegrationAccount; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based implementation of . + /// + public class RestIntegrationAccount : IIntegrationAccount + { + internal RestIntegrationAccount() { } + + public string Id { get; private set; } + + public string Name { get; private set; } + + internal static RestIntegrationAccount Create(Model model) + { + var entity = new RestIntegrationAccount(); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + model.Name = Name; + model.Id = Id; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Integrations/RestIntegrationApplication.cs b/src/Discord.Net.Rest/Entities/Integrations/RestIntegrationApplication.cs new file mode 100644 index 0000000..e532ac9 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Integrations/RestIntegrationApplication.cs @@ -0,0 +1,39 @@ +using Model = Discord.API.IntegrationApplication; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based implementation of . + /// + public class RestIntegrationApplication : RestEntity, IIntegrationApplication + { + public string Name { get; private set; } + + public string Icon { get; private set; } + + public string Description { get; private set; } + + public string Summary { get; private set; } + + public IUser Bot { get; private set; } + + internal RestIntegrationApplication(BaseDiscordClient discord, ulong id) + : base(discord, id) { } + + internal static RestIntegrationApplication Create(BaseDiscordClient discord, Model model) + { + var entity = new RestIntegrationApplication(discord, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Name = model.Name; + Icon = model.Icon.IsSpecified ? model.Icon.Value : null; + Description = model.Description; + Summary = model.Summary; + Bot = RestUser.Create(Discord, model.Bot.Value); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs new file mode 100644 index 0000000..a36e0cc --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs @@ -0,0 +1,363 @@ +using Discord.Net.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based base command interaction. + /// + public class RestCommandBase : RestInteraction + { + /// + /// Gets the name of the invoked command. + /// + public string CommandName + => Data.Name; + + /// + /// Gets the id of the invoked command. + /// + public ulong CommandId + => Data.Id; + + /// + /// Gets the data associated with this interaction. + /// + internal new RestCommandBaseData Data { get; private set; } + + private object _lock = new object(); + + internal RestCommandBase(DiscordRestClient client, Model model) + : base(client, model.Id) + { + } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + var entity = new RestCommandBase(client, model); + await entity.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + return entity; + } + + internal override async Task UpdateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + await base.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + + if (model.Data.IsSpecified && model.Data.Value is RestCommandBaseData data) + Data = data; + } + + /// + /// Responds to an Interaction with type . + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// Message content is too long, length must be less or equal to . + /// The parameters provided were invalid or the token was invalid. + /// + /// A string that contains json to write back to the incoming http request. + /// + public override string Respond( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + public override Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + public override async Task FollowupWithFileAsync( + Stream fileStream, + string fileName, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + using (var file = new FileAttachment(fileStream, fileName)) + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + + /// + public override async Task FollowupWithFileAsync( + string filePath, + string fileName = null, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); + + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + using (var file = new FileAttachment(File.OpenRead(filePath), fileName)) + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + + /// + public override Task FollowupWithFileAsync( + FileAttachment attachment, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Acknowledges this interaction with the . + /// + /// + /// A string that contains json to write back to the incoming http request. + /// + public override string Defer(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + /// Responds to the interaction with a modal. + /// + /// The modal to respond with. + /// The request options for this request. + /// A string that contains json to write back to the incoming http request. + /// + /// + public override string RespondWithModal(Modal modal, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBaseData.cs b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBaseData.cs new file mode 100644 index 0000000..342603d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBaseData.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.Rest +{ + /// + /// Represents the base data tied with the interaction. + /// + public class RestCommandBaseData : RestEntity, IApplicationCommandInteractionData where TOption : IApplicationCommandInteractionDataOption + { + /// + public string Name { get; private set; } + + /// + /// Gets a collection of received with this interaction. + /// + public virtual IReadOnlyCollection Options { get; internal set; } + + internal RestResolvableData ResolvableData; + + internal RestCommandBaseData(BaseDiscordClient client, Model model) + : base(client, model.Id) + { + } + + internal static async Task CreateAsync(DiscordRestClient client, Model model, RestGuild guild, IRestMessageChannel channel, bool doApiCall) + { + var entity = new RestCommandBaseData(client, model); + await entity.UpdateAsync(client, model, guild, channel, doApiCall).ConfigureAwait(false); + return entity; + } + + internal virtual Task UpdateAsync(DiscordRestClient client, Model model, RestGuild guild, IRestMessageChannel channel, bool doApiCall) + { + Name = model.Name; + if (model.Resolved.IsSpecified && ResolvableData == null) + { + ResolvableData = new RestResolvableData(); + return ResolvableData.PopulateAsync(client, guild, channel, model, doApiCall); + } + return Task.CompletedTask; + } + + IReadOnlyCollection IApplicationCommandInteractionData.Options + => (IReadOnlyCollection)Options; + } + + /// + /// Represents the base data tied with the interaction. + /// + public class RestCommandBaseData : RestCommandBaseData + { + internal RestCommandBaseData(DiscordRestClient client, Model model) + : base(client, model) { } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestResolvableData.cs b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestResolvableData.cs new file mode 100644 index 0000000..cf2860e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestResolvableData.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + internal class RestResolvableData where T : API.IResolvable + { + internal readonly Dictionary GuildMembers + = new Dictionary(); + internal readonly Dictionary Users + = new Dictionary(); + internal readonly Dictionary Channels + = new Dictionary(); + internal readonly Dictionary Roles + = new Dictionary(); + internal readonly Dictionary Messages + = new Dictionary(); + + internal readonly Dictionary Attachments + = new Dictionary(); + + internal async Task PopulateAsync(DiscordRestClient discord, RestGuild guild, IRestMessageChannel channel, T model, bool doApiCall) + { + var resolved = model.Resolved.Value; + + if (resolved.Users.IsSpecified) + { + foreach (var user in resolved.Users.Value) + { + var restUser = RestUser.Create(discord, user.Value); + + Users.Add(ulong.Parse(user.Key), restUser); + } + } + + if (resolved.Channels.IsSpecified) + { + var channels = doApiCall ? await guild.GetChannelsAsync().ConfigureAwait(false) : null; + + foreach (var channelModel in resolved.Channels.Value) + { + if (channels != null) + { + var guildChannel = channels.FirstOrDefault(x => x.Id == channelModel.Value.Id); + + guildChannel.Update(channelModel.Value); + + Channels.Add(ulong.Parse(channelModel.Key), guildChannel); + } + else + { + var restChannel = RestChannel.Create(discord, channelModel.Value); + + restChannel.Update(channelModel.Value); + + Channels.Add(ulong.Parse(channelModel.Key), restChannel); + } + } + } + + if (resolved.Members.IsSpecified) + { + foreach (var member in resolved.Members.Value) + { + // pull the adjacent user model + member.Value.User = resolved.Users.Value.FirstOrDefault(x => x.Key == member.Key).Value; + var restMember = RestGuildUser.Create(discord, guild, member.Value); + + GuildMembers.Add(ulong.Parse(member.Key), restMember); + } + } + + if (resolved.Roles.IsSpecified) + { + foreach (var role in resolved.Roles.Value) + { + var restRole = RestRole.Create(discord, guild, role.Value); + + Roles.Add(ulong.Parse(role.Key), restRole); + } + } + + if (resolved.Messages.IsSpecified) + { + foreach (var msg in resolved.Messages.Value) + { + channel ??= (IRestMessageChannel)(Channels.FirstOrDefault(x => x.Key == msg.Value.ChannelId).Value + ?? (doApiCall + ? await discord.GetChannelAsync(msg.Value.ChannelId).ConfigureAwait(false) + : null)); + + RestUser author; + + if (msg.Value.Author.IsSpecified) + { + author = RestUser.Create(discord, msg.Value.Author.Value); + } + else + { + author = RestGuildUser.Create(discord, guild, msg.Value.Member.Value); + } + + var message = RestMessage.Create(discord, channel, author, msg.Value); + + Messages.Add(message.Id, message); + } + } + + if (resolved.Attachments.IsSpecified) + { + foreach (var attachment in resolved.Attachments.Value) + { + var discordAttachment = Attachment.Create(attachment.Value, discord); + + Attachments.Add(ulong.Parse(attachment.Key), discordAttachment); + } + } + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommand.cs new file mode 100644 index 0000000..465d996 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommand.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based message command interaction. + /// + public class RestMessageCommand : RestCommandBase, IMessageCommandInteraction, IDiscordInteraction + { + /// + /// Gets the data associated with this interaction. + /// + public new RestMessageCommandData Data { get; private set; } + + internal RestMessageCommand(DiscordRestClient client, Model model) + : base(client, model) + { + + } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + var entity = new RestMessageCommand(client, model); + await entity.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + return entity; + } + + internal override async Task UpdateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + await base.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data = await RestMessageCommandData.CreateAsync(client, dataModel, Guild, Channel, doApiCall).ConfigureAwait(false); + } + + //IMessageCommandInteraction + /// + IMessageCommandInteractionData IMessageCommandInteraction.Data => Data; + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommandData.cs b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommandData.cs new file mode 100644 index 0000000..d2968a3 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/MessageCommands/RestMessageCommandData.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.Rest +{ + /// + /// Represents the data for a . + /// + public class RestMessageCommandData : RestCommandBaseData, IMessageCommandInteractionData, IDiscordInteractionData + { + /// + /// Gets the message associated with this message command. + /// + public RestMessage Message + => ResolvableData?.Messages.FirstOrDefault().Value; + + /// + /// + /// Note Not implemented for + /// + public override IReadOnlyCollection Options + => throw new NotImplementedException(); + + internal RestMessageCommandData(DiscordRestClient client, Model model) + : base(client, model) { } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, RestGuild guild, IRestMessageChannel channel, bool doApiCall) + { + var entity = new RestMessageCommandData(client, model); + await entity.UpdateAsync(client, model, guild, channel, doApiCall).ConfigureAwait(false); + return entity; + } + + //IMessageCommandInteractionData + /// + IMessage IMessageCommandInteractionData.Message => Message; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommand.cs new file mode 100644 index 0000000..91319a6 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommand.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based user command. + /// + public class RestUserCommand : RestCommandBase, IUserCommandInteraction, IDiscordInteraction + { + /// + /// Gets the data associated with this interaction. + /// + public new RestUserCommandData Data { get; private set; } + + internal RestUserCommand(DiscordRestClient client, Model model) + : base(client, model) + { + } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + var entity = new RestUserCommand(client, model); + await entity.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + return entity; + } + + internal override async Task UpdateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + await base.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data = await RestUserCommandData.CreateAsync(client, dataModel, Guild, Channel, doApiCall).ConfigureAwait(false); + } + + //IUserCommandInteractionData + /// + IUserCommandInteractionData IUserCommandInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommandData.cs b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommandData.cs new file mode 100644 index 0000000..61b291f --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/ContextMenuCommands/UserCommands/RestUserCommandData.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.Rest +{ + /// + /// Represents the data for a . + /// + public class RestUserCommandData : RestCommandBaseData, IUserCommandInteractionData, IDiscordInteractionData + { + /// + /// Gets the user who this command targets. + /// + public RestUser Member + => (RestUser)ResolvableData.GuildMembers.Values.FirstOrDefault() ?? ResolvableData.Users.Values.FirstOrDefault(); + + /// + /// + /// Note Not implemented for + /// + public override IReadOnlyCollection Options + => throw new System.NotImplementedException(); + + internal RestUserCommandData(DiscordRestClient client, Model model) + : base(client, model) { } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, RestGuild guild, IRestMessageChannel channel, bool doApiCall) + { + var entity = new RestUserCommandData(client, model); + await entity.UpdateAsync(client, model, guild, channel, doApiCall).ConfigureAwait(false); + return entity; + } + + //IUserCommandInteractionData + /// + IUser IUserCommandInteractionData.User => Member; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs new file mode 100644 index 0000000..f1d47cc --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/InteractionHelper.cs @@ -0,0 +1,640 @@ +using Discord.API; +using Discord.API.Rest; +using Discord.Net; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Net; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + internal static class InteractionHelper + { + public const double ResponseTimeLimit = 3; + public const double ResponseAndFollowupLimit = 15; + + #region InteractionHelper + public static bool CanSendResponse(IDiscordInteraction interaction) + { + return (DateTime.UtcNow - interaction.CreatedAt).TotalSeconds < ResponseTimeLimit; + } + public static bool CanRespondOrFollowup(IDiscordInteraction interaction) + { + return (DateTime.UtcNow - interaction.CreatedAt).TotalMinutes <= ResponseAndFollowupLimit; + } + + public static Task DeleteAllGuildCommandsAsync(BaseDiscordClient client, ulong guildId, RequestOptions options = null) + { + return client.ApiClient.BulkOverwriteGuildApplicationCommandsAsync(guildId, Array.Empty(), options); + } + + public static Task DeleteAllGlobalCommandsAsync(BaseDiscordClient client, RequestOptions options = null) + { + return client.ApiClient.BulkOverwriteGlobalApplicationCommandsAsync(Array.Empty(), options); + } + + public static Task SendInteractionResponseAsync(BaseDiscordClient client, InteractionResponse response, + IDiscordInteraction interaction, IMessageChannel channel = null, RequestOptions options = null) + => client.ApiClient.CreateInteractionResponseAsync(response, interaction.Id, interaction.Token, options); + + public static Task SendInteractionResponseAsync(BaseDiscordClient client, UploadInteractionFileParams response, + IDiscordInteraction interaction, IMessageChannel channel = null, RequestOptions options = null) + => client.ApiClient.CreateInteractionResponseAsync(response, interaction.Id, interaction.Token, options); + + public static async Task GetOriginalResponseAsync(BaseDiscordClient client, IMessageChannel channel, + IDiscordInteraction interaction, RequestOptions options = null) + { + var model = await client.ApiClient.GetInteractionResponseAsync(interaction.Token, options).ConfigureAwait(false); + if (model != null) + return RestInteractionMessage.Create(client, model, interaction.Token, channel); + return null; + } + + public static async Task SendFollowupAsync(BaseDiscordClient client, CreateWebhookMessageParams args, + string token, IMessageChannel channel, RequestOptions options = null) + { + var model = await client.ApiClient.CreateInteractionFollowupMessageAsync(args, token, options).ConfigureAwait(false); + + var entity = RestFollowupMessage.Create(client, model, token, channel); + return entity; + } + + public static async Task SendFollowupAsync(BaseDiscordClient client, UploadWebhookFileParams args, + string token, IMessageChannel channel, RequestOptions options = null) + { + var model = await client.ApiClient.CreateInteractionFollowupMessageAsync(args, token, options).ConfigureAwait(false); + + var entity = RestFollowupMessage.Create(client, model, token, channel); + return entity; + } + #endregion + + #region Global commands + public static async Task GetGlobalCommandAsync(BaseDiscordClient client, ulong id, + RequestOptions options = null) + { + var model = await client.ApiClient.GetGlobalApplicationCommandAsync(id, options).ConfigureAwait(false); + + return RestGlobalCommand.Create(client, model); + } + public static Task CreateGlobalCommandAsync(BaseDiscordClient client, + Action func, RequestOptions options = null) where TArg : ApplicationCommandProperties + { + var args = Activator.CreateInstance(typeof(TArg)); + func((TArg)args); + return CreateGlobalCommandAsync(client, (TArg)args, options); + } + + public static Task CreateGlobalCommandAsync(BaseDiscordClient client, + ApplicationCommandProperties arg, RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(arg.Name, nameof(arg.Name)); + + var model = new CreateApplicationCommandParams + { + Name = arg.Name.Value, + Type = arg.Type, + DefaultPermission = arg.IsDefaultPermission.IsSpecified + ? arg.IsDefaultPermission.Value + : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), + + // TODO: better conversion to nullable optionals + DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), + DmPermission = arg.IsDMEnabled.ToNullable(), + Nsfw = arg.IsNsfw.GetValueOrDefault(false), + IntegrationTypes = arg.IntegrationTypes, + ContextTypes = arg.ContextTypes + }; + + if (arg is SlashCommandProperties slashProps) + { + Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description)); + + model.Description = slashProps.Description.Value; + + model.Options = slashProps.Options.IsSpecified + ? slashProps.Options.Value.Select(x => new ApplicationCommandOption(x)).ToArray() + : Optional.Unspecified; + } + + return client.ApiClient.CreateGlobalApplicationCommandAsync(model, options); + } + + public static Task BulkOverwriteGlobalCommandsAsync(BaseDiscordClient client, + ApplicationCommandProperties[] args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + + var models = new List(); + + foreach (var arg in args) + { + Preconditions.NotNullOrEmpty(arg.Name, nameof(arg.Name)); + + var model = new CreateApplicationCommandParams + { + Name = arg.Name.Value, + Type = arg.Type, + DefaultPermission = arg.IsDefaultPermission.IsSpecified + ? arg.IsDefaultPermission.Value + : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), + + // TODO: better conversion to nullable optionals + DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), + DmPermission = arg.IsDMEnabled.ToNullable(), + Nsfw = arg.IsNsfw.GetValueOrDefault(false), + IntegrationTypes = arg.IntegrationTypes, + ContextTypes = arg.ContextTypes + }; + + if (arg is SlashCommandProperties slashProps) + { + Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description)); + + model.Description = slashProps.Description.Value; + + model.Options = slashProps.Options.IsSpecified + ? slashProps.Options.Value.Select(x => new ApplicationCommandOption(x)).ToArray() + : Optional.Unspecified; + } + + models.Add(model); + } + + return client.ApiClient.BulkOverwriteGlobalApplicationCommandsAsync(models.ToArray(), options); + } + + public static async Task> BulkOverwriteGuildCommandsAsync(BaseDiscordClient client, ulong guildId, + ApplicationCommandProperties[] args, RequestOptions options = null) + { + Preconditions.NotNull(args, nameof(args)); + + var models = new List(); + + foreach (var arg in args) + { + Preconditions.NotNullOrEmpty(arg.Name, nameof(arg.Name)); + + var model = new CreateApplicationCommandParams + { + Name = arg.Name.Value, + Type = arg.Type, + DefaultPermission = arg.IsDefaultPermission.IsSpecified + ? arg.IsDefaultPermission.Value + : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), + + // TODO: better conversion to nullable optionals + DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), + DmPermission = arg.IsDMEnabled.ToNullable(), + Nsfw = arg.IsNsfw.GetValueOrDefault(false), + IntegrationTypes = arg.IntegrationTypes, + ContextTypes = arg.ContextTypes + }; + + if (arg is SlashCommandProperties slashProps) + { + Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description)); + + model.Description = slashProps.Description.Value; + + model.Options = slashProps.Options.IsSpecified + ? slashProps.Options.Value.Select(x => new ApplicationCommandOption(x)).ToArray() + : Optional.Unspecified; + } + + models.Add(model); + } + + return await client.ApiClient.BulkOverwriteGuildApplicationCommandsAsync(guildId, models.ToArray(), options).ConfigureAwait(false); + } + + private static TArg GetApplicationCommandProperties(IApplicationCommand command) + where TArg : ApplicationCommandProperties + { + bool isBaseClass = typeof(TArg) == typeof(ApplicationCommandProperties); + + switch (true) + { + case true when (typeof(TArg) == typeof(SlashCommandProperties) || isBaseClass) && command.Type == ApplicationCommandType.Slash: + return new SlashCommandProperties() as TArg; + case true when (typeof(TArg) == typeof(MessageCommandProperties) || isBaseClass) && command.Type == ApplicationCommandType.Message: + return new MessageCommandProperties() as TArg; + case true when (typeof(TArg) == typeof(UserCommandProperties) || isBaseClass) && command.Type == ApplicationCommandType.User: + return new UserCommandProperties() as TArg; + default: + throw new InvalidOperationException($"Cannot modify application command of type {command.Type} with the parameter type {typeof(TArg).FullName}"); + } + } + + public static Task ModifyGlobalCommandAsync(BaseDiscordClient client, IApplicationCommand command, + Action func, RequestOptions options = null) where TArg : ApplicationCommandProperties + { + var arg = GetApplicationCommandProperties(command); + func(arg); + return ModifyGlobalCommandAsync(client, command, arg, options); + } + + public static Task ModifyGlobalCommandAsync(BaseDiscordClient client, IApplicationCommand command, + ApplicationCommandProperties args, RequestOptions options = null) + { + if (args.Name.IsSpecified) + { + Preconditions.AtMost(args.Name.Value.Length, 32, nameof(args.Name)); + Preconditions.AtLeast(args.Name.Value.Length, 1, nameof(args.Name)); + } + + var model = new ModifyApplicationCommandParams + { + Name = args.Name, + DefaultPermission = args.IsDefaultPermission.IsSpecified + ? args.IsDefaultPermission.Value + : Optional.Unspecified, + NameLocalizations = args.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = args.DescriptionLocalizations?.ToDictionary(), + Nsfw = args.IsNsfw.GetValueOrDefault(false), + DefaultMemberPermission = args.DefaultMemberPermissions.ToNullable(), + IntegrationTypes = args.IntegrationTypes, + ContextTypes = args.ContextTypes + }; + + if (args is SlashCommandProperties slashProps) + { + if (slashProps.Description.IsSpecified) + { + Preconditions.AtMost(slashProps.Description.Value.Length, 100, nameof(slashProps.Description)); + Preconditions.AtLeast(slashProps.Description.Value.Length, 1, nameof(slashProps.Description)); + } + + if (slashProps.Options.IsSpecified) + { + if (slashProps.Options.Value.Count > 10) + throw new ArgumentException("Option count must be 10 or less"); + } + + model.Description = slashProps.Description; + + model.Options = slashProps.Options.IsSpecified + ? slashProps.Options.Value.Select(x => new ApplicationCommandOption(x)).ToArray() + : Optional.Unspecified; + } + + return client.ApiClient.ModifyGlobalApplicationCommandAsync(model, command.Id, options); + } + + public static Task DeleteGlobalCommandAsync(BaseDiscordClient client, IApplicationCommand command, RequestOptions options = null) + { + Preconditions.NotNull(command, nameof(command)); + Preconditions.NotEqual(command.Id, 0, nameof(command.Id)); + + return client.ApiClient.DeleteGlobalApplicationCommandAsync(command.Id, options); + } + #endregion + + #region Guild Commands + public static Task CreateGuildCommandAsync(BaseDiscordClient client, ulong guildId, + Action func, RequestOptions options) where TArg : ApplicationCommandProperties + { + var args = Activator.CreateInstance(typeof(TArg)); + func((TArg)args); + return CreateGuildCommandAsync(client, guildId, (TArg)args, options); + } + + public static Task CreateGuildCommandAsync(BaseDiscordClient client, ulong guildId, + ApplicationCommandProperties arg, RequestOptions options = null) + { + var model = new CreateApplicationCommandParams + { + Name = arg.Name.Value, + Type = arg.Type, + DefaultPermission = arg.IsDefaultPermission.IsSpecified + ? arg.IsDefaultPermission.Value + : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), + + // TODO: better conversion to nullable optionals + DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable(), + DmPermission = arg.IsDMEnabled.ToNullable(), + Nsfw = arg.IsNsfw.GetValueOrDefault(false) + }; + + if (arg is SlashCommandProperties slashProps) + { + Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description)); + + model.Description = slashProps.Description.Value; + + model.Options = slashProps.Options.IsSpecified + ? slashProps.Options.Value.Select(x => new ApplicationCommandOption(x)).ToArray() + : Optional.Unspecified; + } + + return client.ApiClient.CreateGuildApplicationCommandAsync(model, guildId, options); + } + + public static Task ModifyGuildCommandAsync(BaseDiscordClient client, IApplicationCommand command, ulong guildId, + Action func, RequestOptions options = null) where TArg : ApplicationCommandProperties + { + var arg = GetApplicationCommandProperties(command); + func(arg); + return ModifyGuildCommandAsync(client, command, guildId, arg, options); + } + + public static Task ModifyGuildCommandAsync(BaseDiscordClient client, IApplicationCommand command, ulong guildId, + ApplicationCommandProperties arg, RequestOptions options = null) + { + var model = new ModifyApplicationCommandParams + { + Name = arg.Name, + DefaultPermission = arg.IsDefaultPermission.IsSpecified + ? arg.IsDefaultPermission.Value + : Optional.Unspecified, + NameLocalizations = arg.NameLocalizations?.ToDictionary(), + DescriptionLocalizations = arg.DescriptionLocalizations?.ToDictionary(), + Nsfw = arg.IsNsfw.GetValueOrDefault(false), + DefaultMemberPermission = arg.DefaultMemberPermissions.ToNullable() + }; + + if (arg is SlashCommandProperties slashProps) + { + Preconditions.NotNullOrEmpty(slashProps.Description, nameof(slashProps.Description)); + + model.Description = slashProps.Description.Value; + + model.Options = slashProps.Options.IsSpecified + ? slashProps.Options.Value.Select(x => new ApplicationCommandOption(x)).ToArray() + : Optional.Unspecified; + } + + return client.ApiClient.ModifyGuildApplicationCommandAsync(model, guildId, command.Id, options); + } + + public static Task DeleteGuildCommandAsync(BaseDiscordClient client, ulong guildId, IApplicationCommand command, RequestOptions options = null) + { + Preconditions.NotNull(command, nameof(command)); + Preconditions.NotEqual(command.Id, 0, nameof(command.Id)); + + return client.ApiClient.DeleteGuildApplicationCommandAsync(guildId, command.Id, options); + } + + public static Task DeleteUnknownApplicationCommandAsync(BaseDiscordClient client, ulong? guildId, IApplicationCommand command, RequestOptions options = null) + { + return guildId.HasValue + ? DeleteGuildCommandAsync(client, guildId.Value, command, options) + : DeleteGlobalCommandAsync(client, command, options); + } + #endregion + + #region Responses + public static Task ModifyFollowupMessageAsync(BaseDiscordClient client, RestFollowupMessage message, Action func, + RequestOptions options = null) + { + var args = new MessageProperties(); + func(args); + + var embed = args.Embed; + var embeds = args.Embeds; + + bool hasText = args.Content.IsSpecified ? !string.IsNullOrEmpty(args.Content.Value) : !string.IsNullOrEmpty(message.Content); + bool hasEmbeds = embed.IsSpecified && embed.Value != null || embeds.IsSpecified && embeds.Value?.Length > 0 || message.Embeds.Any(); + bool hasComponents = args.Components.IsSpecified && args.Components.Value != null; + + if (!hasComponents && !hasText && !hasEmbeds) + Preconditions.NotNullOrEmpty(args.Content.IsSpecified ? args.Content.Value : string.Empty, nameof(args.Content)); + + var apiEmbeds = embed.IsSpecified || embeds.IsSpecified ? new List() : null; + + if (embed.IsSpecified && embed.Value != null) + { + apiEmbeds.Add(embed.Value.ToModel()); + } + + if (embeds.IsSpecified && embeds.Value != null) + { + apiEmbeds.AddRange(embeds.Value.Select(x => x.ToModel())); + } + + Preconditions.AtMost(apiEmbeds?.Count ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds are allowed."); + + var apiArgs = new ModifyInteractionResponseParams + { + Content = args.Content, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value.ToModel() : Optional.Unspecified, + Components = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + }; + + return client.ApiClient.ModifyInteractionFollowupMessageAsync(apiArgs, message.Id, message.Token, options); + } + + public static Task DeleteFollowupMessageAsync(BaseDiscordClient client, RestFollowupMessage message, RequestOptions options = null) + => client.ApiClient.DeleteInteractionFollowupMessageAsync(message.Id, message.Token, options); + + public static Task ModifyInteractionResponseAsync(BaseDiscordClient client, string token, Action func, + RequestOptions options = null) + { + var args = new MessageProperties(); + func(args); + + var embed = args.Embed; + var embeds = args.Embeds; + + bool hasText = !string.IsNullOrEmpty(args.Content.GetValueOrDefault()); + bool hasEmbeds = embed.IsSpecified && embed.Value != null || embeds.IsSpecified && embeds.Value?.Length > 0; + bool hasComponents = args.Components.IsSpecified && args.Components.Value != null; + + if (!hasComponents && !hasText && !hasEmbeds) + Preconditions.NotNullOrEmpty(args.Content.IsSpecified ? args.Content.Value : string.Empty, nameof(args.Content)); + + var apiEmbeds = embed.IsSpecified || embeds.IsSpecified ? new List() : null; + + if (embed.IsSpecified && embed.Value != null) + { + apiEmbeds.Add(embed.Value.ToModel()); + } + + if (embeds.IsSpecified && embeds.Value != null) + { + apiEmbeds.AddRange(embeds.Value.Select(x => x.ToModel())); + } + + Preconditions.AtMost(apiEmbeds?.Count ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds are allowed."); + + if (!args.Attachments.IsSpecified) + { + var apiArgs = new ModifyInteractionResponseParams + { + Content = args.Content, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Components = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags + }; + + return client.ApiClient.ModifyInteractionResponseAsync(apiArgs, token, options); + } + else + { + var attachments = args.Attachments.Value?.ToArray() ?? Array.Empty(); + + var apiArgs = new UploadWebhookFileParams(attachments) + { + Content = args.Content, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + MessageComponents = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified + }; + + return client.ApiClient.ModifyInteractionResponseAsync(apiArgs, token, options); + } + } + + public static Task DeleteInteractionResponseAsync(BaseDiscordClient client, RestInteractionMessage message, RequestOptions options = null) + => client.ApiClient.DeleteInteractionResponseAsync(message.Token, options); + + public static Task DeleteInteractionResponseAsync(BaseDiscordClient client, IDiscordInteraction interaction, RequestOptions options = null) + => client.ApiClient.DeleteInteractionResponseAsync(interaction.Token, options); + + public static Task SendAutocompleteResultAsync(BaseDiscordClient client, IEnumerable result, ulong interactionId, + string interactionToken, RequestOptions options) + { + result ??= Array.Empty(); + + Preconditions.AtMost(result.Count(), 25, nameof(result), "A maximum of 25 choices are allowed!"); + + var apiArgs = new InteractionResponse + { + Type = InteractionResponseType.ApplicationCommandAutocompleteResult, + Data = new InteractionCallbackData + { + Choices = result.Any() + ? result.Select(x => new ApplicationCommandOptionChoice { Name = x.Name, Value = x.Value }).ToArray() + : Array.Empty() + } + }; + + return client.ApiClient.CreateInteractionResponseAsync(apiArgs, interactionId, interactionToken, options); + } + + public static Task RespondWithPremiumRequiredAsync(BaseDiscordClient client, ulong interactionId, + string interactionToken, RequestOptions options = null) + { + return client.ApiClient.CreateInteractionResponseAsync(new InteractionResponse + { + Type = InteractionResponseType.PremiumRequired, + Data = Optional.Unspecified + }, interactionId, interactionToken, options); + } + + #endregion + + #region Guild permissions + public static async Task> GetGuildCommandPermissionsAsync(BaseDiscordClient client, + ulong guildId, RequestOptions options) + { + var models = await client.ApiClient.GetGuildApplicationCommandPermissionsAsync(guildId, options); + return models.Select(x => + new GuildApplicationCommandPermission(x.Id, x.ApplicationId, guildId, x.Permissions.Select( + y => new ApplicationCommandPermission(y.Id, y.Type, y.Permission)) + .ToArray()) + ).ToArray(); + } + + public static async Task GetGuildCommandPermissionAsync(BaseDiscordClient client, + ulong guildId, ulong commandId, RequestOptions options) + { + try + { + var model = await client.ApiClient.GetGuildApplicationCommandPermissionAsync(guildId, commandId, options); + return new GuildApplicationCommandPermission(model.Id, model.ApplicationId, guildId, model.Permissions.Select( + y => new ApplicationCommandPermission(y.Id, y.Type, y.Permission)).ToArray()); + } + catch (HttpException x) + { + if (x.HttpCode == HttpStatusCode.NotFound) + return null; + throw; + } + } + + public static async Task ModifyGuildCommandPermissionsAsync(BaseDiscordClient client, ulong guildId, ulong commandId, + ApplicationCommandPermission[] args, RequestOptions options) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.AtMost(args.Length, 10, nameof(args)); + Preconditions.AtLeast(args.Length, 0, nameof(args)); + + var permissionsList = new List(); + + foreach (var arg in args) + { + var permissions = new ApplicationCommandPermissions + { + Id = arg.TargetId, + Permission = arg.Permission, + Type = arg.TargetType + }; + + permissionsList.Add(permissions); + } + + var model = new ModifyGuildApplicationCommandPermissionsParams + { + Permissions = permissionsList.ToArray() + }; + + var apiModel = await client.ApiClient.ModifyApplicationCommandPermissionsAsync(model, guildId, commandId, options); + + return new GuildApplicationCommandPermission(apiModel.Id, apiModel.ApplicationId, guildId, apiModel.Permissions.Select( + x => new ApplicationCommandPermission(x.Id, x.Type, x.Permission)).ToArray()); + } + + public static async Task> BatchEditGuildCommandPermissionsAsync(BaseDiscordClient client, ulong guildId, + IDictionary args, RequestOptions options) + { + Preconditions.NotNull(args, nameof(args)); + Preconditions.NotEqual(args.Count, 0, nameof(args)); + + var models = new List(); + + foreach (var arg in args) + { + Preconditions.AtMost(arg.Value.Length, 10, nameof(args)); + + var model = new ModifyGuildApplicationCommandPermissions + { + Id = arg.Key, + Permissions = arg.Value.Select(x => new ApplicationCommandPermissions + { + Id = x.TargetId, + Permission = x.Permission, + Type = x.TargetType + }).ToArray() + }; + + models.Add(model); + } + + var apiModels = await client.ApiClient.BatchModifyApplicationCommandPermissionsAsync(models.ToArray(), guildId, options); + + return apiModels.Select( + x => new GuildApplicationCommandPermission(x.Id, x.ApplicationId, x.GuildId, x.Permissions.Select( + y => new ApplicationCommandPermission(y.Id, y.Type, y.Permission)).ToArray())).ToArray(); + } + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/InteractionProperties.cs b/src/Discord.Net.Rest/Entities/Interactions/InteractionProperties.cs new file mode 100644 index 0000000..03750d7 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/InteractionProperties.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + /// + /// Represents a class that contains data present in all interactions to evaluate against at rest-interaction creation. + /// + public readonly struct InteractionProperties + { + /// + /// The type of this interaction. + /// + public InteractionType Type { get; } + + /// + /// Gets the type of application command this interaction represents. + /// + /// + /// This will be if the is not . + /// + public ApplicationCommandType? CommandType { get; } + + /// + /// Gets the name of the interaction. + /// + /// + /// This will be if the is not . + /// + public string Name { get; } = string.Empty; + + /// + /// Gets the custom ID of the interaction. + /// + /// + /// This will be if the is not or . + /// + public string CustomId { get; } = string.Empty; + + /// + /// Gets the guild ID of the interaction. + /// + /// + /// This will be if this interaction was not executed in a guild. + /// + public ulong? GuildId { get; } + + /// + /// Gets the channel ID of the interaction. + /// + /// + /// This will be if this interaction is . + /// + public ulong? ChannelId { get; } + + internal InteractionProperties(API.Interaction model) + { + Type = model.Type; + CommandType = null; + + if (model.GuildId.IsSpecified) + GuildId = model.GuildId.Value; + else + GuildId = null; + + if (model.ChannelId.IsSpecified) + ChannelId = model.ChannelId.Value; + else + ChannelId = null; + + switch (Type) + { + case InteractionType.ApplicationCommand: + { + var data = (API.ApplicationCommandInteractionData)model.Data; + + CommandType = data.Type; + Name = data.Name; + } + break; + case InteractionType.MessageComponent: + { + var data = (API.MessageComponentInteractionData)model.Data; + + CustomId = data.CustomId; + } + break; + case InteractionType.ModalSubmit: + { + var data = (API.ModalInteractionData)model.Data; + + CustomId = data.CustomId; + } + break; + } + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs new file mode 100644 index 0000000..130f03f --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs @@ -0,0 +1,516 @@ +using Discord.Net.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DataModel = Discord.API.MessageComponentInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based message component. + /// + public class RestMessageComponent : RestInteraction, IComponentInteraction, IDiscordInteraction + { + /// + /// Gets the data received with this interaction, contains the button that was clicked. + /// + public new RestMessageComponentData Data { get; } + + /// + public RestUserMessage Message { get; private set; } + + private object _lock = new object(); + + internal RestMessageComponent(BaseDiscordClient client, Model model) + : base(client, model.Id) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data = new RestMessageComponentData(dataModel, client, Guild); + } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + var entity = new RestMessageComponent(client, model); + await entity.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + return entity; + } + internal override async Task UpdateAsync(DiscordRestClient discord, Model model, bool doApiCall) + { + await base.UpdateAsync(discord, model, doApiCall).ConfigureAwait(false); + + if (model.Message.IsSpecified && model.ChannelId.IsSpecified) + { + if (Message == null) + { + Message = RestUserMessage.Create(Discord, Channel, User, model.Message.Value); + } + } + } + /// + /// Responds to an Interaction with type . + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A string that contains json to write back to the incoming http request. + /// + public override string Respond( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel(), + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + } + }; + + if (ephemeral) + response.Data.Value.Flags = MessageFlags.Ephemeral; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + /// Updates the message which this component resides in with the type + /// + /// A delegate containing the properties to modify the message with. + /// The request options for this request. + public async Task UpdateAsync(Action func, RequestOptions options = null) + { + var args = new MessageProperties(); + func(args); + + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + if (args.AllowedMentions.IsSpecified) + { + var allowedMentions = args.AllowedMentions.Value; + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 user Ids are allowed."); + } + + var embed = args.Embed; + var embeds = args.Embeds; + + var apiEmbeds = embed.IsSpecified || embeds.IsSpecified ? new List() : null; + + if (embed.IsSpecified && embed.Value != null) + { + apiEmbeds.Add(embed.Value.ToModel()); + } + + if (embeds.IsSpecified && embeds.Value != null) + { + apiEmbeds.AddRange(embeds.Value.Select(x => x.ToModel())); + } + + Preconditions.AtMost(apiEmbeds?.Count ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (args.AllowedMentions.IsSpecified && args.AllowedMentions.Value != null && args.AllowedMentions.Value.AllowedTypes.HasValue) + { + var allowedMentions = args.AllowedMentions.Value; + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) + && allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(args.AllowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) + && allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(args.AllowedMentions)); + } + } + + if (!args.Attachments.IsSpecified) + { + var response = new API.InteractionResponse + { + Type = InteractionResponseType.UpdateMessage, + Data = new API.InteractionCallbackData + { + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + Components = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + } + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + else + { + var attachments = args.Attachments.Value?.ToArray() ?? Array.Empty(); + + var response = new API.Rest.UploadInteractionFileParams(attachments) + { + Type = InteractionResponseType.UpdateMessage, + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + MessageComponents = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + } + + HasResponded = true; + } + + /// + public override Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + public override async Task FollowupWithFileAsync( + Stream fileStream, + string fileName, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + using (var file = new FileAttachment(fileStream, fileName)) + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + + /// + public override async Task FollowupWithFileAsync( + string filePath, + string fileName = null, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); + + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + using (var file = new FileAttachment(File.OpenRead(filePath), fileName)) + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + + /// + public override Task FollowupWithFileAsync( + FileAttachment attachment, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Defers an interaction and responds with type 5 () + /// + /// to send this message ephemerally, otherwise . + /// The request options for this request. + /// + /// A string that contains json to write back to the incoming http request. + /// + public string DeferLoading(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredChannelMessageWithSource, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + /// + /// + /// + /// + /// + /// A string that contains json to write back to the incoming http request. + /// + /// + /// + public override string Defer(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredUpdateMessage, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + /// Responds to the interaction with a modal. + /// + /// The modal to respond with. + /// The request options for this request. + /// A string that contains json to write back to the incoming http request. + /// + /// + public override string RespondWithModal(Modal modal, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction."); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } + + //IComponentInteraction + /// + IComponentInteractionData IComponentInteraction.Data => Data; + + /// + IUserMessage IComponentInteraction.Message => Message; + + /// + Task IComponentInteraction.UpdateAsync(Action func, RequestOptions options) + => UpdateAsync(func, options); + + /// + Task IComponentInteraction.DeferLoadingAsync(bool ephemeral, RequestOptions options) + => Task.FromResult(DeferLoading(ephemeral, options)); + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs new file mode 100644 index 0000000..bc44d0d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs @@ -0,0 +1,134 @@ +using Discord.API; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using Model = Discord.API.MessageComponentInteractionData; + +namespace Discord.Rest +{ + /// + /// Represents data for a . + /// + public class RestMessageComponentData : IComponentInteractionData + { + /// + public string CustomId { get; } + + /// + public ComponentType Type { get; } + + /// + public IReadOnlyCollection Values { get; } + + /// + public IReadOnlyCollection Channels { get; } + + /// + public IReadOnlyCollection Users { get; } + + /// + public IReadOnlyCollection Roles { get; } + + /// + public IReadOnlyCollection Members { get; } + + #region IComponentInteractionData + + /// + IReadOnlyCollection IComponentInteractionData.Channels => Channels; + + /// + IReadOnlyCollection IComponentInteractionData.Users => Users; + + /// + IReadOnlyCollection IComponentInteractionData.Roles => Roles; + + /// + IReadOnlyCollection IComponentInteractionData.Members => Members; + + #endregion + + /// + public string Value { get; } + + internal RestMessageComponentData(Model model, BaseDiscordClient discord, IGuild guild) + { + CustomId = model.CustomId; + Type = model.ComponentType; + Values = model.Values.GetValueOrDefault(); + Value = model.Value.GetValueOrDefault(); + + if (model.Resolved.IsSpecified) + { + Users = model.Resolved.Value.Users.IsSpecified + ? model.Resolved.Value.Users.Value.Select(user => RestUser.Create(discord, user.Value)).ToImmutableArray() + : Array.Empty(); + + Members = model.Resolved.Value.Members.IsSpecified + ? model.Resolved.Value.Members.Value.Select(member => + { + member.Value.User = model.Resolved.Value.Users.Value.First(u => u.Key == member.Key).Value; + + return RestGuildUser.Create(discord, guild, member.Value); + }).ToImmutableArray() + : null; + + Channels = model.Resolved.Value.Channels.IsSpecified + ? model.Resolved.Value.Channels.Value.Select(channel => + { + if (channel.Value.Type is ChannelType.DM) + return RestDMChannel.Create(discord, channel.Value); + return RestChannel.Create(discord, channel.Value); + }).ToImmutableArray() + : Array.Empty(); + + Roles = model.Resolved.Value.Roles.IsSpecified + ? model.Resolved.Value.Roles.Value.Select(role => RestRole.Create(discord, guild, role.Value)).ToImmutableArray() + : Array.Empty(); + } + } + + internal RestMessageComponentData(IMessageComponent component, BaseDiscordClient discord, IGuild guild) + { + CustomId = component.CustomId; + Type = component.Type; + + if (component is API.TextInputComponent textInput) + Value = textInput.Value.Value; + + if (component is API.SelectMenuComponent select) + { + Values = select.Values.GetValueOrDefault(null); + + if (select.Resolved.IsSpecified) + { + Users = select.Resolved.Value.Users.IsSpecified + ? select.Resolved.Value.Users.Value.Select(user => RestUser.Create(discord, user.Value)).ToImmutableArray() + : null; + + Members = select.Resolved.Value.Members.IsSpecified + ? select.Resolved.Value.Members.Value.Select(member => + { + member.Value.User = select.Resolved.Value.Users.Value.First(u => u.Key == member.Key).Value; + + return RestGuildUser.Create(discord, guild, member.Value); + }).ToImmutableArray() + : null; + + Channels = select.Resolved.Value.Channels.IsSpecified + ? select.Resolved.Value.Channels.Value.Select(channel => RestChannel.Create(discord, channel.Value)).ToImmutableArray() + : null; + + Roles = select.Resolved.Value.Roles.IsSpecified + ? select.Resolved.Value.Roles.Value.Select(role => RestRole.Create(discord, guild, role.Value)).ToImmutableArray() + : null; + } + } + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs new file mode 100644 index 0000000..f4c8c2b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModal.cs @@ -0,0 +1,558 @@ +using Discord.Net.Rest; +using Discord.Rest; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using DataModel = Discord.API.ModalInteractionData; +using ModelBase = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a user submitted . + /// + public class RestModal : RestInteraction, IDiscordInteraction, IModalInteraction + { + internal RestModal(DiscordRestClient client, ModelBase model) + : base(client, model.Id) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + if (model.Message.IsSpecified && model.ChannelId.IsSpecified) + { + Message = RestUserMessage.Create(Discord, Channel, User, model.Message.Value); + } + + Data = new RestModalData(dataModel, client, Guild); + } + + internal new static async Task CreateAsync(DiscordRestClient client, ModelBase model, bool doApiCall) + { + var entity = new RestModal(client, model); + await entity.UpdateAsync(client, model, doApiCall); + return entity; + } + + private object _lock = new object(); + + /// + /// Acknowledges this interaction with the if the modal was created + /// in a response to a message component interaction, otherwise. + /// + /// + /// A string that contains json to write back to the incoming http request. + /// + public override string Defer(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = Message is not null + ? InteractionResponseType.DeferredUpdateMessage + : InteractionResponseType.DeferredChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + /// Defers an interaction and responds with type 5 () + /// + /// to send this message ephemerally, otherwise . + /// The request options for this request. + /// + /// A string that contains json to write back to the incoming http request. + /// + public string DeferLoading(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredChannelMessageWithSource, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + + HasResponded = true; + } + + return SerializePayload(response); + } + + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// The sent message. + /// + public override Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// The sent message. + /// + public override Task FollowupWithFileAsync( + Stream fileStream, + string fileName, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + Preconditions.NotNull(fileStream, nameof(fileStream), "File Stream must have data"); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + File = fileStream is not null ? new MultipartFile(fileStream, fileName) : Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// The sent message. + /// + public override async Task FollowupWithFileAsync( + string filePath, + string text = null, + string fileName = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + Preconditions.NotNullOrEmpty(filePath, nameof(filePath), "Path must exist"); + + fileName ??= Path.GetFileName(filePath); + Preconditions.NotNullOrEmpty(fileName, nameof(fileName), "File Name must not be empty or null"); + + using var fileStream = !string.IsNullOrEmpty(filePath) ? new MemoryStream(File.ReadAllBytes(filePath), false) : null; + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + File = fileStream != null ? new MultipartFile(fileStream, fileName) : Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return await InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Responds to an Interaction with type . + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// Message content is too long, length must be less or equal to . + /// The parameters provided were invalid or the token was invalid. + /// + /// A string that contains json to write back to the incoming http request. + /// + public override string Respond( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent component = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Components = component?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + lock (_lock) + { + HasResponded = true; + } + + return SerializePayload(response); + } + + /// + public override Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + public override Task FollowupWithFileAsync( + FileAttachment attachment, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + return FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + } + + /// + public override string RespondWithModal(Modal modal, RequestOptions requestOptions = null) + => throw new NotSupportedException("Modal interactions cannot have modal responces!"); + + /// + public new RestModalData Data { get; set; } + + /// + public RestUserMessage Message { get; private set; } + + IUserMessage IModalInteraction.Message => Message; + + IModalInteractionData IModalInteraction.Data => Data; + + /// + Task IModalInteraction.DeferLoadingAsync(bool ephemeral, RequestOptions options) + => Task.FromResult(DeferLoading(ephemeral, options)); + + /// + public async Task UpdateAsync(Action func, RequestOptions options = null) + { + var args = new MessageProperties(); + func(args); + + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + if (args.AllowedMentions.IsSpecified) + { + var allowedMentions = args.AllowedMentions.Value; + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 user Ids are allowed."); + } + + var embed = args.Embed; + var embeds = args.Embeds; + + var apiEmbeds = embed.IsSpecified || embeds.IsSpecified ? new List() : null; + + if (embed.IsSpecified && embed.Value != null) + { + apiEmbeds.Add(embed.Value.ToModel()); + } + + if (embeds.IsSpecified && embeds.Value != null) + { + apiEmbeds.AddRange(embeds.Value.Select(x => x.ToModel())); + } + + Preconditions.AtMost(apiEmbeds?.Count ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (args.AllowedMentions.IsSpecified && args.AllowedMentions.Value != null && args.AllowedMentions.Value.AllowedTypes.HasValue) + { + var allowedMentions = args.AllowedMentions.Value; + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) + && allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(args.AllowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) + && allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(args.AllowedMentions)); + } + } + + if (!args.Attachments.IsSpecified) + { + var response = new API.InteractionResponse + { + Type = InteractionResponseType.UpdateMessage, + Data = new API.InteractionCallbackData + { + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + Components = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + } + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + else + { + var attachments = args.Attachments.Value?.ToArray() ?? Array.Empty(); + + var response = new API.Rest.UploadInteractionFileParams(attachments) + { + Type = InteractionResponseType.UpdateMessage, + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + MessageComponents = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + } + + HasResponded = true; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs new file mode 100644 index 0000000..c1f329e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DataModel = Discord.API.MessageComponentInteractionData; +using InterationModel = Discord.API.Interaction; +using Model = Discord.API.ModalInteractionData; + +namespace Discord.Rest +{ + /// + /// Represents data sent from a Interaction. + /// + public class RestModalData : IModalInteractionData + { + /// + public string CustomId { get; } + + /// + /// Represents the s components submitted by the user. + /// + public IReadOnlyCollection Components { get; } + + IReadOnlyCollection IModalInteractionData.Components => Components; + + internal RestModalData(Model model, BaseDiscordClient discord, IGuild guild) + { + CustomId = model.CustomId; + Components = model.Components + .SelectMany(x => x.Components) + .Select(x => new RestMessageComponentData(x, discord, guild)) + .ToArray(); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs new file mode 100644 index 0000000..79008dc --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommand.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommand; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based implementation of the . + /// + public abstract class RestApplicationCommand : RestEntity, IApplicationCommand + { + /// + public ulong ApplicationId { get; private set; } + + /// + public ApplicationCommandType Type { get; private set; } + + /// + public string Name { get; private set; } + + /// + public string Description { get; private set; } + + /// + public bool IsDefaultPermission { get; private set; } + + /// + [Obsolete("This property will be deprecated soon. Use ContextTypes instead.")] + public bool IsEnabledInDm { get; private set; } + + /// + public bool IsNsfw { get; private set; } + + /// + public GuildPermissions DefaultMemberPermissions { get; private set; } + + /// + /// Gets a collection of options for this command. + /// + public IReadOnlyCollection Options { get; private set; } + + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string DescriptionLocalized { get; private set; } + + /// + public IReadOnlyCollection IntegrationTypes { get; private set; } + + /// + public IReadOnlyCollection ContextTypes { get; private set; } + + /// + public DateTimeOffset CreatedAt + => SnowflakeUtils.FromSnowflake(Id); + + internal RestApplicationCommand(BaseDiscordClient client, ulong id) + : base(client, id) { } + + internal static RestApplicationCommand Create(BaseDiscordClient client, Model model, ulong? guildId) + { + return guildId.HasValue + ? RestGuildCommand.Create(client, model, guildId.Value) + : RestGlobalCommand.Create(client, model); + } + + internal virtual void Update(Model model) + { + Type = model.Type; + ApplicationId = model.ApplicationId; + Name = model.Name; + Description = model.Description; + IsDefaultPermission = model.DefaultPermissions.GetValueOrDefault(true); + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(RestApplicationCommandOption.Create).ToImmutableArray() + : ImmutableArray.Create(); + + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + NameLocalized = model.NameLocalized.GetValueOrDefault(); + DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault(); + +#pragma warning disable CS0618 // Type or member is obsolete + IsEnabledInDm = model.DmPermission.GetValueOrDefault(true).GetValueOrDefault(true); +#pragma warning restore CS0618 // Type or member is obsolete + DefaultMemberPermissions = new GuildPermissions((ulong)model.DefaultMemberPermission.GetValueOrDefault(0).GetValueOrDefault(0)); + IsNsfw = model.Nsfw.GetValueOrDefault(false).GetValueOrDefault(false); + + IntegrationTypes = model.IntegrationTypes.GetValueOrDefault(null)?.ToImmutableArray(); + ContextTypes = model.ContextTypes.GetValueOrDefault(null)?.ToImmutableArray(); + } + + /// + public abstract Task DeleteAsync(RequestOptions options = null); + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return ModifyAsync(func, options); + } + + /// + public abstract Task ModifyAsync(Action func, RequestOptions options = null) + where TArg : ApplicationCommandProperties; + + IReadOnlyCollection IApplicationCommand.Options => Options; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs new file mode 100644 index 0000000..b736c43 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandChoice.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using Model = Discord.API.ApplicationCommandOptionChoice; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based implementation of . + /// + public class RestApplicationCommandChoice : IApplicationCommandOptionChoice + { + /// + public string Name { get; } + + /// + public object Value { get; } + + /// + /// Gets the localization dictionary for the name field of this command option choice. + /// + public IReadOnlyDictionary NameLocalizations { get; } + + /// + /// Gets the localized name of this command option choice. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; } + + internal RestApplicationCommandChoice(Model model) + { + Name = model.Name; + Value = model.Value; + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary(); + NameLocalized = model.NameLocalized.GetValueOrDefault(null); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs new file mode 100644 index 0000000..3ac15e6 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/RestApplicationCommandOption.cs @@ -0,0 +1,146 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.ApplicationCommandOption; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based implementation of . + /// + public class RestApplicationCommandOption : IApplicationCommandOption + { + #region RestApplicationCommandOption + /// + public ApplicationCommandOptionType Type { get; private set; } + + /// + public string Name { get; private set; } + + /// + public string Description { get; private set; } + + /// + public bool? IsDefault { get; private set; } + + /// + public bool? IsRequired { get; private set; } + + /// + public bool? IsAutocomplete { get; private set; } + + /// + public double? MinValue { get; private set; } + + /// + public double? MaxValue { get; private set; } + + /// + public int? MinLength { get; private set; } + + /// + public int? MaxLength { get; private set; } + + /// + /// Gets a collection of s for this command. + /// + public IReadOnlyCollection Choices { get; private set; } + + /// + /// Gets a collection of s for this command. + /// + public IReadOnlyCollection Options { get; private set; } + + /// + public IReadOnlyCollection ChannelTypes { get; private set; } + + /// + /// Gets the localization dictionary for the name field of this command option. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command option. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string DescriptionLocalized { get; private set; } + + internal RestApplicationCommandOption() { } + + internal static RestApplicationCommandOption Create(Model model) + { + var options = new RestApplicationCommandOption(); + options.Update(model); + return options; + } + + internal void Update(Model model) + { + Type = model.Type; + Name = model.Name; + Description = model.Description; + + if (model.Default.IsSpecified) + IsDefault = model.Default.Value; + + if (model.Required.IsSpecified) + IsRequired = model.Required.Value; + + if (model.MinValue.IsSpecified) + MinValue = model.MinValue.Value; + + if (model.MaxValue.IsSpecified) + MaxValue = model.MaxValue.Value; + + if (model.Autocomplete.IsSpecified) + IsAutocomplete = model.Autocomplete.Value; + + MinLength = model.MinLength.ToNullable(); + MaxLength = model.MaxLength.ToNullable(); + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(Create).ToImmutableArray() + : ImmutableArray.Create(); + + Choices = model.Choices.IsSpecified + ? model.Choices.Value.Select(x => new RestApplicationCommandChoice(x)).ToImmutableArray() + : ImmutableArray.Create(); + + ChannelTypes = model.ChannelTypes.IsSpecified + ? model.ChannelTypes.Value.ToImmutableArray() + : ImmutableArray.Create(); + + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + NameLocalized = model.NameLocalized.GetValueOrDefault(); + DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault(); + } + #endregion + + #region IApplicationCommandOption + IReadOnlyCollection IApplicationCommandOption.Options + => Options; + IReadOnlyCollection IApplicationCommandOption.Choices + => Choices; + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestGlobalCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/RestGlobalCommand.cs new file mode 100644 index 0000000..dce381b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/RestGlobalCommand.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommand; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based global application command. + /// + public class RestGlobalCommand : RestApplicationCommand + { + internal RestGlobalCommand(BaseDiscordClient client, ulong id) + : base(client, id) { } + + internal static RestGlobalCommand Create(BaseDiscordClient client, Model model) + { + var entity = new RestGlobalCommand(client, model.Id); + entity.Update(model); + return entity; + } + + /// + public override Task DeleteAsync(RequestOptions options = null) + => InteractionHelper.DeleteGlobalCommandAsync(Discord, this); + + /// + /// Modifies this . + /// + /// The delegate containing the properties to modify the command with. + /// The options to be used when sending the request. + /// + /// The modified command. + /// + public override async Task ModifyAsync(Action func, RequestOptions options = null) + { + var cmd = await InteractionHelper.ModifyGlobalCommandAsync(Discord, this, func, options).ConfigureAwait(false); + Update(cmd); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestGuildCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/RestGuildCommand.cs new file mode 100644 index 0000000..41a5d1b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/RestGuildCommand.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommand; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based guild application command. + /// + public class RestGuildCommand : RestApplicationCommand + { + /// + /// Gets the guild Id where this command originates. + /// + public ulong GuildId { get; private set; } + + internal RestGuildCommand(BaseDiscordClient client, ulong id, ulong guildId) + : base(client, id) + { + GuildId = guildId; + } + + internal static RestGuildCommand Create(BaseDiscordClient client, Model model, ulong guildId) + { + var entity = new RestGuildCommand(client, model.Id, guildId); + entity.Update(model); + return entity; + } + + /// + public override Task DeleteAsync(RequestOptions options = null) + => InteractionHelper.DeleteGuildCommandAsync(Discord, GuildId, this); + + /// + /// Modifies this . + /// + /// The delegate containing the properties to modify the command with. + /// The options to be used when sending the request. + /// + /// The modified command + /// + public override async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await InteractionHelper.ModifyGuildCommandAsync(Discord, this, GuildId, func, options).ConfigureAwait(false); + Update(model); + } + + /// + /// Gets this commands permissions inside of the current guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a + /// object defining the permissions of the current slash command. + /// + public Task GetCommandPermission(RequestOptions options = null) + => InteractionHelper.GetGuildCommandPermissionAsync(Discord, GuildId, Id, options); + + /// + /// Modifies the current command permissions for this guild command. + /// + /// The permissions to overwrite. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. The task result contains a + /// object containing the modified permissions. + /// + public Task ModifyCommandPermissions(ApplicationCommandPermission[] permissions, RequestOptions options = null) + => InteractionHelper.ModifyGuildCommandPermissionsAsync(Discord, GuildId, Id, permissions, options); + + /// + /// Gets the guild that this slash command resides in. + /// + /// if you want the approximate member and presence counts for the guild, otherwise . + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a + /// . + /// + public Task GetGuild(bool withCounts = false, RequestOptions options = null) + => ClientHelper.GetGuildAsync(Discord, GuildId, withCounts, options); + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs new file mode 100644 index 0000000..0360df9 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/RestInteraction.cs @@ -0,0 +1,497 @@ +using Discord.Net; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based interaction. + /// + public abstract class RestInteraction : RestEntity, IDiscordInteraction + { + // Added so channel & guild methods don't need a client reference + private Func> _getChannel; + private Func> _getGuild; + + /// + public InteractionType Type { get; private set; } + + /// + public IDiscordInteractionData Data { get; private set; } + + /// + public string Token { get; private set; } + + /// + public int Version { get; private set; } + + /// + /// Gets the user who invoked the interaction. + /// + /// + /// If this user is an and is set to false, + /// will return + /// + public RestUser User { get; private set; } + + /// + public string UserLocale { get; private set; } + + /// + public string GuildLocale { get; private set; } + + /// + public DateTimeOffset CreatedAt { get; private set; } + + /// + /// Gets whether or not the token used to respond to this interaction is valid. + /// + public bool IsValidToken + => InteractionHelper.CanRespondOrFollowup(this); + + /// + /// Gets the channel that this interaction was executed in. + /// + /// + /// This property will be if is set to false. + /// Call to set this property and get the interaction channel. + /// + public IRestMessageChannel Channel { get; private set; } + + /// + public ulong? ChannelId { get; private set; } + + /// + /// Gets the guild this interaction was executed in if applicable. + /// + /// + /// This property will be if is set to false + /// or if the interaction was not executed in a guild. + /// + public RestGuild Guild { get; private set; } + + /// + public ulong? GuildId { get; private set; } + + /// + public bool HasResponded { get; protected set; } + + /// + public bool IsDMInteraction { get; private set; } + + /// + public ulong ApplicationId { get; private set; } + + /// + public InteractionContextType? ContextType { get; private set; } + + /// + public GuildPermissions Permissions { get; private set; } + + /// + public IReadOnlyCollection Entitlements { get; private set; } + + /// + public IReadOnlyDictionary IntegrationOwners { get; private set; } + + internal RestInteraction(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + CreatedAt = discord.UseInteractionSnowflakeDate + ? SnowflakeUtils.FromSnowflake(Id) + : DateTime.UtcNow; + } + + internal static async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + if (model.Type == InteractionType.Ping) + { + return await RestPingInteraction.CreateAsync(client, model, doApiCall); + } + + if (model.Type == InteractionType.ApplicationCommand) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + if (dataModel == null) + return null; + + return dataModel.Type switch + { + ApplicationCommandType.Slash => await RestSlashCommand.CreateAsync(client, model, doApiCall).ConfigureAwait(false), + ApplicationCommandType.Message => await RestMessageCommand.CreateAsync(client, model, doApiCall).ConfigureAwait(false), + ApplicationCommandType.User => await RestUserCommand.CreateAsync(client, model, doApiCall).ConfigureAwait(false), + _ => null + }; + } + + if (model.Type == InteractionType.MessageComponent) + return await RestMessageComponent.CreateAsync(client, model, doApiCall).ConfigureAwait(false); + + if (model.Type == InteractionType.ApplicationCommandAutocomplete) + return await RestAutocompleteInteraction.CreateAsync(client, model, doApiCall).ConfigureAwait(false); + + if (model.Type == InteractionType.ModalSubmit) + return await RestModal.CreateAsync(client, model, doApiCall).ConfigureAwait(false); + + return null; + } + + internal virtual async Task UpdateAsync(DiscordRestClient discord, Model model, bool doApiCall) + { + ChannelId = model.Channel.IsSpecified + ? model.Channel.Value.Id + : null; + + GuildId = model.GuildId.IsSpecified + ? model.GuildId.Value + : null; + + IsDMInteraction = GuildId is null; + + Data = model.Data.IsSpecified + ? model.Data.Value + : null; + + Token = model.Token; + Version = model.Version; + Type = model.Type; + ApplicationId = model.ApplicationId; + + if (Guild is null && GuildId is not null) + { + if (doApiCall) + Guild = await discord.GetGuildAsync(GuildId.Value); + else + { + Guild = null; + _getGuild = async (opt, ul) => await discord.GetGuildAsync(ul, opt); + } + } + + if (User is null) + { + if (model.Member.IsSpecified && GuildId is not null) + { + User = RestGuildUser.Create(Discord, Guild, model.Member.Value, GuildId); + } + else + { + User = RestUser.Create(Discord, model.User.Value); + } + } + + + if (Channel is null && ChannelId is not null) + { + try + { + if (doApiCall) + Channel = (IRestMessageChannel)await discord.GetChannelAsync(ChannelId.Value); + else + { + if (model.Channel.IsSpecified) + { + Channel = model.Channel.Value.Type switch + { + ChannelType.News or + ChannelType.Text or + ChannelType.Voice or + ChannelType.Stage or + ChannelType.NewsThread or + ChannelType.PrivateThread or + ChannelType.PublicThread or + ChannelType.Media or + ChannelType.Forum + => RestGuildChannel.Create(discord, Guild, model.Channel.Value) as IRestMessageChannel, + ChannelType.DM => RestDMChannel.Create(discord, model.Channel.Value), + ChannelType.Group => RestGroupChannel.Create(discord, model.Channel.Value), + _ => null + }; + } + _getChannel = async (opt, ul) => + { + if (Guild is null) + return (IRestMessageChannel)await discord.GetChannelAsync(ul, opt); + + // get a guild channel if the guild is set. + return (IRestMessageChannel)await Guild.GetChannelAsync(ul, opt); + }; + } + } + catch (HttpException x) when (x.DiscordCode == DiscordErrorCode.MissingPermissions) { } // ignore + } + + UserLocale = model.UserLocale.IsSpecified + ? model.UserLocale.Value + : null; + + GuildLocale = model.GuildLocale.IsSpecified + ? model.GuildLocale.Value + : null; + + Entitlements = model.Entitlements.Select(x => RestEntitlement.Create(discord, x)).ToImmutableArray(); + + IntegrationOwners = model.IntegrationOwners; + ContextType = model.ContextType.IsSpecified + ? model.ContextType.Value + : null; + + Permissions = new GuildPermissions((ulong)model.ApplicationPermissions); + } + + internal string SerializePayload(object payload) + { + var json = new StringBuilder(); + using (var text = new StringWriter(json)) + using (var writer = new JsonTextWriter(text)) + DiscordRestClient.Serializer.Serialize(writer, payload); + + return json.ToString(); + } + + /// + /// Gets the channel this interaction was executed in. Will be a DM channel if the interaction was executed in DM. + /// + /// + /// Calling this method successfully will populate the property. + /// After this, further calls to this method will no longer call the API, and depend on the value set in . + /// + /// The request options for this request. + /// A Rest channel to send messages to. + /// Thrown if no channel can be received. + public async Task GetChannelAsync(RequestOptions options = null) + { + if (Channel is not null) + return Channel; + + if (IsDMInteraction) + { + Channel = await User.CreateDMChannelAsync(options); + } + else if (ChannelId is not null) + { + Channel = await _getChannel(options, ChannelId.Value) ?? throw new InvalidOperationException("The interaction channel was not able to be retrieved."); + _getChannel = null; // get rid of it, we don't need it anymore. + } + + return Channel; + } + + /// + /// Gets the guild this interaction was executed in if applicable. + /// + /// + /// Calling this method successfully will populate the property. + /// After this, further calls to this method will no longer call the API, and depend on the value set in . + /// + /// The request options for this request. + /// The guild this interaction was executed in. if the interaction was executed inside DM. + public async Task GetGuildAsync(RequestOptions options) + { + if (GuildId is null) + return null; + + Guild ??= await _getGuild(options, GuildId.Value); + _getGuild = null; // get rid of it, we don't need it anymore. + + return Guild; + } + + /// + public abstract string Defer(bool ephemeral = false, RequestOptions options = null); + /// + /// Gets the original response for this interaction. + /// + /// The request options for this request. + /// A that represents the initial response. + public Task GetOriginalResponseAsync(RequestOptions options = null) + => InteractionHelper.GetOriginalResponseAsync(Discord, Channel, this, options); + + /// + /// Edits original response for this interaction. + /// + /// A delegate containing the properties to modify the message with. + /// The request options for this request. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public async Task ModifyOriginalResponseAsync(Action func, RequestOptions options = null) + { + var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options); + return RestInteractionMessage.Create(Discord, model, Token, Channel); + } + /// + public abstract string RespondWithModal(Modal modal, RequestOptions options = null); + + /// + public abstract string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + public Task DeleteOriginalResponseAsync(RequestOptions options = null) + => InteractionHelper.DeleteInteractionResponseAsync(Discord, this, options); + + /// + public Task RespondWithPremiumRequiredAsync(RequestOptions options = null) + => InteractionHelper.RespondWithPremiumRequiredAsync(Discord, Id, Token, options); + + #region IDiscordInteraction + /// + IReadOnlyCollection IDiscordInteraction.Entitlements => Entitlements; + + /// + IUser IDiscordInteraction.User => User; + + /// + Task IDiscordInteraction.RespondAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => Task.FromResult(Respond(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options)); + /// + Task IDiscordInteraction.DeferAsync(bool ephemeral, RequestOptions options) + => Task.FromResult(Defer(ephemeral, options)); + /// + Task IDiscordInteraction.RespondWithModalAsync(Modal modal, RequestOptions options) + => Task.FromResult(RespondWithModal(modal, options)); + /// + async Task IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, + MessageComponent components, Embed embed, RequestOptions options) + => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.GetOriginalResponseAsync(RequestOptions options) + => await GetOriginalResponseAsync(options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.ModifyOriginalResponseAsync(Action func, RequestOptions options) + => await ModifyOriginalResponseAsync(func, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(Stream fileStream, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, + AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(fileStream, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(string filePath, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, + AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(filePath, text, fileName, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(FileAttachment attachment, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(attachment, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFilesAsync(IEnumerable attachments, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFilesAsync(attachments, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + Task IDiscordInteraction.RespondWithFilesAsync(IEnumerable attachments, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) => throw new NotSupportedException("REST-Based interactions don't support files."); +#if NETCOREAPP3_0_OR_GREATER != true + /// + Task IDiscordInteraction.RespondWithFileAsync(Stream fileStream, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) => throw new NotSupportedException("REST-Based interactions don't support files."); + /// + Task IDiscordInteraction.RespondWithFileAsync(string filePath, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) => throw new NotSupportedException("REST-Based interactions don't support files."); + /// + Task IDiscordInteraction.RespondWithFileAsync(FileAttachment attachment, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) => throw new NotSupportedException("REST-Based interactions don't support files."); +#endif + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs new file mode 100644 index 0000000..47e1a3b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/RestPingInteraction.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based ping interaction. + /// + public class RestPingInteraction : RestInteraction, IDiscordInteraction + { + internal RestPingInteraction(BaseDiscordClient client, ulong id) + : base(client, id) + { + } + + internal static new async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + var entity = new RestPingInteraction(client, model.Id); + await entity.UpdateAsync(client, model, doApiCall); + return entity; + } + + public string AcknowledgePing() + { + var model = new API.InteractionResponse() + { + Type = InteractionResponseType.Pong + }; + + return SerializePayload(model); + } + + public override string Defer(bool ephemeral = false, RequestOptions options = null) => throw new NotSupportedException(); + public override string RespondWithModal(Modal modal, RequestOptions options = null) => throw new NotSupportedException(); + public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + public override Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) => throw new NotSupportedException(); + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs new file mode 100644 index 0000000..ddcab2e --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteraction.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DataModel = Discord.API.AutocompleteInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based autocomplete interaction. + /// + public class RestAutocompleteInteraction : RestInteraction, IAutocompleteInteraction, IDiscordInteraction + { + /// + /// Gets the autocomplete data of this interaction. + /// + public new RestAutocompleteInteractionData Data { get; } + + private object _lock = new object(); + + internal RestAutocompleteInteraction(DiscordRestClient client, Model model) + : base(client, model.Id) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + if (dataModel != null) + Data = new RestAutocompleteInteractionData(dataModel); + } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + var entity = new RestAutocompleteInteraction(client, model); + await entity.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + return entity; + } + + /// + /// Responds to this interaction with a set of choices. + /// + /// + /// The set of choices for the user to pick from. + /// + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that + /// there is no choices for their autocompleted input. + /// + /// + /// The request options for this response. + /// + /// A string that contains json to write back to the incoming http request. + /// + public string Respond(IEnumerable result, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + + HasResponded = true; + } + + var model = new API.InteractionResponse + { + Type = InteractionResponseType.ApplicationCommandAutocompleteResult, + Data = new API.InteractionCallbackData + { + Choices = result.Any() + ? result.Select(x => new API.ApplicationCommandOptionChoice { Name = x.Name, Value = x.Value }).ToArray() + : Array.Empty() + } + }; + + return SerializePayload(model); + } + + /// + /// Responds to this interaction with a set of choices. + /// + /// The request options for this response. + /// + /// The set of choices for the user to pick from. + /// + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that + /// there is no choices for their autocompleted input. + /// + /// + /// + /// A string that contains json to write back to the incoming http request. + /// + public string Respond(RequestOptions options = null, params AutocompleteResult[] result) + => Respond(result, options); + public override string Defer(bool ephemeral = false, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override string Respond(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override string RespondWithModal(Modal modal, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + + //IAutocompleteInteraction + /// + IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; + + /// + Task IAutocompleteInteraction.RespondAsync(IEnumerable result, RequestOptions options) + =>Task.FromResult(Respond(result, options)); + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteractionData.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteractionData.cs new file mode 100644 index 0000000..a723745 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestAutocompleteInteractionData.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DataModel = Discord.API.AutocompleteInteractionData; + +namespace Discord.Rest +{ + /// + /// Represents the data for a . + /// + public class RestAutocompleteInteractionData : IAutocompleteInteractionData + { + /// + public string CommandName { get; } + + /// + public ulong CommandId { get; } + + /// + public ApplicationCommandType Type { get; } + + /// + public ulong Version { get; } + + /// + public AutocompleteOption Current { get; } + + /// + public IReadOnlyCollection Options { get; } + + internal RestAutocompleteInteractionData(DataModel model) + { + var options = model.Options.SelectMany(GetOptions); + + Current = options.FirstOrDefault(x => x.Focused); + Options = options.ToImmutableArray(); + + if (Options.Count == 1 && Current == null) + Current = Options.FirstOrDefault(); + + CommandName = model.Name; + CommandId = model.Id; + Type = model.Type; + Version = model.Version; + } + + private List GetOptions(API.AutocompleteInteractionDataOption model) + { + var options = new List(); + + options.Add(new AutocompleteOption(model.Type, model.Name, model.Value.GetValueOrDefault(null), model.Focused.GetValueOrDefault(false))); + + if (model.Options.IsSpecified) + { + options.AddRange(model.Options.Value.SelectMany(GetOptions)); + } + + return options; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommand.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommand.cs new file mode 100644 index 0000000..8fbd3a8 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommand.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based slash command. + /// + public class RestSlashCommand : RestCommandBase, ISlashCommandInteraction, IDiscordInteraction + { + /// + /// Gets the data associated with this interaction. + /// + public new RestSlashCommandData Data { get; private set; } + + internal RestSlashCommand(DiscordRestClient client, Model model) + : base(client, model) + { + } + + internal new static async Task CreateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + var entity = new RestSlashCommand(client, model); + await entity.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + return entity; + } + + internal override async Task UpdateAsync(DiscordRestClient client, Model model, bool doApiCall) + { + await base.UpdateAsync(client, model, doApiCall).ConfigureAwait(false); + + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data = await RestSlashCommandData.CreateAsync(client, dataModel, Guild, Channel, doApiCall).ConfigureAwait(false); + } + + //ISlashCommandInteraction + /// + IApplicationCommandInteractionData ISlashCommandInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommandData.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommandData.cs new file mode 100644 index 0000000..19a819a --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommandData.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.Rest +{ + + public class RestSlashCommandData : RestCommandBaseData, IDiscordInteractionData + { + internal RestSlashCommandData(DiscordRestClient client, Model model) + : base(client, model) { } + + internal static new async Task CreateAsync(DiscordRestClient client, Model model, RestGuild guild, IRestMessageChannel channel, bool doApiCall) + { + var entity = new RestSlashCommandData(client, model); + await entity.UpdateAsync(client, model, guild, channel, doApiCall).ConfigureAwait(false); + return entity; + } + internal override async Task UpdateAsync(DiscordRestClient client, Model model, RestGuild guild, IRestMessageChannel channel, bool doApiCall) + { + await base.UpdateAsync(client, model, guild, channel, doApiCall).ConfigureAwait(false); + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(x => new RestSlashCommandDataOption(this, x)).ToImmutableArray() + : ImmutableArray.Create(); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommandDataOption.cs b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommandDataOption.cs new file mode 100644 index 0000000..cbb9589 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Interactions/SlashCommands/RestSlashCommandDataOption.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.ApplicationCommandInteractionDataOption; + + +namespace Discord.Rest +{ + /// + /// Represents a REST-based option for a slash command. + /// + public class RestSlashCommandDataOption : IApplicationCommandInteractionDataOption + { + #region RestSlashCommandDataOption + /// + public string Name { get; private set; } + + /// + public object Value { get; private set; } + + /// + public ApplicationCommandOptionType Type { get; private set; } + + /// + /// Gets a collection of sub command options received for this sub command group. + /// + public IReadOnlyCollection Options { get; private set; } + + internal RestSlashCommandDataOption() { } + internal RestSlashCommandDataOption(RestSlashCommandData data, Model model) + { + Name = model.Name; + Type = model.Type; + + if (model.Value.IsSpecified) + { + switch (Type) + { + case ApplicationCommandOptionType.User: + case ApplicationCommandOptionType.Role: + case ApplicationCommandOptionType.Channel: + case ApplicationCommandOptionType.Mentionable: + case ApplicationCommandOptionType.Attachment: + if (ulong.TryParse($"{model.Value.Value}", out var valueId)) + { + switch (Type) + { + case ApplicationCommandOptionType.User: + { + var guildUser = data.ResolvableData.GuildMembers.FirstOrDefault(x => x.Key == valueId).Value; + + if (guildUser != null) + Value = guildUser; + else + Value = data.ResolvableData.Users.FirstOrDefault(x => x.Key == valueId).Value; + } + break; + case ApplicationCommandOptionType.Channel: + Value = data.ResolvableData.Channels.FirstOrDefault(x => x.Key == valueId).Value; + break; + case ApplicationCommandOptionType.Role: + Value = data.ResolvableData.Roles.FirstOrDefault(x => x.Key == valueId).Value; + break; + case ApplicationCommandOptionType.Mentionable: + { + if (data.ResolvableData.GuildMembers.Any(x => x.Key == valueId) || data.ResolvableData.Users.Any(x => x.Key == valueId)) + { + var guildUser = data.ResolvableData.GuildMembers.FirstOrDefault(x => x.Key == valueId).Value; + + if (guildUser != null) + Value = guildUser; + else + Value = data.ResolvableData.Users.FirstOrDefault(x => x.Key == valueId).Value; + } + else if (data.ResolvableData.Roles.Any(x => x.Key == valueId)) + { + Value = data.ResolvableData.Roles.FirstOrDefault(x => x.Key == valueId).Value; + } + } + break; + case ApplicationCommandOptionType.Attachment: + Value = data.ResolvableData.Attachments.FirstOrDefault(x => x.Key == valueId).Value; + break; + default: + Value = model.Value.Value; + break; + } + } + break; + case ApplicationCommandOptionType.String: + Value = model.Value.ToString(); + break; + case ApplicationCommandOptionType.Integer: + { + if (model.Value.Value is long val) + Value = val; + else if (long.TryParse(model.Value.Value.ToString(), out long res)) + Value = res; + } + break; + case ApplicationCommandOptionType.Boolean: + { + if (model.Value.Value is bool val) + Value = val; + else if (bool.TryParse(model.Value.Value.ToString(), out bool res)) + Value = res; + } + break; + case ApplicationCommandOptionType.Number: + { + if (model.Value.Value is int val) + Value = val; + else if (double.TryParse(model.Value.Value.ToString(), out double res)) + Value = res; + } + break; + } + } + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(x => new RestSlashCommandDataOption(data, x)).ToImmutableArray() + : ImmutableArray.Create(); + } + #endregion + + #region Converters + public static explicit operator bool(RestSlashCommandDataOption option) + => (bool)option.Value; + public static explicit operator int(RestSlashCommandDataOption option) + => (int)option.Value; + public static explicit operator string(RestSlashCommandDataOption option) + => option.Value.ToString(); + #endregion + + #region IApplicationCommandInteractionDataOption + IReadOnlyCollection IApplicationCommandInteractionDataOption.Options + => Options; + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Invites/InviteHelper.cs b/src/Discord.Net.Rest/Entities/Invites/InviteHelper.cs new file mode 100644 index 0000000..c697b48 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Invites/InviteHelper.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace Discord.Rest +{ + internal static class InviteHelper + { + public static Task DeleteAsync(IInvite invite, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.DeleteInviteAsync(invite.Code, options); + } +} diff --git a/src/Discord.Net.Rest/Entities/Invites/RestInvite.cs b/src/Discord.Net.Rest/Entities/Invites/RestInvite.cs new file mode 100644 index 0000000..cf2cb3c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Invites/RestInvite.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Invite; + +namespace Discord.Rest +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestInvite : RestEntity, IInvite, IUpdateable + { + public ChannelType ChannelType { get; private set; } + /// + public string ChannelName { get; private set; } + /// + public string GuildName { get; private set; } + /// + public int? PresenceCount { get; private set; } + /// + public int? MemberCount { get; private set; } + /// + public ulong ChannelId { get; private set; } + /// + public ulong? GuildId { get; private set; } + /// + public IUser Inviter { get; private set; } + /// + public IUser TargetUser { get; private set; } + /// + public TargetUserType TargetUserType { get; private set; } + + /// + /// Gets the guild this invite is linked to. + /// + /// + /// A partial guild object representing the guild that the invite points to. + /// + public PartialGuild PartialGuild { get; private set; } + + /// + public RestApplication Application { get; private set; } + + /// + public DateTimeOffset? ExpiresAt { get; private set; } + + /// + /// Gets guild scheduled event data. if event id was invalid. + /// + public RestGuildEvent ScheduledEvent { get; private set; } + + internal IChannel Channel { get; } + + internal IGuild Guild { get; } + + /// + public string Code => Id; + /// + public string Url => $"{DiscordConfig.InviteUrl}{Code}"; + + internal RestInvite(BaseDiscordClient discord, IGuild guild, IChannel channel, string id) + : base(discord, id) + { + Guild = guild; + Channel = channel; + } + internal static RestInvite Create(BaseDiscordClient discord, IGuild guild, IChannel channel, Model model) + { + var entity = new RestInvite(discord, guild, channel, model.Code); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + GuildId = model.Guild.IsSpecified ? model.Guild.Value.Id : default(ulong?); + ChannelId = model.Channel.Id; + GuildName = model.Guild.IsSpecified ? model.Guild.Value.Name : null; + ChannelName = model.Channel.Name; + MemberCount = model.MemberCount.IsSpecified ? model.MemberCount.Value : null; + PresenceCount = model.PresenceCount.IsSpecified ? model.PresenceCount.Value : null; + ChannelType = (ChannelType)model.Channel.Type; + Inviter = model.Inviter.IsSpecified ? RestUser.Create(Discord, model.Inviter.Value) : null; + TargetUser = model.TargetUser.IsSpecified ? RestUser.Create(Discord, model.TargetUser.Value) : null; + TargetUserType = model.TargetUserType.IsSpecified ? model.TargetUserType.Value : TargetUserType.Undefined; + + if (model.Guild.IsSpecified) + { + PartialGuild = PartialGuildExtensions.Create(model.Guild.Value); + } + + if(model.Application.IsSpecified) + Application = RestApplication.Create(Discord, model.Application.Value); + + ExpiresAt = model.ExpiresAt.IsSpecified ? model.ExpiresAt.Value : null; + + if(model.ScheduledEvent.IsSpecified) + ScheduledEvent = RestGuildEvent.Create(Discord, Guild, model.ScheduledEvent.Value); + } + + /// + public async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetInviteAsync(Code, options).ConfigureAwait(false); + Update(model); + } + /// + public Task DeleteAsync(RequestOptions options = null) + => InviteHelper.DeleteAsync(this, Discord, options); + + /// + /// Gets the URL of the invite. + /// + /// + /// A string that resolves to the Url of the invite. + /// + public override string ToString() => Url; + private string DebuggerDisplay => $"{Url} ({GuildName} / {ChannelName})"; + + #region IInvite + + /// + IGuild IInvite.Guild + { + get + { + if (Guild != null) + return Guild; + if (Channel is IGuildChannel guildChannel) + return guildChannel.Guild; //If it fails, it'll still return this exception + throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + } + } + /// + IChannel IInvite.Channel + { + get + { + if (Channel != null) + return Channel; + throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + } + } + + /// + IApplication IInvite.Application => Application; + + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Invites/RestInviteMetadata.cs b/src/Discord.Net.Rest/Entities/Invites/RestInviteMetadata.cs new file mode 100644 index 0000000..a0ed9ec --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Invites/RestInviteMetadata.cs @@ -0,0 +1,43 @@ +using System; +using Model = Discord.API.InviteMetadata; + +namespace Discord.Rest +{ + /// Represents additional information regarding the REST-based invite object. + public class RestInviteMetadata : RestInvite, IInviteMetadata + { + private long _createdAtTicks; + + /// + public bool IsTemporary { get; private set; } + /// + public int? MaxAge { get; private set; } + /// + public int? MaxUses { get; private set; } + /// + public int? Uses { get; private set; } + + /// + public DateTimeOffset? CreatedAt => DateTimeUtils.FromTicks(_createdAtTicks); + + internal RestInviteMetadata(BaseDiscordClient discord, IGuild guild, IChannel channel, string id) + : base(discord, guild, channel, id) + { + } + internal static RestInviteMetadata Create(BaseDiscordClient discord, IGuild guild, IChannel channel, Model model) + { + var entity = new RestInviteMetadata(discord, guild, channel, model.Code); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + base.Update(model); + IsTemporary = model.Temporary; + MaxAge = model.MaxAge; + MaxUses = model.MaxUses; + Uses = model.Uses; + _createdAtTicks = model.CreatedAt.UtcTicks; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/Attachment.cs b/src/Discord.Net.Rest/Entities/Messages/Attachment.cs new file mode 100644 index 0000000..592b2a5 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/Attachment.cs @@ -0,0 +1,104 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Model = Discord.API.Attachment; + +namespace Discord +{ + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class Attachment : IAttachment + { + /// + public ulong Id { get; } + /// + public string Filename { get; } + /// + public string Url { get; } + /// + public string ProxyUrl { get; } + /// + public int Size { get; } + /// + public int? Height { get; } + /// + public int? Width { get; } + /// + public bool Ephemeral { get; } + /// + public string Description { get; } + /// + public string ContentType { get; } + /// + public string Waveform { get; } + /// + public double? Duration { get; } + + /// + public IReadOnlyCollection ClipParticipants { get; } + + /// + public string Title { get; } + + /// + public DateTimeOffset? ClipCreatedAt { get; } + + /// + public AttachmentFlags Flags { get; } + + internal Attachment(ulong id, string filename, string url, string proxyUrl, int size, int? height, int? width, + bool? ephemeral, string description, string contentType, double? duration, string waveform, AttachmentFlags flags, string title, + IReadOnlyCollection clipParticipants, DateTimeOffset? clipCreatedAt) + { + Id = id; + Filename = filename; + Url = url; + ProxyUrl = proxyUrl; + Size = size; + Height = height; + Width = width; + Ephemeral = ephemeral.GetValueOrDefault(false); + Description = description; + ContentType = contentType; + Duration = duration; + Waveform = waveform; + Flags = flags; + Title = title; + ClipParticipants = clipParticipants; + ClipCreatedAt = clipCreatedAt; + } + + internal static Attachment Create(Model model, BaseDiscordClient discord) + { + return new Attachment(model.Id, model.Filename, model.Url, model.ProxyUrl, model.Size, + model.Height.IsSpecified ? model.Height.Value : null, + model.Width.IsSpecified ? model.Width.Value : null, + model.Ephemeral.ToNullable(), model.Description.GetValueOrDefault(), + model.ContentType.GetValueOrDefault(), + model.DurationSeconds.IsSpecified ? model.DurationSeconds.Value : null, + model.Waveform.GetValueOrDefault(null), + model.Flags.GetValueOrDefault(AttachmentFlags.None), + model.Title.GetValueOrDefault(null), + model.ClipParticipants.GetValueOrDefault(Array.Empty()).Select(x => RestUser.Create(discord, x)).ToImmutableArray(), + model.ClipCreatedAt.IsSpecified ? model.ClipCreatedAt.Value : null); + } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + /// Returns the filename of this attachment. + /// + /// + /// A string containing the filename of this attachment. + /// + public override string ToString() => Filename; + private string DebuggerDisplay => $"{Filename} ({Size} bytes)"; + + /// + IReadOnlyCollection IAttachment.ClipParticipants => ClipParticipants; + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/CustomSticker.cs b/src/Discord.Net.Rest/Entities/Messages/CustomSticker.cs new file mode 100644 index 0000000..6fd0f77 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/CustomSticker.cs @@ -0,0 +1,74 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Sticker; + +namespace Discord.Rest +{ + /// + /// Represents a Rest-based custom sticker within a guild. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class CustomSticker : Sticker, ICustomSticker + { + /// + /// Gets the users id who uploaded the sticker. + /// + /// + /// In order to get the author id, the bot needs the MANAGE_EMOJIS_AND_STICKERS permission. + /// + public ulong? AuthorId { get; private set; } + + /// + /// Gets the guild that this custom sticker is in. + /// + /// + /// Note: This property can be if the sticker wasn't fetched from a guild. + /// + public RestGuild Guild { get; private set; } + + private ulong GuildId { get; set; } + + internal CustomSticker(BaseDiscordClient client, ulong id, RestGuild guild, ulong? authorId = null) + : base(client, id) + { + AuthorId = authorId; + Guild = guild; + } + internal CustomSticker(BaseDiscordClient client, ulong id, ulong guildId, ulong? authorId = null) + : base(client, id) + { + AuthorId = authorId; + GuildId = guildId; + } + + internal static CustomSticker Create(BaseDiscordClient client, Model model, RestGuild guild, ulong? authorId = null) + { + var entity = new CustomSticker(client, model.Id, guild, authorId); + entity.Update(model); + return entity; + } + + internal static CustomSticker Create(BaseDiscordClient client, Model model, ulong guildId, ulong? authorId = null) + { + var entity = new CustomSticker(client, model.Id, guildId, authorId); + entity.Update(model); + return entity; + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => GuildHelper.DeleteStickerAsync(Discord, GuildId, this, options); + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await GuildHelper.ModifyStickerAsync(Discord, GuildId, this, func, options); + Update(model); + } + + private string DebuggerDisplay => Guild != null ? $"{Name} in {Guild.Name} ({Id})" : $"{Name} ({Id})"; + + IGuild ICustomSticker.Guild => Guild; + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs b/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs new file mode 100644 index 0000000..a9d5c1f --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/MessageHelper.cs @@ -0,0 +1,390 @@ +using Discord.API; +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Model = Discord.API.Message; +using UserModel = Discord.API.User; + +namespace Discord.Rest +{ + internal static class MessageHelper + { + /// + /// Regex used to check if some text is formatted as inline code. + /// + private static readonly Regex InlineCodeRegex = new Regex(@"[^\\]?(`).+?[^\\](`)", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.Singleline); + + /// + /// Regex used to check if some text is formatted as a code block. + /// + private static readonly Regex BlockCodeRegex = new Regex(@"[^\\]?(```).+?[^\\](```)", RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.Singleline); + + /// Only the author of a message may modify the message. + /// Message content is too long, length must be less or equal to . + public static Task ModifyAsync(IMessage msg, BaseDiscordClient client, Action func, + RequestOptions options) + => ModifyAsync(msg.Channel.Id, msg.Id, client, func, options); + + public static Task ModifyAsync(ulong channelId, ulong msgId, BaseDiscordClient client, Action func, + RequestOptions options) + { + var args = new MessageProperties(); + func(args); + + var embed = args.Embed; + var embeds = args.Embeds; + + bool hasText = args.Content.IsSpecified && string.IsNullOrEmpty(args.Content.Value); + bool hasEmbeds = embed.IsSpecified && embed.Value != null || embeds.IsSpecified && embeds.Value?.Length > 0; + bool hasComponents = args.Components.IsSpecified && args.Components.Value != null; + bool hasAttachments = args.Attachments.IsSpecified; + bool hasFlags = args.Flags.IsSpecified; + + // No content needed if modifying flags + if ((!hasComponents && !hasText && !hasEmbeds && !hasAttachments) && !hasFlags) + Preconditions.NotNullOrEmpty(args.Content.IsSpecified ? args.Content.Value : string.Empty, nameof(args.Content)); + + if (args.AllowedMentions.IsSpecified) + { + AllowedMentions allowedMentions = args.AllowedMentions.Value; + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + } + + var apiEmbeds = embed.IsSpecified || embeds.IsSpecified ? new List() : null; + + if (embed.IsSpecified && embed.Value != null) + { + apiEmbeds.Add(embed.Value.ToModel()); + } + + if (embeds.IsSpecified && embeds.Value != null) + { + apiEmbeds.AddRange(embeds.Value.Select(x => x.ToModel())); + } + + Preconditions.AtMost(apiEmbeds?.Count ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds are allowed."); + + if (!args.Attachments.IsSpecified) + { + var apiArgs = new API.Rest.ModifyMessageParams + { + Content = args.Content, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value : Optional.Create(), + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value.ToModel() : Optional.Create(), + Components = args.Components.IsSpecified ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() : Optional.Unspecified, + }; + return client.ApiClient.ModifyMessageAsync(channelId, msgId, apiArgs, options); + } + else + { + var attachments = args.Attachments.Value?.ToArray() ?? Array.Empty(); + + var apiArgs = new UploadFileParams(attachments) + { + Content = args.Content, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value : Optional.Create(), + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value.ToModel() : Optional.Create(), + MessageComponent = args.Components.IsSpecified ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() : Optional.Unspecified + }; + + return client.ApiClient.ModifyMessageAsync(channelId, msgId, apiArgs, options); + } + } + + public static Task DeleteAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) + => DeleteAsync(msg.Channel.Id, msg.Id, client, options); + + public static Task DeleteAsync(ulong channelId, ulong msgId, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.DeleteMessageAsync(channelId, msgId, options); + + public static Task AddReactionAsync(ulong channelId, ulong messageId, IEmote emote, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.AddReactionAsync(channelId, messageId, emote is Emote e ? $"{e.Name}:{e.Id}" : UrlEncode(emote.Name), options); + + public static Task AddReactionAsync(IMessage msg, IEmote emote, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.AddReactionAsync(msg.Channel.Id, msg.Id, emote is Emote e ? $"{e.Name}:{e.Id}" : UrlEncode(emote.Name), options); + + public static Task RemoveReactionAsync(ulong channelId, ulong messageId, ulong userId, IEmote emote, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.RemoveReactionAsync(channelId, messageId, userId, emote is Emote e ? $"{e.Name}:{e.Id}" : UrlEncode(emote.Name), options); + + public static Task RemoveReactionAsync(IMessage msg, ulong userId, IEmote emote, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.RemoveReactionAsync(msg.Channel.Id, msg.Id, userId, emote is Emote e ? $"{e.Name}:{e.Id}" : UrlEncode(emote.Name), options); + + public static Task RemoveAllReactionsAsync(ulong channelId, ulong messageId, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.RemoveAllReactionsAsync(channelId, messageId, options); + + public static Task RemoveAllReactionsAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.RemoveAllReactionsAsync(msg.Channel.Id, msg.Id, options); + + public static Task RemoveAllReactionsForEmoteAsync(ulong channelId, ulong messageId, IEmote emote, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.RemoveAllReactionsForEmoteAsync(channelId, messageId, emote is Emote e ? $"{e.Name}:{e.Id}" : UrlEncode(emote.Name), options); + + public static Task RemoveAllReactionsForEmoteAsync(IMessage msg, IEmote emote, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.RemoveAllReactionsForEmoteAsync(msg.Channel.Id, msg.Id, emote is Emote e ? $"{e.Name}:{e.Id}" : UrlEncode(emote.Name), options); + + public static IAsyncEnumerable> GetReactionUsersAsync(IMessage msg, IEmote emote, + int? limit, BaseDiscordClient client, ReactionType reactionType, RequestOptions options) + { + Preconditions.NotNull(emote, nameof(emote)); + var emoji = (emote is Emote e ? $"{e.Name}:{e.Id}" : UrlEncode(emote.Name)); + + return new PagedAsyncEnumerable( + DiscordConfig.MaxUserReactionsPerBatch, + async (info, ct) => + { + var args = new GetReactionUsersParams + { + Limit = info.PageSize + }; + + if (info.Position != null) + args.AfterUserId = info.Position.Value; + + var models = await client.ApiClient.GetReactionUsersAsync(msg.Channel.Id, msg.Id, emoji, args, reactionType, options).ConfigureAwait(false); + return models.Select(x => RestUser.Create(client, x)).ToImmutableArray(); + }, + nextPage: (info, lastPage) => + { + if (lastPage.Count != DiscordConfig.MaxUserReactionsPerBatch) + return false; + + info.Position = lastPage.Max(x => x.Id); + return true; + }, + count: limit + ); + } + + private static string UrlEncode(string text) + { +#if NET461 + return System.Net.WebUtility.UrlEncode(text); +#else + return System.Web.HttpUtility.UrlEncode(text); +#endif + } + public static string SanitizeMessage(IMessage message) + { + var newContent = MentionUtils.Resolve(message, 0, TagHandling.FullName, TagHandling.FullName, TagHandling.FullName, TagHandling.FullName, TagHandling.FullName); + newContent = Format.StripMarkDown(newContent); + return newContent; + } + + public static Task PinAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) + { + if (msg.Channel is IVoiceChannel) + throw new NotSupportedException("Pinned messages are not supported in text-in-voice channels."); + return client.ApiClient.AddPinAsync(msg.Channel.Id, msg.Id, options); + } + + public static Task UnpinAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.RemovePinAsync(msg.Channel.Id, msg.Id, options); + + public static ImmutableArray ParseTags(string text, IMessageChannel channel, IGuild guild, IReadOnlyCollection userMentions) + { + var tags = ImmutableArray.CreateBuilder(); + int index = 0; + var codeIndex = 0; + + // checks if the tag being parsed is wrapped in code blocks + bool CheckWrappedCode() + { + // util to check if the index of a tag is within the bounds of the codeblock + bool EnclosedInBlock(Match m) + => m.Groups[1].Index < index && index < m.Groups[2].Index; + + // loop through all code blocks that are before the start of the tag + while (codeIndex < index) + { + var blockMatch = BlockCodeRegex.Match(text, codeIndex); + if (blockMatch.Success) + { + if (EnclosedInBlock(blockMatch)) + return true; + // continue if the end of the current code was before the start of the tag + codeIndex += blockMatch.Groups[2].Index + blockMatch.Groups[2].Length; + if (codeIndex < index) + continue; + return false; + } + var inlineMatch = InlineCodeRegex.Match(text, codeIndex); + if (inlineMatch.Success) + { + if (EnclosedInBlock(inlineMatch)) + return true; + // continue if the end of the current code was before the start of the tag + codeIndex += inlineMatch.Groups[2].Index + inlineMatch.Groups[2].Length; + if (codeIndex < index) + continue; + return false; + } + return false; + } + return false; + } + + while (true) + { + index = text.IndexOf('<', index); + if (index == -1) + break; + int endIndex = text.IndexOf('>', index + 1); + if (endIndex == -1) + break; + if (CheckWrappedCode()) + break; + string content = text.Substring(index, endIndex - index + 1); + + if (MentionUtils.TryParseUser(content, out ulong id)) + { + IUser mentionedUser = null; + foreach (var mention in userMentions) + { + if (mention.Id == id) + { + mentionedUser = channel?.GetUserAsync(id, CacheMode.CacheOnly).GetAwaiter().GetResult(); + if (mentionedUser == null) + mentionedUser = mention; + break; + } + } + tags.Add(new Tag(TagType.UserMention, index, content.Length, id, mentionedUser)); + } + else if (MentionUtils.TryParseChannel(content, out id)) + { + IChannel mentionedChannel = null; + if (guild != null) + mentionedChannel = guild.GetChannelAsync(id, CacheMode.CacheOnly).GetAwaiter().GetResult(); + tags.Add(new Tag(TagType.ChannelMention, index, content.Length, id, mentionedChannel)); + } + else if (MentionUtils.TryParseRole(content, out id)) + { + IRole mentionedRole = null; + if (guild != null) + mentionedRole = guild.GetRole(id); + tags.Add(new Tag(TagType.RoleMention, index, content.Length, id, mentionedRole)); + } + else if (Emote.TryParse(content, out var emoji)) + tags.Add(new Tag(TagType.Emoji, index, content.Length, emoji.Id, emoji)); + else //Bad Tag + { + index++; + continue; + } + index = endIndex + 1; + } + + index = 0; + codeIndex = 0; + while (true) + { + index = text.IndexOf("@everyone", index); + if (index == -1) + break; + if (CheckWrappedCode()) + break; + var tagIndex = FindIndex(tags, index); + if (tagIndex.HasValue) + tags.Insert(tagIndex.Value, new Tag(TagType.EveryoneMention, index, "@everyone".Length, 0, guild?.EveryoneRole)); + index++; + } + + index = 0; + codeIndex = 0; + while (true) + { + index = text.IndexOf("@here", index); + if (index == -1) + break; + if (CheckWrappedCode()) + break; + var tagIndex = FindIndex(tags, index); + if (tagIndex.HasValue) + tags.Insert(tagIndex.Value, new Tag(TagType.HereMention, index, "@here".Length, 0, guild?.EveryoneRole)); + index++; + } + + return tags.ToImmutable(); + } + + private static int? FindIndex(IReadOnlyList tags, int index) + { + int i = 0; + for (; i < tags.Count; i++) + { + var tag = tags[i]; + if (index < tag.Index) + break; //Position before this tag + } + if (i > 0 && index < tags[i - 1].Index + tags[i - 1].Length) + return null; //Overlaps tag before this + return i; + } + + public static ImmutableArray FilterTagsByKey(TagType type, ImmutableArray tags) + { + return tags + .Where(x => x.Type == type) + .Select(x => x.Key) + .ToImmutableArray(); + } + + public static ImmutableArray FilterTagsByValue(TagType type, ImmutableArray tags) + { + return tags + .Where(x => x.Type == type) + .Select(x => (T)x.Value) + .Where(x => x != null) + .ToImmutableArray(); + } + + public static MessageSource GetSource(Model msg) + { + if (msg.Type != MessageType.Default && msg.Type != MessageType.Reply) + return MessageSource.System; + else if (msg.WebhookId.IsSpecified) + return MessageSource.Webhook; + else if (msg.Author.GetValueOrDefault()?.Bot.GetValueOrDefault(false) == true) + return MessageSource.Bot; + return MessageSource.User; + } + + public static Task CrosspostAsync(IMessage msg, BaseDiscordClient client, RequestOptions options) + => CrosspostAsync(msg.Channel.Id, msg.Id, client, options); + + public static Task CrosspostAsync(ulong channelId, ulong msgId, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.CrosspostAsync(channelId, msgId, options); + + public static IUser GetAuthor(BaseDiscordClient client, IGuild guild, UserModel model, ulong? webhookId) + { + IUser author = null; + if (guild != null) + author = guild.GetUserAsync(model.Id, CacheMode.CacheOnly).Result; + if (author == null) + author = RestUser.Create(client, guild, model, webhookId); + return author; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/RestFollowupMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestFollowupMessage.cs new file mode 100644 index 0000000..aa5dd5a --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/RestFollowupMessage.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using Model = Discord.API.Message; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based follow up message sent by a bot responding to an interaction. + /// + public class RestFollowupMessage : RestUserMessage + { + // Token used to delete/modify this followup message + internal string Token { get; } + + internal RestFollowupMessage(BaseDiscordClient discord, ulong id, IUser author, string token, IMessageChannel channel) + : base(discord, id, channel, author, MessageSource.Bot) + { + Token = token; + } + + internal static RestFollowupMessage Create(BaseDiscordClient discord, Model model, string token, IMessageChannel channel) + { + var entity = new RestFollowupMessage(discord, model.Id, model.Author.IsSpecified ? RestUser.Create(discord, model.Author.Value) : discord.CurrentUser, token, channel); + entity.Update(model); + return entity; + } + + internal new void Update(Model model) + { + base.Update(model); + } + + /// + /// Deletes this object and all of it's children. + /// + /// A task that represents the asynchronous delete operation. + public Task DeleteAsync() + => InteractionHelper.DeleteFollowupMessageAsync(Discord, this); + + /// + /// Modifies this interaction followup message. + /// + /// + /// This method modifies this message with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// + /// The following example replaces the content of the message with Hello World!. + /// + /// await msg.ModifyAsync(x => x.Content = "Hello World!"); + /// + /// + /// A delegate containing the properties to modify the message with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + /// The token used to modify/delete this message expired. + /// /// Something went wrong during the request. + public new async Task ModifyAsync(Action func, RequestOptions options = null) + { + try + { + var model = await InteractionHelper.ModifyFollowupMessageAsync(Discord, this, func, options).ConfigureAwait(false); + Update(model); + } + catch (Net.HttpException x) + { + if (x.HttpCode == System.Net.HttpStatusCode.NotFound) + { + throw new InvalidOperationException("The token of this message has expired!", x); + } + + throw; + } + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/RestInteractionMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestInteractionMessage.cs new file mode 100644 index 0000000..8f08043 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/RestInteractionMessage.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading.Tasks; +using MessageModel = Discord.API.Message; + +namespace Discord.Rest +{ + /// + /// Represents the initial REST-based response to an interaction. + /// + public class RestInteractionMessage : RestUserMessage + { + public InteractionResponseType ResponseType { get; private set; } + internal string Token { get; } + + internal RestInteractionMessage(BaseDiscordClient discord, ulong id, IUser author, string token, IMessageChannel channel) + : base(discord, id, channel, author, MessageSource.Bot) + { + Token = token; + } + + internal static RestInteractionMessage Create(BaseDiscordClient discord, MessageModel model, string token, IMessageChannel channel) + { + var entity = new RestInteractionMessage(discord, model.Id, model.Author.IsSpecified ? RestUser.Create(discord, model.Author.Value) : discord.CurrentUser, token, channel); + entity.Update(model); + return entity; + } + + internal new void Update(MessageModel model) + { + base.Update(model); + } + + /// + /// Deletes this object and all of its children. + /// + /// A task that represents the asynchronous delete operation. + public Task DeleteAsync() + => InteractionHelper.DeleteInteractionResponseAsync(Discord, this); + + /// + /// Modifies this interaction response + /// + /// + /// This method modifies this message with the specified properties. To see an example of this + /// method and what properties are available, please refer to . + /// + /// + /// The following example replaces the content of the message with Hello World!. + /// + /// await msg.ModifyAsync(x => x.Content = "Hello World!"); + /// + /// + /// A delegate containing the properties to modify the message with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + /// The token used to modify/delete this message expired. + /// /// Something went wrong during the request. + public new async Task ModifyAsync(Action func, RequestOptions options = null) + { + try + { + var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options).ConfigureAwait(false); + Update(model); + } + catch (Net.HttpException x) + { + if (x.HttpCode == System.Net.HttpStatusCode.NotFound) + { + throw new InvalidOperationException("The token of this message has expired!", x); + } + + throw; + } + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs new file mode 100644 index 0000000..fd9aa07 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/RestMessage.cs @@ -0,0 +1,348 @@ +using Discord.API; + +using Newtonsoft.Json.Linq; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; + +using Model = Discord.API.Message; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based message. + /// + public abstract class RestMessage : RestEntity, IMessage, IUpdateable + { + private long _timestampTicks; + private ImmutableArray _reactions = ImmutableArray.Create(); + private ImmutableArray _userMentions = ImmutableArray.Create(); + + /// + public IMessageChannel Channel { get; } + /// + /// Gets the Author of the message. + /// + public IUser Author { get; } + /// + public MessageSource Source { get; } + + /// + public string Content { get; private set; } + + /// + public string CleanContent => MessageHelper.SanitizeMessage(this); + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public virtual bool IsTTS => false; + /// + public virtual bool IsPinned => false; + /// + public virtual bool IsSuppressed => false; + /// + public virtual DateTimeOffset? EditedTimestamp => null; + /// + public virtual bool MentionedEveryone => false; + + /// + public RestThreadChannel Thread { get; private set; } + + /// + IThreadChannel IMessage.Thread => Thread; + + /// + /// Gets a collection of the 's on the message. + /// + public virtual IReadOnlyCollection Attachments => ImmutableArray.Create(); + /// + /// Gets a collection of the 's on the message. + /// + public virtual IReadOnlyCollection Embeds => ImmutableArray.Create(); + /// + public virtual IReadOnlyCollection MentionedChannelIds => ImmutableArray.Create(); + /// + public virtual IReadOnlyCollection MentionedRoleIds => ImmutableArray.Create(); + /// + public virtual IReadOnlyCollection Tags => ImmutableArray.Create(); + /// + public virtual IReadOnlyCollection Stickers => ImmutableArray.Create(); + + /// + public DateTimeOffset Timestamp => DateTimeUtils.FromTicks(_timestampTicks); + /// + public MessageActivity Activity { get; private set; } + /// + public MessageApplication Application { get; private set; } + /// + public MessageReference Reference { get; private set; } + + /// + /// Gets the interaction this message is a response to. + /// + public MessageInteraction Interaction { get; private set; } + /// + public MessageFlags? Flags { get; private set; } + /// + public MessageType Type { get; private set; } + + /// + public MessageRoleSubscriptionData RoleSubscriptionData { get; private set; } + + /// + public IReadOnlyCollection Components { get; private set; } + /// + /// Gets a collection of the mentioned users in the message. + /// + public IReadOnlyCollection MentionedUsers => _userMentions; + + internal RestMessage(BaseDiscordClient discord, ulong id, IMessageChannel channel, IUser author, MessageSource source) + : base(discord, id) + { + Channel = channel; + Author = author; + Source = source; + } + internal static RestMessage Create(BaseDiscordClient discord, IMessageChannel channel, IUser author, Model model) + { + if (model.Type == MessageType.Default || + model.Type == MessageType.Reply || + model.Type == MessageType.ApplicationCommand || + model.Type == MessageType.ThreadStarterMessage) + return RestUserMessage.Create(discord, channel, author, model); + else + return RestSystemMessage.Create(discord, channel, author, model); + } + internal virtual void Update(Model model) + { + Type = model.Type; + + if (model.Timestamp.IsSpecified) + _timestampTicks = model.Timestamp.Value.UtcTicks; + + if (model.Content.IsSpecified) + Content = model.Content.Value; + + if (model.Application.IsSpecified) + { + // create a new Application from the API model + Application = new MessageApplication() + { + Id = model.Application.Value.Id, + CoverImage = model.Application.Value.CoverImage, + Description = model.Application.Value.Description, + Icon = model.Application.Value.Icon, + Name = model.Application.Value.Name + }; + } + + if (model.Activity.IsSpecified) + { + // create a new Activity from the API model + Activity = new MessageActivity() + { + Type = model.Activity.Value.Type.Value, + PartyId = model.Activity.Value.PartyId.GetValueOrDefault() + }; + } + + if (model.Reference.IsSpecified) + { + // Creates a new Reference from the API model + Reference = new MessageReference + { + GuildId = model.Reference.Value.GuildId, + InternalChannelId = model.Reference.Value.ChannelId, + MessageId = model.Reference.Value.MessageId, + FailIfNotExists = model.Reference.Value.FailIfNotExists + }; + } + + if (model.Components.IsSpecified) + { + Components = model.Components.Value.Select(x => new ActionRowComponent(x.Components.Select(y => + { + switch (y.Type) + { + case ComponentType.Button: + { + var parsed = (API.ButtonComponent)y; + return new Discord.ButtonComponent( + parsed.Style, + parsed.Label.GetValueOrDefault(), + parsed.Emote.IsSpecified + ? parsed.Emote.Value.Id.HasValue + ? new Emote(parsed.Emote.Value.Id.Value, parsed.Emote.Value.Name, parsed.Emote.Value.Animated.GetValueOrDefault()) + : new Emoji(parsed.Emote.Value.Name) + : null, + parsed.CustomId.GetValueOrDefault(), + parsed.Url.GetValueOrDefault(), + parsed.Disabled.GetValueOrDefault()); + } + case ComponentType.SelectMenu or ComponentType.ChannelSelect or ComponentType.RoleSelect or ComponentType.MentionableSelect or ComponentType.UserSelect: + { + var parsed = (API.SelectMenuComponent)y; + return new SelectMenuComponent( + parsed.CustomId, + parsed.Options?.Select(z => new SelectMenuOption( + z.Label, + z.Value, + z.Description.GetValueOrDefault(), + z.Emoji.IsSpecified + ? z.Emoji.Value.Id.HasValue + ? new Emote(z.Emoji.Value.Id.Value, z.Emoji.Value.Name, z.Emoji.Value.Animated.GetValueOrDefault()) + : new Emoji(z.Emoji.Value.Name) + : null, + z.Default.ToNullable())).ToList(), + parsed.Placeholder.GetValueOrDefault(), + parsed.MinValues, + parsed.MaxValues, + parsed.Disabled, + parsed.Type, + parsed.ChannelTypes.GetValueOrDefault(), + parsed.DefaultValues.IsSpecified + ? parsed.DefaultValues.Value.Select(x => new SelectMenuDefaultValue(x.Id, x.Type)) + : Array.Empty() + ); + } + default: + return null; + } + }).ToList())).ToImmutableArray(); + } + else + Components = new List(); + + if (model.Flags.IsSpecified) + Flags = model.Flags.Value; + + if (model.Reactions.IsSpecified) + { + var value = model.Reactions.Value; + if (value.Length > 0) + { + var reactions = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + reactions.Add(RestReaction.Create(value[i])); + _reactions = reactions.ToImmutable(); + } + else + _reactions = ImmutableArray.Create(); + } + else + _reactions = ImmutableArray.Create(); + + if (model.Interaction.IsSpecified) + { + Interaction = new MessageInteraction(model.Interaction.Value.Id, + model.Interaction.Value.Type, + model.Interaction.Value.Name, + RestUser.Create(Discord, model.Interaction.Value.User)); + } + + if (model.UserMentions.IsSpecified) + { + var value = model.UserMentions.Value; + if (value.Length > 0) + { + var newMentions = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + var val = value[i]; + if (val != null) + newMentions.Add(RestUser.Create(Discord, val)); + } + _userMentions = newMentions.ToImmutable(); + } + } + + if (model.RoleSubscriptionData.IsSpecified) + { + RoleSubscriptionData = new( + model.RoleSubscriptionData.Value.SubscriptionListingId, + model.RoleSubscriptionData.Value.TierName, + model.RoleSubscriptionData.Value.MonthsSubscribed, + model.RoleSubscriptionData.Value.IsRenewal); + } + + if (model.Thread.IsSpecified) + Thread = RestThreadChannel.Create(Discord, new RestGuild(Discord, model.Thread.Value.GuildId.Value), model.Thread.Value); + } + /// + public async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetChannelMessageAsync(Channel.Id, Id, options).ConfigureAwait(false); + Update(model); + } + /// + public Task DeleteAsync(RequestOptions options = null) + => MessageHelper.DeleteAsync(this, Discord, options); + + /// + /// Gets the of the message. + /// + /// + /// A string that is the of the message. + /// + public override string ToString() => Content; + + #region IMessage + + /// + IUser IMessage.Author => Author; + + /// + IReadOnlyCollection IMessage.Attachments => Attachments; + + /// + IReadOnlyCollection IMessage.Embeds => Embeds; + + /// + IReadOnlyCollection IMessage.MentionedUserIds => MentionedUsers.Select(x => x.Id).ToImmutableArray(); + + /// + IReadOnlyCollection IMessage.Components => Components; + + /// + [Obsolete("This property will be deprecated soon. Use IUserMessage.InteractionMetadata instead.")] + IMessageInteraction IMessage.Interaction => Interaction; + + /// + IReadOnlyCollection IMessage.Stickers => Stickers; + + #endregion + + /// + public IReadOnlyDictionary Reactions => _reactions.ToDictionary(x => x.Emote, x => new ReactionMetadata + { + ReactionCount = x.Count, + IsMe = x.Me, + BurstColors = x.BurstColors, + BurstCount = x.BurstCount, + NormalCount = x.NormalCount, + }); + + /// + public Task AddReactionAsync(IEmote emote, RequestOptions options = null) + => MessageHelper.AddReactionAsync(this, emote, Discord, options); + /// + public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions options = null) + => MessageHelper.RemoveReactionAsync(this, user.Id, emote, Discord, options); + /// + public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions options = null) + => MessageHelper.RemoveReactionAsync(this, userId, emote, Discord, options); + /// + public Task RemoveAllReactionsAsync(RequestOptions options = null) + => MessageHelper.RemoveAllReactionsAsync(this, Discord, options); + /// + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null) + => MessageHelper.RemoveAllReactionsForEmoteAsync(this, emote, Discord, options); + /// + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emote, int limit, RequestOptions options = null, ReactionType type = ReactionType.Normal) + => MessageHelper.GetReactionUsersAsync(this, emote, limit, Discord, type, options); + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/RestReaction.cs b/src/Discord.Net.Rest/Entities/Messages/RestReaction.cs new file mode 100644 index 0000000..2be12f8 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/RestReaction.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using Model = Discord.API.Reaction; + +namespace Discord.Rest +{ + /// + /// Represents a REST reaction object. + /// + public class RestReaction : IReaction + { + /// + public IEmote Emote { get; } + + /// + /// Gets the number of reactions added. + /// + public int Count { get; } + + /// + /// Gets whether the reaction is added by the user. + /// + public bool Me { get; } + + /// + /// Gets whether the super-reaction is added by the user. + /// + public bool MeBurst { get; } + + /// + /// Gets the number of burst reactions added. + /// + public int BurstCount { get; } + + /// + /// Gets the number of normal reactions added. + /// + public int NormalCount { get; } + + /// + public IReadOnlyCollection BurstColors { get; } + + internal RestReaction(IEmote emote, int count, bool me, int burst, int normal, IReadOnlyCollection colors, bool meBurst) + { + Emote = emote; + Count = count; + Me = me; + BurstCount = burst; + NormalCount = normal; + BurstColors = colors; + MeBurst = meBurst; + } + internal static RestReaction Create(Model model) + { + IEmote emote; + if (model.Emoji.Id.HasValue) + emote = new Emote(model.Emoji.Id.Value, model.Emoji.Name, model.Emoji.Animated.GetValueOrDefault()); + else + emote = new Emoji(model.Emoji.Name); + return new RestReaction(emote, + model.Count, + model.Me, + model.CountDetails.BurstCount, + model.CountDetails.NormalCount, + model.Colors.ToReadOnlyCollection(), + model.MeBurst); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/RestSystemMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestSystemMessage.cs new file mode 100644 index 0000000..1c59d4f --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/RestSystemMessage.cs @@ -0,0 +1,29 @@ +using System.Diagnostics; +using Model = Discord.API.Message; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based system message. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestSystemMessage : RestMessage, ISystemMessage + { + internal RestSystemMessage(BaseDiscordClient discord, ulong id, IMessageChannel channel, IUser author) + : base(discord, id, channel, author, MessageSource.System) + { + } + internal new static RestSystemMessage Create(BaseDiscordClient discord, IMessageChannel channel, IUser author, Model model) + { + var entity = new RestSystemMessage(discord, model.Id, channel, author); + entity.Update(model); + return entity; + } + internal override void Update(Model model) + { + base.Update(model); + } + + private string DebuggerDisplay => $"{Author}: {Content} ({Id}, {Type})"; + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/RestUserMessage.cs b/src/Discord.Net.Rest/Entities/Messages/RestUserMessage.cs new file mode 100644 index 0000000..2312f92 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/RestUserMessage.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Message; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based message sent by a user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestUserMessage : RestMessage, IUserMessage + { + private bool _isMentioningEveryone, _isTTS, _isPinned; + private long? _editedTimestampTicks; + private IUserMessage _referencedMessage; + private ImmutableArray _attachments = ImmutableArray.Create(); + private ImmutableArray _embeds = ImmutableArray.Create(); + private ImmutableArray _tags = ImmutableArray.Create(); + private ImmutableArray _roleMentionIds = ImmutableArray.Create(); + private ImmutableArray _stickers = ImmutableArray.Create(); + + /// + public override bool IsTTS => _isTTS; + /// + public override bool IsPinned => _isPinned; + /// + public override bool IsSuppressed => Flags.HasValue && Flags.Value.HasFlag(MessageFlags.SuppressEmbeds); + /// + public override DateTimeOffset? EditedTimestamp => DateTimeUtils.FromTicks(_editedTimestampTicks); + /// + public override bool MentionedEveryone => _isMentioningEveryone; + /// + public override IReadOnlyCollection Attachments => _attachments; + /// + public override IReadOnlyCollection Embeds => _embeds; + /// + public override IReadOnlyCollection MentionedChannelIds => MessageHelper.FilterTagsByKey(TagType.ChannelMention, _tags); + /// + public override IReadOnlyCollection MentionedRoleIds => _roleMentionIds; + /// + public override IReadOnlyCollection Tags => _tags; + /// + public override IReadOnlyCollection Stickers => _stickers; + /// + public IUserMessage ReferencedMessage => _referencedMessage; + + /// + public IMessageInteractionMetadata InteractionMetadata { get; internal set; } + + /// + public MessageResolvedData ResolvedData { get; internal set; } + + internal RestUserMessage(BaseDiscordClient discord, ulong id, IMessageChannel channel, IUser author, MessageSource source) + : base(discord, id, channel, author, source) + { + } + internal new static RestUserMessage Create(BaseDiscordClient discord, IMessageChannel channel, IUser author, Model model) + { + var entity = new RestUserMessage(discord, model.Id, channel, author, MessageHelper.GetSource(model)); + entity.Update(model); + return entity; + } + + internal override void Update(Model model) + { + base.Update(model); + + if (model.IsTextToSpeech.IsSpecified) + _isTTS = model.IsTextToSpeech.Value; + if (model.Pinned.IsSpecified) + _isPinned = model.Pinned.Value; + if (model.EditedTimestamp.IsSpecified) + _editedTimestampTicks = model.EditedTimestamp.Value?.UtcTicks; + if (model.MentionEveryone.IsSpecified) + _isMentioningEveryone = model.MentionEveryone.Value; + if (model.RoleMentions.IsSpecified) + _roleMentionIds = model.RoleMentions.Value.ToImmutableArray(); + + if (model.Attachments.IsSpecified) + { + var value = model.Attachments.Value; + if (value.Length > 0) + { + var attachments = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + attachments.Add(Attachment.Create(value[i], Discord)); + _attachments = attachments.ToImmutable(); + } + else + _attachments = ImmutableArray.Create(); + } + + if (model.Embeds.IsSpecified) + { + var value = model.Embeds.Value; + if (value.Length > 0) + { + var embeds = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + embeds.Add(value[i].ToEntity()); + _embeds = embeds.ToImmutable(); + } + else + _embeds = ImmutableArray.Create(); + } + + var guildId = (Channel as IGuildChannel)?.GuildId; + var guild = guildId != null ? (Discord as IDiscordClient).GetGuildAsync(guildId.Value, CacheMode.CacheOnly).Result : null; + if (model.Content.IsSpecified) + { + var text = model.Content.Value; + _tags = MessageHelper.ParseTags(text, null, guild, MentionedUsers); + model.Content = text; + } + + if (model.ReferencedMessage.IsSpecified && model.ReferencedMessage.Value != null) + { + var refMsg = model.ReferencedMessage.Value; + IUser refMsgAuthor = MessageHelper.GetAuthor(Discord, guild, refMsg.Author.Value, refMsg.WebhookId.ToNullable()); + _referencedMessage = RestUserMessage.Create(Discord, Channel, refMsgAuthor, refMsg); + } + + if (model.StickerItems.IsSpecified) + { + var value = model.StickerItems.Value; + if (value.Length > 0) + { + var stickers = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + stickers.Add(new StickerItem(Discord, value[i])); + _stickers = stickers.ToImmutable(); + } + else + _stickers = ImmutableArray.Create(); + } + + if (model.Resolved.IsSpecified) + { + var users = model.Resolved.Value.Users.IsSpecified + ? model.Resolved.Value.Users.Value.Select(x => RestUser.Create(Discord, x.Value)).ToImmutableArray() + : ImmutableArray.Empty; + + var members = model.Resolved.Value.Members.IsSpecified + ? model.Resolved.Value.Members.Value.Select(x => + { + x.Value.User = model.Resolved.Value.Users.Value.TryGetValue(x.Key, out var user) + ? user + : null; + + return RestGuildUser.Create(Discord, guild, x.Value, guildId); + }).ToImmutableArray() + : ImmutableArray.Empty; + + var roles = model.Resolved.Value.Roles.IsSpecified + ? model.Resolved.Value.Roles.Value.Select(x => RestRole.Create(Discord, guild, x.Value)).ToImmutableArray() + : ImmutableArray.Empty; + + var channels = model.Resolved.Value.Channels.IsSpecified + ? model.Resolved.Value.Channels.Value.Select(x => RestChannel.Create(Discord, x.Value, guild)).ToImmutableArray() + : ImmutableArray.Empty; + + ResolvedData = new MessageResolvedData(users, members, roles, channels); + } + if (model.InteractionMetadata.IsSpecified) + InteractionMetadata = model.InteractionMetadata.Value.ToInteractionMetadata(); + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await MessageHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + + /// + public Task PinAsync(RequestOptions options = null) + => MessageHelper.PinAsync(this, Discord, options); + /// + public Task UnpinAsync(RequestOptions options = null) + => MessageHelper.UnpinAsync(this, Discord, options); + + public string Resolve(int startIndex, TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + => MentionUtils.Resolve(this, startIndex, userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + /// + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + => MentionUtils.Resolve(this, 0, userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + + /// + /// This operation may only be called on a channel. + public Task CrosspostAsync(RequestOptions options = null) + { + if (!(Channel is INewsChannel)) + { + throw new InvalidOperationException("Publishing (crossposting) is only valid in news channels."); + } + + return MessageHelper.CrosspostAsync(this, Discord, options); + } + + private string DebuggerDisplay => $"{Author}: {Content} ({Id}{(Attachments.Count > 0 ? $", {Attachments.Count} Attachments" : "")})"; + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/Sticker.cs b/src/Discord.Net.Rest/Entities/Messages/Sticker.cs new file mode 100644 index 0000000..accdbe6 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/Sticker.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Model = Discord.API.Sticker; + +namespace Discord.Rest +{ + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class Sticker : RestEntity, ISticker + { + /// + public ulong PackId { get; protected set; } + /// + public string Name { get; protected set; } + /// + public string Description { get; protected set; } + /// + public IReadOnlyCollection Tags { get; protected set; } + /// + public StickerType Type { get; protected set; } + /// + public bool? IsAvailable { get; protected set; } + /// + public int? SortOrder { get; protected set; } + /// + public StickerFormatType Format { get; protected set; } + + /// + public string GetStickerUrl() + => CDN.GetStickerUrl(Id, Format); + + internal Sticker(BaseDiscordClient client, ulong id) + : base(client, id) { } + internal static Sticker Create(BaseDiscordClient client, Model model) + { + var entity = new Sticker(client, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + PackId = model.PackId; + Name = model.Name; + Description = model.Description; + Tags = model.Tags.IsSpecified ? model.Tags.Value.Split(',').Select(x => x.Trim()).ToArray() : Array.Empty(); + Type = model.Type; + SortOrder = model.SortValue; + IsAvailable = model.Available; + Format = model.FormatType; + } + + private string DebuggerDisplay => $"{Name} ({Id})"; + } +} diff --git a/src/Discord.Net.Rest/Entities/Messages/StickerItem.cs b/src/Discord.Net.Rest/Entities/Messages/StickerItem.cs new file mode 100644 index 0000000..0ce4f63 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Messages/StickerItem.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using Model = Discord.API.StickerItem; + +namespace Discord.Rest +{ + /// + /// Represents a partial sticker received in a message. + /// + public class StickerItem : RestEntity, IStickerItem + { + /// + public string Name { get; } + + /// + public StickerFormatType Format { get; } + + internal StickerItem(BaseDiscordClient client, Model model) + : base(client, model.Id) + { + Name = model.Name; + Format = model.FormatType; + } + + /// + /// Resolves this sticker item by fetching the from the API. + /// + /// + /// A task representing the download operation, the result of the task is a sticker object. + /// + public async Task ResolveStickerAsync() + { + var model = await Discord.ApiClient.GetStickerAsync(Id); + + return model.GuildId.IsSpecified + ? CustomSticker.Create(Discord, model, model.GuildId.Value, model.User.IsSpecified ? model.User.Value.Id : null) + : Sticker.Create(Discord, model); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/RestApplication.cs b/src/Discord.Net.Rest/Entities/RestApplication.cs new file mode 100644 index 0000000..2e2fcce --- /dev/null +++ b/src/Discord.Net.Rest/Entities/RestApplication.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading.Tasks; + +using Model = Discord.API.Application; + +namespace Discord.Rest; + +/// +/// Represents a REST-based entity that contains information about a Discord application created via the developer portal. +/// +[DebuggerDisplay(@"{DebuggerDisplay,nq}")] +public class RestApplication : RestEntity, IApplication +{ + protected string _iconId; + + /// + public string Name { get; private set; } + /// + public string Description { get; private set; } + /// + public IReadOnlyCollection RPCOrigins { get; private set; } + /// + public ApplicationFlags Flags { get; private set; } + /// + public bool? IsBotPublic { get; private set; } + /// + public bool? BotRequiresCodeGrant { get; private set; } + /// + public ITeam Team { get; private set; } + /// + public IUser Owner { get; private set; } + /// + public string TermsOfService { get; private set; } + /// + public string PrivacyPolicy { get; private set; } + + /// + public string VerifyKey { get; private set; } + /// + public string CustomInstallUrl { get; private set; } + /// + public string RoleConnectionsVerificationUrl { get; private set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public string IconUrl => CDN.GetApplicationIconUrl(Id, _iconId); + + /// + public PartialGuild Guild { get; private set; } + + /// + public int? ApproximateGuildCount { get; private set; } + + /// + public IReadOnlyCollection RedirectUris { get; private set; } + + /// + public string InteractionsEndpointUrl { get; private set; } + + /// + public ApplicationInstallParams InstallParams { get; private set; } + + /// + public ApplicationDiscoverabilityState DiscoverabilityState { get; private set; } + + /// + public DiscoveryEligibilityFlags DiscoveryEligibilityFlags { get; private set; } + + /// + public ApplicationExplicitContentFilterLevel ExplicitContentFilterLevel { get; private set; } + + /// + public bool IsHook { get; private set; } + + /// + public IReadOnlyCollection InteractionEventTypes { get; private set; } + + /// + public ApplicationInteractionsVersion InteractionsVersion { get; private set; } + + /// + public bool IsMonetized { get; private set; } + + /// + public ApplicationMonetizationEligibilityFlags MonetizationEligibilityFlags { get; private set; } + + /// + public ApplicationMonetizationState MonetizationState { get; private set; } + + /// + public ApplicationRpcState RpcState { get; private set; } + + /// + public ApplicationStoreState StoreState { get; private set; } + + /// + public ApplicationVerificationState VerificationState { get; private set; } + + /// + public IReadOnlyCollection Tags { get; private set; } + + /// + public IReadOnlyDictionary IntegrationTypesConfig { get; private set; } + + internal RestApplication(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + internal static RestApplication Create(BaseDiscordClient discord, Model model) + { + var entity = new RestApplication(discord, model.Id); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + Description = model.Description; + RPCOrigins = model.RPCOrigins.IsSpecified ? model.RPCOrigins.Value.ToImmutableArray() : ImmutableArray.Empty; + Name = model.Name; + _iconId = model.Icon; + IsBotPublic = model.IsBotPublic.IsSpecified ? model.IsBotPublic.Value : null; + BotRequiresCodeGrant = model.BotRequiresCodeGrant.IsSpecified ? model.BotRequiresCodeGrant.Value : null; + Tags = model.Tags.GetValueOrDefault(null)?.ToImmutableArray() ?? ImmutableArray.Empty; + PrivacyPolicy = model.PrivacyPolicy; + TermsOfService = model.TermsOfService; + + InstallParams = model.InstallParams.IsSpecified + ? new ApplicationInstallParams(model.InstallParams.Value.Scopes, (GuildPermission)model.InstallParams.Value.Permission) + : null; + + if (model.Flags.IsSpecified) + Flags = model.Flags.Value; + if (model.Owner.IsSpecified) + Owner = RestUser.Create(Discord, model.Owner.Value); + if (model.Team != null) + Team = RestTeam.Create(Discord, model.Team); + + CustomInstallUrl = model.CustomInstallUrl.IsSpecified ? model.CustomInstallUrl.Value : null; + RoleConnectionsVerificationUrl = model.RoleConnectionsUrl.IsSpecified ? model.RoleConnectionsUrl.Value : null; + VerifyKey = model.VerifyKey; + + if (model.PartialGuild.IsSpecified) + Guild = PartialGuildExtensions.Create(model.PartialGuild.Value); + + InteractionsEndpointUrl = model.InteractionsEndpointUrl.IsSpecified ? model.InteractionsEndpointUrl.Value : null; + + if (model.RedirectUris.IsSpecified) + RedirectUris = model.RedirectUris.Value.ToImmutableArray(); + + ApproximateGuildCount = model.ApproximateGuildCount.IsSpecified ? model.ApproximateGuildCount.Value : null; + + DiscoverabilityState = model.DiscoverabilityState.GetValueOrDefault(ApplicationDiscoverabilityState.None); + DiscoveryEligibilityFlags = model.DiscoveryEligibilityFlags.GetValueOrDefault(DiscoveryEligibilityFlags.None); + ExplicitContentFilterLevel = model.ExplicitContentFilter.GetValueOrDefault(ApplicationExplicitContentFilterLevel.Disabled); + IsHook = model.IsHook; + + InteractionEventTypes = model.InteractionsEventTypes.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + InteractionsVersion = model.InteractionsVersion.GetValueOrDefault(ApplicationInteractionsVersion.Version1); + + IsMonetized = model.IsMonetized; + MonetizationEligibilityFlags = model.MonetizationEligibilityFlags.GetValueOrDefault(ApplicationMonetizationEligibilityFlags.None); + MonetizationState = model.MonetizationState.GetValueOrDefault(ApplicationMonetizationState.None); + + RpcState = model.RpcState.GetValueOrDefault(ApplicationRpcState.Disabled); + StoreState = model.StoreState.GetValueOrDefault(ApplicationStoreState.None); + VerificationState = model.VerificationState.GetValueOrDefault(ApplicationVerificationState.Ineligible); + + var dict = new Dictionary(); + if (model.IntegrationTypesConfig.IsSpecified) + { + foreach (var p in model.IntegrationTypesConfig.Value) + { + dict.Add(p.Key, new ApplicationInstallParams(p.Value.Scopes ?? Array.Empty(), p.Value.Permission)); + } + } + IntegrationTypesConfig = dict.ToImmutableDictionary(); + } + + /// Unable to update this object from a different application token. + public async Task UpdateAsync() + { + var response = await Discord.ApiClient.GetMyApplicationAsync().ConfigureAwait(false); + if (response.Id != Id) + throw new InvalidOperationException("Unable to update this object from a different application token."); + Update(response); + } + + /// + /// Gets the name of the application. + /// + /// + /// The name of the application. + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id})"; +} diff --git a/src/Discord.Net.Rest/Entities/RestEntity.cs b/src/Discord.Net.Rest/Entities/RestEntity.cs new file mode 100644 index 0000000..8493698 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/RestEntity.cs @@ -0,0 +1,17 @@ +using System; + +namespace Discord.Rest +{ + public abstract class RestEntity : IEntity + where T : IEquatable + { + internal BaseDiscordClient Discord { get; } + public T Id { get; } + + internal RestEntity(BaseDiscordClient discord, T id) + { + Discord = discord; + Id = id; + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Roles/RestRole.cs b/src/Discord.Net.Rest/Entities/Roles/RestRole.cs new file mode 100644 index 0000000..ef111a4 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Roles/RestRole.cs @@ -0,0 +1,125 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Role; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based role. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestRole : RestEntity, IRole + { + #region RestRole + internal IGuild Guild { get; } + /// + public Color Color { get; private set; } + /// + public bool IsHoisted { get; private set; } + /// + public bool IsManaged { get; private set; } + /// + public bool IsMentionable { get; private set; } + /// + public string Name { get; private set; } + /// + public string Icon { get; private set; } + /// + public Emoji Emoji { get; private set; } + /// + public GuildPermissions Permissions { get; private set; } + /// + public int Position { get; private set; } + /// + public RoleTags Tags { get; private set; } + + /// + public RoleFlags Flags { get; private set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + /// Gets if this role is the @everyone role of the guild or not. + /// + public bool IsEveryone => Id == Guild.Id; + /// + public string Mention => IsEveryone ? "@everyone" : MentionUtils.MentionRole(Id); + + internal RestRole(BaseDiscordClient discord, IGuild guild, ulong id) + : base(discord, id) + { + Guild = guild; + } + internal static RestRole Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestRole(discord, guild, model.Id); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + Name = model.Name; + IsHoisted = model.Hoist; + IsManaged = model.Managed; + IsMentionable = model.Mentionable; + Position = model.Position; + Color = new Color(model.Color); + Permissions = new GuildPermissions(model.Permissions); + Flags = model.Flags; + + if (model.Tags.IsSpecified) + Tags = model.Tags.Value.ToEntity(); + + if (model.Icon.IsSpecified) + { + Icon = model.Icon.Value; + } + + if (model.Emoji.IsSpecified) + { + Emoji = new Emoji(model.Emoji.Value); + } + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await RoleHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + /// + public Task DeleteAsync(RequestOptions options = null) + => RoleHelper.DeleteAsync(this, Discord, options); + + /// + public string GetIconUrl() + => CDN.GetGuildRoleIconUrl(Id, Icon); + + /// + public int CompareTo(IRole role) => RoleUtils.Compare(this, role); + + /// + /// Gets the name of the role. + /// + /// + /// A string that is the name of the role. + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id})"; + #endregion + + #region IRole + /// + IGuild IRole.Guild + { + get + { + if (Guild != null) + return Guild; + throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + } + } + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs b/src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs new file mode 100644 index 0000000..0586f6b --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Roles/RoleHelper.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using BulkParams = Discord.API.Rest.ModifyGuildRolesParams; +using Model = Discord.API.Role; + +namespace Discord.Rest +{ + internal static class RoleHelper + { + #region General + public static Task DeleteAsync(IRole role, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.DeleteGuildRoleAsync(role.Guild.Id, role.Id, options); + + public static async Task ModifyAsync(IRole role, BaseDiscordClient client, + Action func, RequestOptions options) + { + var args = new RoleProperties(); + func(args); + + if (args.Icon.IsSpecified || args.Emoji.IsSpecified) + { + role.Guild.Features.EnsureFeature(GuildFeature.RoleIcons); + + if ((args.Icon.IsSpecified && args.Icon.Value != null) && (args.Emoji.IsSpecified && args.Emoji.Value != null)) + { + throw new ArgumentException("Emoji and Icon properties cannot be present on a role at the same time."); + } + } + + var apiArgs = new API.Rest.ModifyGuildRoleParams + { + Color = args.Color.IsSpecified ? args.Color.Value.RawValue : Optional.Create(), + Hoist = args.Hoist, + Mentionable = args.Mentionable, + Name = args.Name, + Permissions = args.Permissions.IsSpecified ? args.Permissions.Value.RawValue.ToString() : Optional.Create(), + Icon = args.Icon.IsSpecified ? args.Icon.Value?.ToModel() ?? null : Optional.Unspecified, + Emoji = args.Emoji.IsSpecified ? args.Emoji.Value?.Name ?? "" : Optional.Create(), + }; + + if ((args.Icon.IsSpecified && args.Icon.Value != null) && role.Emoji != null) + { + apiArgs.Emoji = ""; + } + + if ((args.Emoji.IsSpecified && args.Emoji.Value != null) && !string.IsNullOrEmpty(role.Icon)) + { + apiArgs.Icon = Optional.Unspecified; + } + + var model = await client.ApiClient.ModifyGuildRoleAsync(role.Guild.Id, role.Id, apiArgs, options).ConfigureAwait(false); + + if (args.Position.IsSpecified) + { + var bulkArgs = new[] { new BulkParams(role.Id, args.Position.Value) }; + await client.ApiClient.ModifyGuildRolesAsync(role.Guild.Id, bulkArgs, options).ConfigureAwait(false); + model.Position = args.Position.Value; + } + return model; + } + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Teams/RestTeam.cs b/src/Discord.Net.Rest/Entities/Teams/RestTeam.cs new file mode 100644 index 0000000..43c9417 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Teams/RestTeam.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.Team; + +namespace Discord.Rest +{ + public class RestTeam : RestEntity, ITeam + { + /// + public string IconUrl => _iconId != null ? CDN.GetTeamIconUrl(Id, _iconId) : null; + /// + public IReadOnlyList TeamMembers { get; private set; } + /// + public string Name { get; private set; } + /// + public ulong OwnerUserId { get; private set; } + + private string _iconId; + + internal RestTeam(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + internal static RestTeam Create(BaseDiscordClient discord, Model model) + { + var entity = new RestTeam(discord, model.Id); + entity.Update(model); + return entity; + } + internal virtual void Update(Model model) + { + if (model.Icon.IsSpecified) + _iconId = model.Icon.Value; + Name = model.Name; + OwnerUserId = model.OwnerUserId; + TeamMembers = model.TeamMembers.Select(x => new RestTeamMember(Discord, x)).ToImmutableArray(); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Teams/RestTeamMember.cs b/src/Discord.Net.Rest/Entities/Teams/RestTeamMember.cs new file mode 100644 index 0000000..322bb6a --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Teams/RestTeamMember.cs @@ -0,0 +1,30 @@ +using System; +using Model = Discord.API.TeamMember; + +namespace Discord.Rest +{ + public class RestTeamMember : ITeamMember + { + /// + public MembershipState MembershipState { get; } + /// + public string[] Permissions { get; } + /// + public ulong TeamId { get; } + /// + public IUser User { get; } + + internal RestTeamMember(BaseDiscordClient discord, Model model) + { + MembershipState = model.MembershipState switch + { + API.MembershipState.Invited => MembershipState.Invited, + API.MembershipState.Accepted => MembershipState.Accepted, + _ => throw new InvalidOperationException("Invalid membership state"), + }; + Permissions = model.Permissions; + TeamId = model.TeamId; + User = RestUser.Create(discord, model.User); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/RestConnection.cs b/src/Discord.Net.Rest/Entities/Users/RestConnection.cs new file mode 100644 index 0000000..b0e19e2 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/RestConnection.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Linq; +using Model = Discord.API.Connection; + +namespace Discord.Rest +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestConnection : IConnection + { + /// + public string Id { get; private set; } + /// + public string Name { get; private set; } + /// + public string Type { get; private set; } + /// + public bool? IsRevoked { get; private set; } + /// + public IReadOnlyCollection Integrations { get; private set; } + /// + public bool Verified { get; private set; } + /// + public bool FriendSync { get; private set; } + /// + public bool ShowActivity { get; private set; } + /// + public ConnectionVisibility Visibility { get; private set; } + + internal BaseDiscordClient Discord { get; } + + internal RestConnection(BaseDiscordClient discord) + { + Discord = discord; + } + + internal static RestConnection Create(BaseDiscordClient discord, Model model) + { + var entity = new RestConnection(discord); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Id = model.Id; + Name = model.Name; + Type = model.Type; + IsRevoked = model.Revoked.IsSpecified ? model.Revoked.Value : null; + Integrations = model.Integrations.IsSpecified ? model.Integrations.Value + .Select(intergration => RestIntegration.Create(Discord, null, intergration)).ToImmutableArray() : null; + Verified = model.Verified; + FriendSync = model.FriendSync; + ShowActivity = model.ShowActivity; + Visibility = model.Visibility; + } + + /// + /// Gets the name of the connection. + /// + /// + /// Name of the connection. + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id}, {Type}{(IsRevoked.GetValueOrDefault() ? ", Revoked" : "")})"; + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/RestGroupUser.cs b/src/Discord.Net.Rest/Entities/Users/RestGroupUser.cs new file mode 100644 index 0000000..8412d13 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/RestGroupUser.cs @@ -0,0 +1,49 @@ +using System; +using System.Diagnostics; +using Model = Discord.API.User; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based group user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestGroupUser : RestUser, IGroupUser + { + #region RestGroupUser + internal RestGroupUser(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + internal new static RestGroupUser Create(BaseDiscordClient discord, Model model) + { + var entity = new RestGroupUser(discord, model.Id); + entity.Update(model); + return entity; + } + #endregion + + #region IVoiceState + /// + bool IVoiceState.IsDeafened => false; + /// + bool IVoiceState.IsMuted => false; + /// + bool IVoiceState.IsSelfDeafened => false; + /// + bool IVoiceState.IsSelfMuted => false; + /// + bool IVoiceState.IsSuppressed => false; + /// + IVoiceChannel IVoiceState.VoiceChannel => null; + /// + string IVoiceState.VoiceSessionId => null; + /// + bool IVoiceState.IsStreaming => false; + /// + bool IVoiceState.IsVideoing => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/RestGuildUser.cs b/src/Discord.Net.Rest/Entities/Users/RestGuildUser.cs new file mode 100644 index 0000000..201cdbe --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/RestGuildUser.cs @@ -0,0 +1,238 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.GuildMember; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based guild user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestGuildUser : RestUser, IGuildUser + { + #region RestGuildUser + private long? _premiumSinceTicks; + private long? _timedOutTicks; + private long? _joinedAtTicks; + private ImmutableArray _roleIds; + + /// + public string DisplayName => Nickname ?? GlobalName ?? Username; + + /// + public string Nickname { get; private set; } + /// + public string DisplayAvatarId => GuildAvatarId ?? AvatarId; + /// + public string GuildAvatarId { get; private set; } + internal IGuild Guild { get; private set; } + /// + public bool IsDeafened { get; private set; } + /// + public bool IsMuted { get; private set; } + /// + public DateTimeOffset? PremiumSince => DateTimeUtils.FromTicks(_premiumSinceTicks); + /// + public ulong GuildId { get; } + /// + public bool? IsPending { get; private set; } + + /// + public GuildUserFlags Flags { get; private set; } + + /// + public int Hierarchy + { + get + { + if (Guild.OwnerId == Id) + return int.MaxValue; + + var orderedRoles = Guild.Roles.OrderByDescending(x => x.Position); + return orderedRoles.Where(x => RoleIds.Contains(x.Id)).Max(x => x.Position); + } + } + + /// + public DateTimeOffset? TimedOutUntil + { + get + { + if (!_timedOutTicks.HasValue || _timedOutTicks.Value < 0) + return null; + else + return DateTimeUtils.FromTicks(_timedOutTicks); + } + } + + /// + /// Resolving permissions requires the parent guild to be downloaded. + public GuildPermissions GuildPermissions + { + get + { + if (!Guild.Available) + throw new InvalidOperationException("Resolving permissions requires the parent guild to be downloaded."); + return new GuildPermissions(Permissions.ResolveGuild(Guild, this)); + } + } + /// + public IReadOnlyCollection RoleIds => _roleIds; + + /// + public DateTimeOffset? JoinedAt => DateTimeUtils.FromTicks(_joinedAtTicks); + + internal RestGuildUser(BaseDiscordClient discord, IGuild guild, ulong id, ulong? guildId = null) + : base(discord, id) + { + if (guild is not null) + Guild = guild; + GuildId = guildId ?? Guild.Id; + } + internal static RestGuildUser Create(BaseDiscordClient discord, IGuild guild, Model model, ulong? guildId = null) + { + var entity = new RestGuildUser(discord, guild, model.User.Id, guildId); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + base.Update(model.User); + if (model.JoinedAt.IsSpecified) + _joinedAtTicks = model.JoinedAt.Value.UtcTicks; + if (model.Nick.IsSpecified) + Nickname = model.Nick.Value; + if (model.Avatar.IsSpecified) + GuildAvatarId = model.Avatar.Value; + if (model.Deaf.IsSpecified) + IsDeafened = model.Deaf.Value; + if (model.Mute.IsSpecified) + IsMuted = model.Mute.Value; + if (model.Roles.IsSpecified) + UpdateRoles(model.Roles.Value); + if (model.PremiumSince.IsSpecified) + _premiumSinceTicks = model.PremiumSince.Value?.UtcTicks; + if (model.TimedOutUntil.IsSpecified) + _timedOutTicks = model.TimedOutUntil.Value?.UtcTicks; + if (model.Pending.IsSpecified) + IsPending = model.Pending.Value; + Flags = model.Flags; + } + private void UpdateRoles(ulong[] roleIds) + { + var roles = ImmutableArray.CreateBuilder(roleIds.Length + 1); + roles.Add(GuildId); + for (int i = 0; i < roleIds.Length; i++) + roles.Add(roleIds[i]); + _roleIds = roles.ToImmutable(); + } + + /// + public override async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetGuildMemberAsync(GuildId, Id, options).ConfigureAwait(false); + Update(model); + } + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var args = await UserHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + if (args.Deaf.IsSpecified) + IsDeafened = args.Deaf.Value; + if (args.Mute.IsSpecified) + IsMuted = args.Mute.Value; + if (args.Nickname.IsSpecified) + Nickname = args.Nickname.Value; + if (args.Roles.IsSpecified) + UpdateRoles(args.Roles.Value.Select(x => x.Id).ToArray()); + else if (args.RoleIds.IsSpecified) + UpdateRoles(args.RoleIds.Value.ToArray()); + } + /// + public Task KickAsync(string reason = null, RequestOptions options = null) + => UserHelper.KickAsync(this, Discord, reason, options); + /// + public Task AddRoleAsync(ulong roleId, RequestOptions options = null) + => AddRolesAsync(new[] { roleId }, options); + /// + public Task AddRoleAsync(IRole role, RequestOptions options = null) + => AddRoleAsync(role.Id, options); + /// + public Task AddRolesAsync(IEnumerable roleIds, RequestOptions options = null) + => UserHelper.AddRolesAsync(this, Discord, roleIds, options); + /// + public Task AddRolesAsync(IEnumerable roles, RequestOptions options = null) + => AddRolesAsync(roles.Select(x => x.Id), options); + /// + public Task RemoveRoleAsync(ulong roleId, RequestOptions options = null) + => RemoveRolesAsync(new[] { roleId }, options); + /// + public Task RemoveRoleAsync(IRole role, RequestOptions options = null) + => RemoveRoleAsync(role.Id, options); + /// + public Task RemoveRolesAsync(IEnumerable roleIds, RequestOptions options = null) + => UserHelper.RemoveRolesAsync(this, Discord, roleIds, options); + /// + public Task RemoveRolesAsync(IEnumerable roles, RequestOptions options = null) + => RemoveRolesAsync(roles.Select(x => x.Id)); + /// + public Task SetTimeOutAsync(TimeSpan span, RequestOptions options = null) + => UserHelper.SetTimeoutAsync(this, Discord, span, options); + /// + public Task RemoveTimeOutAsync(RequestOptions options = null) + => UserHelper.RemoveTimeOutAsync(this, Discord, options); + + /// + /// Resolving permissions requires the parent guild to be downloaded. + public ChannelPermissions GetPermissions(IGuildChannel channel) + { + var guildPerms = GuildPermissions; + return new ChannelPermissions(Permissions.ResolveChannel(Guild, this, channel, guildPerms.RawValue)); + } + + /// + public string GetGuildAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => CDN.GetGuildUserAvatarUrl(Id, GuildId, GuildAvatarId, size, format); + + /// + public override string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => GetGuildAvatarUrl(format, size) ?? base.GetDisplayAvatarUrl(format, size); + #endregion + + #region IGuildUser + /// + IGuild IGuildUser.Guild + { + get + { + if (Guild != null) + return Guild; + throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + } + } + #endregion + + #region IVoiceState + /// + bool IVoiceState.IsSelfDeafened => false; + /// + bool IVoiceState.IsSelfMuted => false; + /// + bool IVoiceState.IsSuppressed => false; + /// + IVoiceChannel IVoiceState.VoiceChannel => null; + /// + string IVoiceState.VoiceSessionId => null; + /// + bool IVoiceState.IsStreaming => false; + /// + bool IVoiceState.IsVideoing => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/RestSelfUser.cs b/src/Discord.Net.Rest/Entities/Users/RestSelfUser.cs new file mode 100644 index 0000000..b5ef01c --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/RestSelfUser.cs @@ -0,0 +1,76 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.User; + +namespace Discord.Rest +{ + /// + /// Represents the logged-in REST-based user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestSelfUser : RestUser, ISelfUser + { + /// + public string Email { get; private set; } + /// + public bool IsVerified { get; private set; } + /// + public bool IsMfaEnabled { get; private set; } + /// + public UserProperties Flags { get; private set; } + /// + public PremiumType PremiumType { get; private set; } + /// + public string Locale { get; private set; } + + internal RestSelfUser(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + internal new static RestSelfUser Create(BaseDiscordClient discord, Model model) + { + var entity = new RestSelfUser(discord, model.Id); + entity.Update(model); + return entity; + } + /// + internal override void Update(Model model) + { + base.Update(model); + + if (model.Email.IsSpecified) + Email = model.Email.Value; + if (model.Verified.IsSpecified) + IsVerified = model.Verified.Value; + if (model.MfaEnabled.IsSpecified) + IsMfaEnabled = model.MfaEnabled.Value; + if (model.Flags.IsSpecified) + Flags = (UserProperties)model.Flags.Value; + if (model.PremiumType.IsSpecified) + PremiumType = model.PremiumType.Value; + if (model.Locale.IsSpecified) + Locale = model.Locale.Value; + } + + /// + /// Unable to update this object using a different token. + public override async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetMyUserAsync(options).ConfigureAwait(false); + if (model.Id != Id) + throw new InvalidOperationException("Unable to update this object using a different token."); + Update(model); + } + + /// + /// Unable to modify this object using a different token. + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + if (Id != Discord.CurrentUser.Id) + throw new InvalidOperationException("Unable to modify this object using a different token."); + var model = await UserHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/RestThreadUser.cs b/src/Discord.Net.Rest/Entities/Users/RestThreadUser.cs new file mode 100644 index 0000000..fdd20e9 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/RestThreadUser.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading.Tasks; +using Model = Discord.API.ThreadMember; + +namespace Discord.Rest +{ + /// + /// Represents a thread user received over the REST api. + /// + public class RestThreadUser : RestEntity, IThreadUser + { + /// + public IThreadChannel Thread { get; } + + /// + public DateTimeOffset ThreadJoinedAt { get; private set; } + + /// + public IGuild Guild { get; } + + /// + public RestGuildUser GuildUser { get; private set; } + + /// + public string Mention => MentionUtils.MentionUser(Id); + + internal RestThreadUser(BaseDiscordClient discord, IGuild guild, IThreadChannel channel, ulong id) + : base(discord, id) + { + Guild = guild; + Thread = channel; + } + + internal static RestThreadUser Create(BaseDiscordClient client, IGuild guild, Model model, IThreadChannel channel) + { + var entity = new RestThreadUser(client, guild, channel, model.UserId.Value); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + ThreadJoinedAt = model.JoinTimestamp; + if (model.GuildMember.IsSpecified) + GuildUser = RestGuildUser.Create(Discord, Guild, model.GuildMember.Value); + } + + /// + IGuildUser IThreadUser.GuildUser => GuildUser; + + /// + /// Gets the guild user for this thread user. + /// + /// + /// A task representing the asynchronous get operation. The task returns a + /// that represents the current thread user. + /// + public Task GetGuildUser() + => Guild.GetUserAsync(Id); + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/RestUser.cs b/src/Discord.Net.Rest/Entities/Users/RestUser.cs new file mode 100644 index 0000000..7887228 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/RestUser.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Threading.Tasks; + +using EventUserModel = Discord.API.GuildScheduledEventUser; +using Model = Discord.API.User; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestUser : RestEntity, IUser, IUpdateable + { + #region RestUser + /// + public bool IsBot { get; private set; } + /// + public string Username { get; private set; } + /// + public ushort DiscriminatorValue { get; private set; } + /// + public string AvatarId { get; private set; } + + /// + /// Gets the hash of the banner. + /// + /// + /// if the user has no banner set. + /// + public string BannerId { get; private set; } + + /// + /// Gets the color of the banner. + /// + /// + /// if the user has no banner set. + /// + public Color? BannerColor { get; private set; } + + /// + public Color? AccentColor { get; private set; } + /// + public UserProperties? PublicFlags { get; private set; } + /// + public string GlobalName { get; internal set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public string Discriminator => DiscriminatorValue.ToString("D4"); + /// + public string Mention => MentionUtils.MentionUser(Id); + /// + public virtual IActivity Activity => null; + /// + public virtual UserStatus Status => UserStatus.Offline; + /// + public virtual IReadOnlyCollection ActiveClients => ImmutableHashSet.Empty; + /// + public virtual IReadOnlyCollection Activities => ImmutableList.Empty; + /// + public virtual bool IsWebhook => false; + + /// + public string AvatarDecorationHash { get; private set; } + + /// + public ulong? AvatarDecorationSkuId { get; private set; } + + + internal RestUser(BaseDiscordClient discord, ulong id) + : base(discord, id) + { + } + internal static RestUser Create(BaseDiscordClient discord, Model model) + => Create(discord, null, model, null); + internal static RestUser Create(BaseDiscordClient discord, IGuild guild, Model model, ulong? webhookId) + { + RestUser entity; + if (webhookId.HasValue) + entity = new RestWebhookUser(discord, guild, model.Id, webhookId.Value); + else + entity = new RestUser(discord, model.Id); + entity.Update(model); + return entity; + } + internal static RestUser Create(BaseDiscordClient discord, IGuild guild, EventUserModel model) + { + if (model.Member.IsSpecified) + { + var member = model.Member.Value; + member.User = model.User; + return RestGuildUser.Create(discord, guild, member); + } + else + return RestUser.Create(discord, model.User); + } + + internal virtual void Update(Model model) + { + if (model.Avatar.IsSpecified) + AvatarId = model.Avatar.Value; + if (model.Banner.IsSpecified) + BannerId = model.Banner.Value; + if (model.BannerColor.IsSpecified) + BannerColor = model.BannerColor.Value; + if (model.AccentColor.IsSpecified) + AccentColor = model.AccentColor.Value; + if (model.Discriminator.IsSpecified) + DiscriminatorValue = ushort.Parse(model.Discriminator.GetValueOrDefault(null) ?? "0", NumberStyles.None, CultureInfo.InvariantCulture); + if (model.Bot.IsSpecified) + IsBot = model.Bot.Value; + if (model.Username.IsSpecified) + Username = model.Username.Value; + if (model.PublicFlags.IsSpecified) + PublicFlags = model.PublicFlags.Value; + if (model.GlobalName.IsSpecified) + GlobalName = model.GlobalName.Value; + if (model.AvatarDecoration is { IsSpecified: true, Value: not null }) + { + AvatarDecorationHash = model.AvatarDecoration.Value?.Asset; + AvatarDecorationSkuId = model.AvatarDecoration.Value?.SkuId; + } + } + + /// + public virtual async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetUserAsync(Id, options).ConfigureAwait(false); + Update(model); + } + + /// + /// Creates a direct message channel to this user. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a rest DM channel where the user is the recipient. + /// + public Task CreateDMChannelAsync(RequestOptions options = null) + => UserHelper.CreateDMChannelAsync(this, Discord, options); + + /// + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => CDN.GetUserAvatarUrl(Id, AvatarId, size, format); + + /// + public string GetBannerUrl(ImageFormat format = ImageFormat.Auto, ushort size = 256) + => CDN.GetUserBannerUrl(Id, BannerId, size, format); + + /// + public string GetDefaultAvatarUrl() + => DiscriminatorValue != 0 + ? CDN.GetDefaultUserAvatarUrl(DiscriminatorValue) + : CDN.GetDefaultUserAvatarUrl(Id); + + public virtual string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => GetAvatarUrl(format, size) ?? GetDefaultAvatarUrl(); + + /// + public string GetAvatarDecorationUrl() + => AvatarDecorationHash is not null + ? CDN.GetAvatarDecorationUrl(AvatarDecorationHash) + : null; + + /// + /// Gets the Username#Discriminator of the user. + /// + /// + /// A string that resolves to Username#Discriminator of the user. + /// + public override string ToString() + => Format.UsernameAndDiscriminator(this, Discord.FormatUsersInBidirectionalUnicode); + + private string DebuggerDisplay => $"{Format.UsernameAndDiscriminator(this, Discord.FormatUsersInBidirectionalUnicode)} ({Id}{(IsBot ? ", Bot" : "")})"; + #endregion + + #region IUser + /// + async Task IUser.CreateDMChannelAsync(RequestOptions options) + => await CreateDMChannelAsync(options).ConfigureAwait(false); + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/RestWebhookUser.cs b/src/Discord.Net.Rest/Entities/Users/RestWebhookUser.cs new file mode 100644 index 0000000..ba3501d --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/RestWebhookUser.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.User; + +namespace Discord.Rest +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestWebhookUser : RestUser, IWebhookUser + { + #region RestWebhookUser + /// + public ulong WebhookId { get; } + internal IGuild Guild { get; } + /// + public DateTimeOffset? PremiumSince { get; private set; } + + /// + public override bool IsWebhook => true; + /// + public ulong GuildId => Guild.Id; + + internal RestWebhookUser(BaseDiscordClient discord, IGuild guild, ulong id, ulong webhookId) + : base(discord, id) + { + Guild = guild; + WebhookId = webhookId; + } + internal static RestWebhookUser Create(BaseDiscordClient discord, IGuild guild, Model model, ulong webhookId) + { + var entity = new RestWebhookUser(discord, guild, model.Id, webhookId); + entity.Update(model); + return entity; + } + #endregion + + #region IGuildUser + /// + IGuild IGuildUser.Guild + { + get + { + if (Guild != null) + return Guild; + throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + } + } + /// + IReadOnlyCollection IGuildUser.RoleIds => ImmutableArray.Create(); + /// + DateTimeOffset? IGuildUser.JoinedAt => null; + /// + string IGuildUser.DisplayName => null; + /// + string IGuildUser.Nickname => null; + /// + string IGuildUser.DisplayAvatarId => null; + /// + string IGuildUser.GuildAvatarId => null; + /// + string IGuildUser.GetGuildAvatarUrl(ImageFormat format, ushort size) => null; + /// + bool? IGuildUser.IsPending => null; + /// + int IGuildUser.Hierarchy => 0; + /// + DateTimeOffset? IGuildUser.TimedOutUntil => null; + /// + GuildPermissions IGuildUser.GuildPermissions => GuildPermissions.Webhook; + /// + GuildUserFlags IGuildUser.Flags => GuildUserFlags.None; + + /// + ChannelPermissions IGuildUser.GetPermissions(IGuildChannel channel) => Permissions.ToChannelPerms(channel, GuildPermissions.Webhook.RawValue); + /// + Task IGuildUser.KickAsync(string reason, RequestOptions options) => + throw new NotSupportedException("Webhook users cannot be kicked."); + + /// + Task IGuildUser.ModifyAsync(Action func, RequestOptions options) => + throw new NotSupportedException("Webhook users cannot be modified."); + /// + Task IGuildUser.AddRoleAsync(ulong role, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.AddRoleAsync(IRole role, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.AddRolesAsync(IEnumerable roles, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.AddRolesAsync(IEnumerable roles, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.RemoveRoleAsync(ulong role, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.RemoveRoleAsync(IRole role, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.RemoveRolesAsync(IEnumerable roles, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.RemoveRolesAsync(IEnumerable roles, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + Task IGuildUser.SetTimeOutAsync(TimeSpan span, RequestOptions options) => + throw new NotSupportedException("Timeouts are not supported on webhook users."); + /// + Task IGuildUser.RemoveTimeOutAsync(RequestOptions options) => + throw new NotSupportedException("Timeouts are not supported on webhook users."); + #endregion + + #region IVoiceState + /// + bool IVoiceState.IsDeafened => false; + /// + bool IVoiceState.IsMuted => false; + /// + bool IVoiceState.IsSelfDeafened => false; + /// + bool IVoiceState.IsSelfMuted => false; + /// + bool IVoiceState.IsSuppressed => false; + /// + IVoiceChannel IVoiceState.VoiceChannel => null; + /// + string IVoiceState.VoiceSessionId => null; + /// + bool IVoiceState.IsStreaming => false; + /// + bool IVoiceState.IsVideoing => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Users/UserHelper.cs b/src/Discord.Net.Rest/Entities/Users/UserHelper.cs new file mode 100644 index 0000000..64607b4 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Users/UserHelper.cs @@ -0,0 +1,113 @@ +using Discord.API.Rest; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ImageModel = Discord.API.Image; +using Model = Discord.API.User; + +namespace Discord.Rest +{ + internal static class UserHelper + { + public static Task ModifyAsync(ISelfUser user, BaseDiscordClient client, Action func, RequestOptions options) + { + var args = new SelfUserProperties(); + func(args); + var apiArgs = new API.Rest.ModifyCurrentUserParams + { + Avatar = args.Avatar.IsSpecified ? args.Avatar.Value?.ToModel() : Optional.Create(), + Username = args.Username, + Banner = args.Banner.IsSpecified ? args.Banner.Value?.ToModel() : Optional.Create() + }; + + if (!apiArgs.Avatar.IsSpecified && user.AvatarId != null) + apiArgs.Avatar = new ImageModel(user.AvatarId); + + return client.ApiClient.ModifySelfAsync(apiArgs, options); + } + public static async Task ModifyAsync(IGuildUser user, BaseDiscordClient client, Action func, + RequestOptions options) + { + var args = new GuildUserProperties(); + func(args); + + if (args.TimedOutUntil.IsSpecified && args.TimedOutUntil.Value.Value.Offset > (new TimeSpan(28, 0, 0, 0))) + throw new ArgumentOutOfRangeException(nameof(args.TimedOutUntil), "Offset cannot be more than 28 days from the current date."); + + var apiArgs = new API.Rest.ModifyGuildMemberParams + { + Deaf = args.Deaf, + Mute = args.Mute, + Nickname = args.Nickname, + TimedOutUntil = args.TimedOutUntil, + Flags = args.Flags + }; + + if (args.Channel.IsSpecified) + apiArgs.ChannelId = args.Channel.Value?.Id; + else if (args.ChannelId.IsSpecified) + apiArgs.ChannelId = args.ChannelId.Value; + + if (args.Roles.IsSpecified) + apiArgs.RoleIds = args.Roles.Value.Select(x => x.Id).ToArray(); + else if (args.RoleIds.IsSpecified) + apiArgs.RoleIds = args.RoleIds.Value.ToArray(); + + /* + * Ensure that the nick passed in the params of the request is not null. + * string.Empty ("") is the only way to reset the user nick in the API, + * a value of null does not. This is a workaround. + */ + if (apiArgs.Nickname.IsSpecified && apiArgs.Nickname.Value == null) + apiArgs.Nickname = new Optional(string.Empty); + + await client.ApiClient.ModifyGuildMemberAsync(user.GuildId, user.Id, apiArgs, options).ConfigureAwait(false); + return args; + } + + public static Task KickAsync(IGuildUser user, BaseDiscordClient client, string reason, RequestOptions options) + => client.ApiClient.RemoveGuildMemberAsync(user.GuildId, user.Id, reason, options); + + public static async Task CreateDMChannelAsync(IUser user, BaseDiscordClient client, + RequestOptions options) + { + var args = new CreateDMChannelParams(user.Id); + return RestDMChannel.Create(client, await client.ApiClient.CreateDMChannelAsync(args, options).ConfigureAwait(false)); + } + + public static async Task AddRolesAsync(IGuildUser user, BaseDiscordClient client, IEnumerable roleIds, RequestOptions options) + { + foreach (var roleId in roleIds) + await client.ApiClient.AddRoleAsync(user.Guild.Id, user.Id, roleId, options).ConfigureAwait(false); + } + + public static async Task RemoveRolesAsync(IGuildUser user, BaseDiscordClient client, IEnumerable roleIds, RequestOptions options) + { + foreach (var roleId in roleIds) + await client.ApiClient.RemoveRoleAsync(user.Guild.Id, user.Id, roleId, options).ConfigureAwait(false); + } + + public static Task SetTimeoutAsync(IGuildUser user, BaseDiscordClient client, TimeSpan span, RequestOptions options) + { + if (span.TotalDays > 28) // As its double, an exact value of 28 can be accepted. + throw new ArgumentOutOfRangeException(nameof(span), "Offset cannot be more than 28 days from the current date."); + if (span.Ticks <= 0) + throw new ArgumentOutOfRangeException(nameof(span), "Offset cannot hold no value or have a negative value."); + var apiArgs = new API.Rest.ModifyGuildMemberParams() + { + TimedOutUntil = DateTimeOffset.UtcNow.Add(span) + }; + return client.ApiClient.ModifyGuildMemberAsync(user.Guild.Id, user.Id, apiArgs, options); + } + + public static Task RemoveTimeOutAsync(IGuildUser user, BaseDiscordClient client, RequestOptions options) + { + var apiArgs = new API.Rest.ModifyGuildMemberParams() + { + TimedOutUntil = null + }; + return client.ApiClient.ModifyGuildMemberAsync(user.Guild.Id, user.Id, apiArgs, options); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Webhooks/RestWebhook.cs b/src/Discord.Net.Rest/Entities/Webhooks/RestWebhook.cs new file mode 100644 index 0000000..22b9b60 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Webhooks/RestWebhook.cs @@ -0,0 +1,152 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Webhook; + +namespace Discord.Rest +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class RestWebhook : RestEntity, IWebhook, IUpdateable + { + #region RestWebhook + + internal IGuild Guild { get; private set; } + internal IIntegrationChannel Channel { get; private set; } + + /// + public string Token { get; } + + /// + public ulong? ChannelId { get; private set; } + + /// + public string Name { get; private set; } + + /// + public string AvatarId { get; private set; } + + /// + public ulong? GuildId { get; private set; } + + /// + public IUser Creator { get; private set; } + + /// + public ulong? ApplicationId { get; private set; } + + /// + public WebhookType Type { get; private set; } + + /// + /// Gets the partial guild of the followed channel. if is not . + /// + public PartialGuild PartialGuild { get; private set; } + + /// + /// Gets the id of the followed channel. if is not . + /// + public ulong? FollowedChannelId { get; private set; } + + /// + /// Gets the name of the followed channel. if is not . + /// + public string FollowedChannelName { get; private set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + internal RestWebhook(BaseDiscordClient discord, IGuild guild, ulong id, string token, ulong? channelId, WebhookType type, PartialGuild partialGuild, + ulong? followedChannelId, string followedChannelName) + : base(discord, id) + { + Guild = guild; + Token = token; + ChannelId = channelId; + Type = type; + PartialGuild = partialGuild; + FollowedChannelId = followedChannelId; + FollowedChannelName = followedChannelName; + } + + internal RestWebhook(BaseDiscordClient discord, IIntegrationChannel channel, ulong id, string token, ulong? channelId, WebhookType type, PartialGuild partialGuild, + ulong? followedChannelId, string followedChannelName) + : this(discord, channel.Guild, id, token, channelId, type, partialGuild, followedChannelId, followedChannelName) + { + Channel = channel; + } + + internal static RestWebhook Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestWebhook(discord, guild, model.Id, model.Token.GetValueOrDefault(null), model.ChannelId, model.Type, + model.Guild.IsSpecified ? PartialGuildExtensions.Create(model.Guild.Value) : null, + model.Channel.IsSpecified ? model.Channel.Value.Id : null, + model.Channel.IsSpecified ? model.Channel.Value.Name.GetValueOrDefault(null) : null + ); + entity.Update(model); + return entity; + } + + internal static RestWebhook Create(BaseDiscordClient discord, IIntegrationChannel channel, Model model) + { + var entity = new RestWebhook(discord, channel, model.Id, model.Token.GetValueOrDefault(null), model.ChannelId, model.Type, + model.Guild.IsSpecified ? PartialGuildExtensions.Create(model.Guild.Value) : null, + model.Channel.IsSpecified ? model.Channel.Value.Id : null, + model.Channel.IsSpecified ? model.Channel.Value.Name.GetValueOrDefault(null) : null); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + if (ChannelId != model.ChannelId) + ChannelId = model.ChannelId; + if (model.Avatar.IsSpecified) + AvatarId = model.Avatar.Value; + if (model.Creator.IsSpecified) + Creator = RestUser.Create(Discord, model.Creator.Value); + if (model.GuildId.IsSpecified) + GuildId = model.GuildId.Value; + if (model.Name.IsSpecified) + Name = model.Name.Value; + + ApplicationId = model.ApplicationId; + } + + /// + public async Task UpdateAsync(RequestOptions options = null) + { + var model = await Discord.ApiClient.GetWebhookAsync(Id, options).ConfigureAwait(false); + Update(model); + } + + /// + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => CDN.GetUserAvatarUrl(Id, AvatarId, size, format); + + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await WebhookHelper.ModifyAsync(this, Discord, func, options).ConfigureAwait(false); + Update(model); + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => WebhookHelper.DeleteAsync(this, Discord, options); + + public override string ToString() => $"Webhook: {Name}:{Id}"; + private string DebuggerDisplay => $"Webhook: {Name} ({Id})"; + #endregion + + #region IWebhook + /// + IGuild IWebhook.Guild + => Guild ?? throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + /// + IIntegrationChannel IWebhook.Channel + => Channel ?? throw new InvalidOperationException("Unable to return this entity's parent unless it was fetched through that object."); + /// + Task IWebhook.ModifyAsync(Action func, RequestOptions options) + => ModifyAsync(func, options); + #endregion + } +} diff --git a/src/Discord.Net.Rest/Entities/Webhooks/WebhookHelper.cs b/src/Discord.Net.Rest/Entities/Webhooks/WebhookHelper.cs new file mode 100644 index 0000000..385a6f3 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Webhooks/WebhookHelper.cs @@ -0,0 +1,36 @@ +using Discord.API.Rest; +using System; +using System.Threading.Tasks; +using ImageModel = Discord.API.Image; +using Model = Discord.API.Webhook; + +namespace Discord.Rest +{ + internal static class WebhookHelper + { + public static Task ModifyAsync(IWebhook webhook, BaseDiscordClient client, + Action func, RequestOptions options) + { + var args = new WebhookProperties(); + func(args); + var apiArgs = new ModifyWebhookParams + { + Avatar = args.Image.IsSpecified ? args.Image.Value?.ToModel() : Optional.Create(), + Name = args.Name + }; + + if (!apiArgs.Avatar.IsSpecified && webhook.AvatarId != null) + apiArgs.Avatar = new ImageModel(webhook.AvatarId); + + if (args.Channel.IsSpecified) + apiArgs.ChannelId = args.Channel.Value.Id; + else if (args.ChannelId.IsSpecified) + apiArgs.ChannelId = args.ChannelId.Value; + + return client.ApiClient.ModifyWebhookAsync(webhook.Id, apiArgs, options); + } + + public static Task DeleteAsync(IWebhook webhook, BaseDiscordClient client, RequestOptions options) + => client.ApiClient.DeleteWebhookAsync(webhook.Id, options); + } +} diff --git a/src/Discord.Net.Rest/Extensions/ClientExtensions.cs b/src/Discord.Net.Rest/Extensions/ClientExtensions.cs new file mode 100644 index 0000000..647c7d4 --- /dev/null +++ b/src/Discord.Net.Rest/Extensions/ClientExtensions.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + public static class ClientExtensions + { + /// + /// Adds a user to the specified guild. + /// + /// + /// This method requires you have an OAuth2 access token for the user, requested with the guilds.join scope, and that the bot have the MANAGE_INVITES permission in the guild. + /// + /// The Discord client object. + /// The snowflake identifier of the guild. + /// The snowflake identifier of the user. + /// The OAuth2 access token for the user, requested with the guilds.join scope. + /// The delegate containing the properties to be applied to the user upon being added to the guild. + /// The options to be used when sending the request. + public static Task AddGuildUserAsync(this BaseDiscordClient client, ulong guildId, ulong userId, string accessToken, Action func = null, RequestOptions options = null) + => GuildHelper.AddGuildUserAsync(guildId, client, userId, accessToken, func, options); + } +} diff --git a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs new file mode 100644 index 0000000..34d279e --- /dev/null +++ b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs @@ -0,0 +1,221 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Rest +{ + internal static class EntityExtensions + { + public static IEmote ToIEmote(this API.Emoji model) + { + if (model.Id.HasValue) + return model.ToEntity(); + return new Emoji(model.Name); + } + + public static GuildEmote ToEntity(this API.Emoji model) + => new GuildEmote(model.Id.Value, + model.Name, + model.Animated.GetValueOrDefault(), + model.Managed, + model.RequireColons, + ImmutableArray.Create(model.Roles), + model.User.IsSpecified ? model.User.Value.Id : (ulong?)null); + + public static Embed ToEntity(this API.Embed model) + { + return new Embed(model.Type, model.Title, model.Description, model.Url, model.Timestamp, + model.Color.HasValue ? new Color(model.Color.Value) : (Color?)null, + model.Image.IsSpecified ? model.Image.Value.ToEntity() : (EmbedImage?)null, + model.Video.IsSpecified ? model.Video.Value.ToEntity() : (EmbedVideo?)null, + model.Author.IsSpecified ? model.Author.Value.ToEntity() : (EmbedAuthor?)null, + model.Footer.IsSpecified ? model.Footer.Value.ToEntity() : (EmbedFooter?)null, + model.Provider.IsSpecified ? model.Provider.Value.ToEntity() : (EmbedProvider?)null, + model.Thumbnail.IsSpecified ? model.Thumbnail.Value.ToEntity() : (EmbedThumbnail?)null, + model.Fields.IsSpecified ? model.Fields.Value.Select(x => x.ToEntity()).ToImmutableArray() : ImmutableArray.Create()); + } + public static RoleTags ToEntity(this API.RoleTags model) + { + return new RoleTags( + model.BotId.IsSpecified ? model.BotId.Value : null, + model.IntegrationId.IsSpecified ? model.IntegrationId.Value : null, + model.IsPremiumSubscriber.IsSpecified); + } + public static API.Embed ToModel(this Embed entity) + { + if (entity == null) + return null; + var model = new API.Embed + { + Type = entity.Type, + Title = entity.Title, + Description = entity.Description, + Url = entity.Url, + Timestamp = entity.Timestamp, + Color = entity.Color?.RawValue + }; + if (entity.Author != null) + model.Author = entity.Author.Value.ToModel(); + model.Fields = entity.Fields.Select(x => x.ToModel()).ToArray(); + if (entity.Footer != null) + model.Footer = entity.Footer.Value.ToModel(); + if (entity.Image != null) + model.Image = entity.Image.Value.ToModel(); + if (entity.Provider != null) + model.Provider = entity.Provider.Value.ToModel(); + if (entity.Thumbnail != null) + model.Thumbnail = entity.Thumbnail.Value.ToModel(); + if (entity.Video != null) + model.Video = entity.Video.Value.ToModel(); + return model; + } + + public static API.AllowedMentions ToModel(this AllowedMentions entity) + { + if (entity == null) + return null; + return new API.AllowedMentions() + { + Parse = entity.AllowedTypes?.EnumerateMentionTypes().ToArray(), + Roles = entity.RoleIds?.ToArray(), + Users = entity.UserIds?.ToArray(), + RepliedUser = entity.MentionRepliedUser ?? Optional.Create(), + }; + } + public static API.MessageReference ToModel(this MessageReference entity) + { + return new API.MessageReference() + { + ChannelId = entity.InternalChannelId, + GuildId = entity.GuildId, + MessageId = entity.MessageId, + FailIfNotExists = entity.FailIfNotExists + }; + } + public static IEnumerable EnumerateMentionTypes(this AllowedMentionTypes mentionTypes) + { + if (mentionTypes.HasFlag(AllowedMentionTypes.Everyone)) + yield return "everyone"; + if (mentionTypes.HasFlag(AllowedMentionTypes.Roles)) + yield return "roles"; + if (mentionTypes.HasFlag(AllowedMentionTypes.Users)) + yield return "users"; + } + public static EmbedAuthor ToEntity(this API.EmbedAuthor model) + { + return new EmbedAuthor(model.Name, model.Url, model.IconUrl, model.ProxyIconUrl); + } + public static API.EmbedAuthor ToModel(this EmbedAuthor entity) + { + return new API.EmbedAuthor { Name = entity.Name, Url = entity.Url, IconUrl = entity.IconUrl }; + } + public static EmbedField ToEntity(this API.EmbedField model) + { + return new EmbedField(model.Name, model.Value, model.Inline); + } + public static API.EmbedField ToModel(this EmbedField entity) + { + return new API.EmbedField { Name = entity.Name, Value = entity.Value, Inline = entity.Inline }; + } + public static EmbedFooter ToEntity(this API.EmbedFooter model) + { + return new EmbedFooter(model.Text, model.IconUrl, model.ProxyIconUrl); + } + public static API.EmbedFooter ToModel(this EmbedFooter entity) + { + return new API.EmbedFooter { Text = entity.Text, IconUrl = entity.IconUrl }; + } + public static EmbedImage ToEntity(this API.EmbedImage model) + { + return new EmbedImage(model.Url, model.ProxyUrl, + model.Height.IsSpecified ? model.Height.Value : (int?)null, + model.Width.IsSpecified ? model.Width.Value : (int?)null); + } + public static API.EmbedImage ToModel(this EmbedImage entity) + { + return new API.EmbedImage { Url = entity.Url }; + } + public static EmbedProvider ToEntity(this API.EmbedProvider model) + { + return new EmbedProvider(model.Name, model.Url); + } + public static API.EmbedProvider ToModel(this EmbedProvider entity) + { + return new API.EmbedProvider { Name = entity.Name, Url = entity.Url }; + } + public static EmbedThumbnail ToEntity(this API.EmbedThumbnail model) + { + return new EmbedThumbnail(model.Url, model.ProxyUrl, + model.Height.IsSpecified ? model.Height.Value : (int?)null, + model.Width.IsSpecified ? model.Width.Value : (int?)null); + } + public static API.EmbedThumbnail ToModel(this EmbedThumbnail entity) + { + return new API.EmbedThumbnail { Url = entity.Url }; + } + public static EmbedVideo ToEntity(this API.EmbedVideo model) + { + return new EmbedVideo(model.Url, + model.Height.IsSpecified ? model.Height.Value : (int?)null, + model.Width.IsSpecified ? model.Width.Value : (int?)null); + } + public static API.EmbedVideo ToModel(this EmbedVideo entity) + { + return new API.EmbedVideo { Url = entity.Url }; + } + + public static API.Image ToModel(this Image entity) + { + return new API.Image(entity.Stream); + } + + public static Overwrite ToEntity(this API.Overwrite model) + { + return new Overwrite(model.TargetId, model.TargetType, new OverwritePermissions(model.Allow, model.Deny)); + } + + public static API.Message ToMessage(this API.InteractionResponse model, IDiscordInteraction interaction) + { + if (model.Data.IsSpecified) + { + var data = model.Data.Value; + var messageModel = new API.Message + { + IsTextToSpeech = data.TTS, + Content = (data.Content.IsSpecified && data.Content.Value == null) ? Optional.Unspecified : data.Content, + Embeds = data.Embeds, + AllowedMentions = data.AllowedMentions, + Components = data.Components, + Flags = data.Flags, + }; + + if (interaction is IApplicationCommandInteraction command) + { + messageModel.Interaction = new API.MessageInteraction + { + Id = command.Id, + Name = command.Data.Name, + Type = InteractionType.ApplicationCommand, + User = new API.User + { + Username = command.User.Username, + Avatar = command.User.AvatarId, + Bot = command.User.IsBot, + Discriminator = command.User.Discriminator == "0000" ? Optional.Unspecified : command.User.Discriminator, + PublicFlags = command.User.PublicFlags.HasValue ? command.User.PublicFlags.Value : Optional.Unspecified, + Id = command.User.Id, + GlobalName = command.User.GlobalName, + } + }; + } + + return messageModel; + } + + return new API.Message + { + Id = interaction.Id, + }; + } + } +} diff --git a/src/Discord.Net.Rest/Extensions/InteractionMetadataExtensions.cs b/src/Discord.Net.Rest/Extensions/InteractionMetadataExtensions.cs new file mode 100644 index 0000000..547bd3f --- /dev/null +++ b/src/Discord.Net.Rest/Extensions/InteractionMetadataExtensions.cs @@ -0,0 +1,42 @@ +using System.Collections.Immutable; + +namespace Discord.Rest; + +internal static class InteractionMetadataExtensions +{ + public static IMessageInteractionMetadata ToInteractionMetadata(this API.MessageInteractionMetadata metadata) + { + switch (metadata.Type) + { + case InteractionType.ApplicationCommand: + return new ApplicationCommandInteractionMetadata( + metadata.Id, + metadata.Type, + metadata.UserId, + metadata.IntegrationOwners.ToImmutableDictionary(), + metadata.OriginalResponseMessageId.IsSpecified ? metadata.OriginalResponseMessageId.Value : null, + metadata.Name.GetValueOrDefault(null)); + + case InteractionType.MessageComponent: + return new MessageComponentInteractionMetadata( + metadata.Id, + metadata.Type, + metadata.UserId, + metadata.IntegrationOwners.ToImmutableDictionary(), + metadata.OriginalResponseMessageId.IsSpecified ? metadata.OriginalResponseMessageId.Value : null, + metadata.InteractedMessageId.GetValueOrDefault(0)); + + case InteractionType.ModalSubmit: + return new ModalSubmitInteractionMetadata( + metadata.Id, + metadata.Type, + metadata.UserId, + metadata.IntegrationOwners.ToImmutableDictionary(), + metadata.OriginalResponseMessageId.IsSpecified ? metadata.OriginalResponseMessageId.Value : null, + metadata.TriggeringInteractionMetadata.GetValueOrDefault(null)?.ToInteractionMetadata()); + + default: + return null; + } + } +} diff --git a/src/Discord.Net.Rest/Extensions/PartialGuildExtensions.cs b/src/Discord.Net.Rest/Extensions/PartialGuildExtensions.cs new file mode 100644 index 0000000..b8caace --- /dev/null +++ b/src/Discord.Net.Rest/Extensions/PartialGuildExtensions.cs @@ -0,0 +1,35 @@ +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.Rest; + +internal static class PartialGuildExtensions +{ + public static PartialGuild Create(API.PartialGuild model) + => new PartialGuild + { + Id = model.Id, + Name = model.Name, + Description = model.Description.IsSpecified ? model.Description.Value : null, + SplashId = model.Splash.IsSpecified ? model.Splash.Value : null, + BannerId = model.BannerHash.IsSpecified ? model.BannerHash.Value : null, + Features = model.Features.IsSpecified ? model.Features.Value : null, + IconId = model.IconHash.IsSpecified ? model.IconHash.Value : null, + VerificationLevel = model.VerificationLevel.IsSpecified ? model.VerificationLevel.Value : null, + VanityURLCode = model.VanityUrlCode.IsSpecified ? model.VanityUrlCode.Value : null, + PremiumSubscriptionCount = model.PremiumSubscriptionCount.IsSpecified ? model.PremiumSubscriptionCount.Value : null, + NsfwLevel = model.NsfwLevel.IsSpecified ? model.NsfwLevel.Value : null, + WelcomeScreen = model.WelcomeScreen.IsSpecified + ? new WelcomeScreen( + model.WelcomeScreen.Value.Description.IsSpecified ? model.WelcomeScreen.Value.Description.Value : null, + model.WelcomeScreen.Value.WelcomeChannels.Select(ch => + new WelcomeScreenChannel( + ch.ChannelId, + ch.Description, + ch.EmojiName.IsSpecified ? ch.EmojiName.Value : null, + ch.EmojiId.IsSpecified ? ch.EmojiId.Value : null)).ToImmutableArray()) + : null, + ApproximateMemberCount = model.ApproximateMemberCount.IsSpecified ? model.ApproximateMemberCount.Value : null, + ApproximatePresenceCount = model.ApproximatePresenceCount.IsSpecified ? model.ApproximatePresenceCount.Value : null + }; +} diff --git a/src/Discord.Net.Rest/Extensions/StringExtensions.cs b/src/Discord.Net.Rest/Extensions/StringExtensions.cs new file mode 100644 index 0000000..d39d439 --- /dev/null +++ b/src/Discord.Net.Rest/Extensions/StringExtensions.cs @@ -0,0 +1,46 @@ +using Discord.Net.Converters; +using Newtonsoft.Json; +using System; +using System.Linq; + +namespace Discord.Rest +{ + /// + /// Responsible for formatting certain entities as Json , to reuse later on. + /// + public static class StringExtensions + { + private static Lazy _settings = new(() => + { + var serializer = new JsonSerializerSettings() + { + ContractResolver = new DiscordContractResolver() + }; + return serializer; + }); + + /// + /// Gets a Json formatted from an . + /// + /// + /// See to parse Json back into embed. + /// + /// The builder to format as Json . + /// The formatting in which the Json will be returned. + /// A Json containing the data from the . + public static string ToJsonString(this EmbedBuilder builder, Formatting formatting = Formatting.Indented) + => ToJsonString(builder.Build(), formatting); + + /// + /// Gets a Json formatted from an . + /// + /// + /// See to parse Json back into embed. + /// + /// The embed to format as Json . + /// The formatting in which the Json will be returned. + /// A Json containing the data from the . + public static string ToJsonString(this Embed embed, Formatting formatting = Formatting.Indented) + => JsonConvert.SerializeObject(embed.ToModel(), formatting, _settings.Value); + } +} diff --git a/src/Discord.Net.Rest/IRestClientProvider.cs b/src/Discord.Net.Rest/IRestClientProvider.cs new file mode 100644 index 0000000..c0a3997 --- /dev/null +++ b/src/Discord.Net.Rest/IRestClientProvider.cs @@ -0,0 +1,14 @@ +using Discord.Rest; + +namespace Discord.Rest; + +/// +/// An interface that represents a client provider for Rest-based clients. +/// +public interface IRestClientProvider +{ + /// + /// Gets the Rest client of this provider. + /// + DiscordRestClient RestClient { get; } +} diff --git a/src/Discord.Net.Rest/Interactions/RestInteractionContext.cs b/src/Discord.Net.Rest/Interactions/RestInteractionContext.cs new file mode 100644 index 0000000..57f72c2 --- /dev/null +++ b/src/Discord.Net.Rest/Interactions/RestInteractionContext.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + /// + /// Represents a Rest based context of an . + /// + public class RestInteractionContext : IRestInteractionContext, IRouteMatchContainer + where TInteraction : RestInteraction + { + /// + /// Gets the that the command will be executed with. + /// + public DiscordRestClient Client { get; } + + /// + /// Gets the the command originated from. + /// + /// + /// Will be null if the command is from a DM Channel. + /// + public RestGuild Guild { get; } + + /// + /// Gets the the command originated from. + /// + public IRestMessageChannel Channel { get; } + + /// + /// Gets the who executed the command. + /// + public RestUser User { get; } + + /// + /// Gets the the command was received with. + /// + public TInteraction Interaction { get; } + + /// + /// Gets or sets the callback to use when the service has outgoing json for the rest webhook. + /// + /// + /// If this property is the default callback will be used. + /// + public Func InteractionResponseCallback { get; set; } + + /// + public IReadOnlyCollection SegmentMatches { get; private set; } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + public RestInteractionContext(DiscordRestClient client, TInteraction interaction) + { + Client = client; + Guild = interaction.Guild; + Channel = interaction.Channel; + User = interaction.User; + Interaction = interaction; + } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + /// The callback for outgoing json. + public RestInteractionContext(DiscordRestClient client, TInteraction interaction, Func interactionResponseCallback) + : this(client, interaction) + { + InteractionResponseCallback = interactionResponseCallback; + } + + /// + public void SetSegmentMatches(IEnumerable segmentMatches) => SegmentMatches = segmentMatches.ToImmutableArray(); + + //IRouteMatchContainer + /// + IEnumerable IRouteMatchContainer.SegmentMatches => SegmentMatches; + + // IInteractionContext + /// + IDiscordClient IInteractionContext.Client => Client; + + /// + IGuild IInteractionContext.Guild => Guild; + + /// + IMessageChannel IInteractionContext.Channel => Channel; + + /// + IUser IInteractionContext.User => User; + + /// + IDiscordInteraction IInteractionContext.Interaction => Interaction; + } + + /// + /// Represents a Rest based context of an . + /// + public class RestInteractionContext : RestInteractionContext + { + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + public RestInteractionContext(DiscordRestClient client, RestInteraction interaction) : base(client, interaction) { } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + /// The callback for outgoing json. + public RestInteractionContext(DiscordRestClient client, RestInteraction interaction, Func interactionResponseCallback) + : base(client, interaction, interactionResponseCallback) { } + } +} diff --git a/src/Discord.Net.Rest/Net/BadSignatureException.cs b/src/Discord.Net.Rest/Net/BadSignatureException.cs new file mode 100644 index 0000000..08672df --- /dev/null +++ b/src/Discord.Net.Rest/Net/BadSignatureException.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + public class BadSignatureException : Exception + { + internal BadSignatureException() : base("Failed to verify authenticity of message: public key doesnt match signature") + { + + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/ArrayConverter.cs b/src/Discord.Net.Rest/Net/Converters/ArrayConverter.cs new file mode 100644 index 0000000..ce2e9b1 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/ArrayConverter.cs @@ -0,0 +1,59 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; + +namespace Discord.Net.Converters +{ + internal class ArrayConverter : JsonConverter + { + private readonly JsonConverter _innerConverter; + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public ArrayConverter(JsonConverter innerConverter) + { + _innerConverter = innerConverter; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var result = new List(); + if (reader.TokenType == JsonToken.StartArray) + { + reader.Read(); + while (reader.TokenType != JsonToken.EndArray) + { + T obj; + if (_innerConverter != null) + obj = (T)_innerConverter.ReadJson(reader, typeof(T), null, serializer); + else + obj = serializer.Deserialize(reader); + result.Add(obj); + reader.Read(); + } + } + return result.ToArray(); + } + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value != null) + { + writer.WriteStartArray(); + var a = (T[])value; + for (int i = 0; i < a.Length; i++) + { + if (_innerConverter != null) + _innerConverter.WriteJson(writer, a[i], serializer); + else + serializer.Serialize(writer, a[i], typeof(T)); + } + + writer.WriteEndArray(); + } + else + writer.WriteNull(); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/ColorConverter.cs b/src/Discord.Net.Rest/Net/Converters/ColorConverter.cs new file mode 100644 index 0000000..4c0ab52 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/ColorConverter.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; + +using System.Globalization; +using System; + +namespace Discord.Net.Converters; + +internal class ColorConverter : JsonConverter +{ + public static readonly ColorConverter Instance = new (); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.Value is null) + return null; + return new Color(uint.Parse(reader.Value.ToString()!.TrimStart('#'), NumberStyles.HexNumber)); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue($"#{(uint)value:X}"); + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs b/src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs new file mode 100644 index 0000000..8de2c02 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs @@ -0,0 +1,122 @@ +using Discord.API; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Discord.Net.Converters +{ + internal class DiscordContractResolver : DefaultContractResolver + { + #region DiscordContractResolver + private static readonly TypeInfo _ienumerable = typeof(IEnumerable).GetTypeInfo(); + private static readonly MethodInfo _shouldSerialize = typeof(DiscordContractResolver).GetTypeInfo().GetDeclaredMethod("ShouldSerialize"); + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + if (property.Ignored) + return property; + + if (member is PropertyInfo propInfo) + { + var converter = GetConverter(property, propInfo, propInfo.PropertyType, 0); + if (converter != null) + { + property.Converter = converter; + } + } + else + throw new InvalidOperationException($"{member.DeclaringType.FullName}.{member.Name} is not a property."); + return property; + } + + private static JsonConverter GetConverter(JsonProperty property, PropertyInfo propInfo, Type type, int depth) + { + if (type.IsArray) + return MakeGenericConverter(property, propInfo, typeof(ArrayConverter<>), type.GetElementType(), depth); + if (type.IsConstructedGenericType) + { + Type genericType = type.GetGenericTypeDefinition(); + if (depth == 0 && genericType == typeof(Optional<>)) + { + var typeInput = propInfo.DeclaringType; + var innerTypeOutput = type.GenericTypeArguments[0]; + + var getter = typeof(Func<,>).MakeGenericType(typeInput, type); + var getterDelegate = propInfo.GetMethod.CreateDelegate(getter); + var shouldSerialize = _shouldSerialize.MakeGenericMethod(typeInput, innerTypeOutput); + var shouldSerializeDelegate = (Func)shouldSerialize.CreateDelegate(typeof(Func)); + property.ShouldSerialize = x => shouldSerializeDelegate(x, getterDelegate); + + return MakeGenericConverter(property, propInfo, typeof(OptionalConverter<>), innerTypeOutput, depth); + } + else if (genericType == typeof(Nullable<>)) + return MakeGenericConverter(property, propInfo, typeof(NullableConverter<>), type.GenericTypeArguments[0], depth); + else if (genericType == typeof(EntityOrId<>)) + return MakeGenericConverter(property, propInfo, typeof(UInt64EntityOrIdConverter<>), type.GenericTypeArguments[0], depth); + } + #endregion + + #region Primitives + bool hasInt53 = propInfo.GetCustomAttribute() != null; + if (!hasInt53) + { + if (type == typeof(ulong)) + return UInt64Converter.Instance; + } + bool hasUnixStamp = propInfo.GetCustomAttribute() != null; + if (hasUnixStamp) + { + if (type == typeof(DateTimeOffset)) + return UnixTimestampConverter.Instance; + } + + //Enums + if (type == typeof(UserStatus)) + return UserStatusConverter.Instance; + if (type == typeof(EmbedType)) + return EmbedTypeConverter.Instance; + if (type == typeof(SelectDefaultValueType)) + return SelectMenuDefaultValueTypeConverter.Instance; + + //Special + if (type == typeof(API.Image)) + return ImageConverter.Instance; + if (typeof(IMessageComponent).IsAssignableFrom(type)) + return MessageComponentConverter.Instance; + if (type == typeof(API.Interaction)) + return InteractionConverter.Instance; + if (type == typeof(API.DiscordError)) + return DiscordErrorConverter.Instance; + if (type == typeof(GuildFeatures)) + return GuildFeaturesConverter.Instance; + if(type == typeof(Color)) + return ColorConverter.Instance; + + //Entities + var typeInfo = type.GetTypeInfo(); + if (typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEntity))) + return UInt64EntityConverter.Instance; + if (typeInfo.ImplementedInterfaces.Any(x => x == typeof(IEntity))) + return StringEntityConverter.Instance; + + return null; + } + + private static bool ShouldSerialize(object owner, Delegate getter) + { + return (getter as Func>)((TOwner)owner).IsSpecified; + } + + private static JsonConverter MakeGenericConverter(JsonProperty property, PropertyInfo propInfo, Type converterType, Type innerType, int depth) + { + var genericType = converterType.MakeGenericType(innerType).GetTypeInfo(); + var innerConverter = GetConverter(property, propInfo, innerType, depth + 1); + return genericType.DeclaredConstructors.First().Invoke(new object[] { innerConverter }) as JsonConverter; + } + #endregion + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/DiscordErrorConverter.cs b/src/Discord.Net.Rest/Net/Converters/DiscordErrorConverter.cs new file mode 100644 index 0000000..9e42cff --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/DiscordErrorConverter.cs @@ -0,0 +1,88 @@ +using Discord.API; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Net.Converters +{ + internal class DiscordErrorConverter : JsonConverter + { + public static DiscordErrorConverter Instance + => new DiscordErrorConverter(); + + public override bool CanConvert(Type objectType) => objectType == typeof(DiscordError); + + public override bool CanRead => true; + public override bool CanWrite => false; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var obj = JObject.Load(reader); + var err = new API.DiscordError(); + + + var result = obj.GetValue("errors", StringComparison.OrdinalIgnoreCase); + result?.Parent.Remove(); + + // Populate the remaining properties. + using (var subReader = obj.CreateReader()) + { + serializer.Populate(subReader, err); + } + + if (result != null) + { + var innerReader = result.CreateReader(); + + var errors = ReadErrors(innerReader); + err.Errors = errors.ToArray(); + } + + return err; + } + + private List ReadErrors(JsonReader reader, string path = "") + { + List errs = new List(); + var obj = JObject.Load(reader); + var props = obj.Properties(); + foreach (var prop in props) + { + if (prop.Name == "_errors" && path == "") // root level error + { + errs.Add(new ErrorDetails() + { + Name = Optional.Unspecified, + Errors = prop.Value.ToObject() + }); + } + else if (prop.Name == "_errors") // path errors (not root level) + { + errs.Add(new ErrorDetails() + { + Name = path, + Errors = prop.Value.ToObject() + }); + } + else if (int.TryParse(prop.Name, out var i)) // array value + { + var r = prop.Value.CreateReader(); + errs.AddRange(ReadErrors(r, path + $"[{i}]")); + } + else // property name + { + var r = prop.Value.CreateReader(); + errs.AddRange(ReadErrors(r, path + $"{(path != "" ? "." : "")}{prop.Name[0].ToString().ToUpper() + new string(prop.Name.Skip(1).ToArray())}")); + } + } + + return errs; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => throw new NotImplementedException(); + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/EmbedTypeConverter.cs b/src/Discord.Net.Rest/Net/Converters/EmbedTypeConverter.cs new file mode 100644 index 0000000..93ef8c1 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/EmbedTypeConverter.cs @@ -0,0 +1,64 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.Net.Converters +{ + internal class EmbedTypeConverter : JsonConverter + { + public static readonly EmbedTypeConverter Instance = new EmbedTypeConverter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return (string)reader.Value switch + { + "rich" => EmbedType.Rich, + "link" => EmbedType.Link, + "video" => EmbedType.Video, + "image" => EmbedType.Image, + "gifv" => EmbedType.Gifv, + "article" => EmbedType.Article, + "tweet" => EmbedType.Tweet, + "html" => EmbedType.Html, + // TODO 2.2 EmbedType.News + _ => EmbedType.Unknown, + }; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + switch ((EmbedType)value) + { + case EmbedType.Rich: + writer.WriteValue("rich"); + break; + case EmbedType.Link: + writer.WriteValue("link"); + break; + case EmbedType.Video: + writer.WriteValue("video"); + break; + case EmbedType.Image: + writer.WriteValue("image"); + break; + case EmbedType.Gifv: + writer.WriteValue("gifv"); + break; + case EmbedType.Article: + writer.WriteValue("article"); + break; + case EmbedType.Tweet: + writer.WriteValue("tweet"); + break; + case EmbedType.Html: + writer.WriteValue("html"); + break; + default: + throw new JsonSerializationException("Invalid embed type"); + } + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs b/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs new file mode 100644 index 0000000..26884d4 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/GuildFeaturesConverter.cs @@ -0,0 +1,88 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Text; + +namespace Discord.Net.Converters +{ + internal class GuildFeaturesConverter : JsonConverter + { + public static GuildFeaturesConverter Instance + => new GuildFeaturesConverter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanWrite => false; + public override bool CanRead => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var obj = JToken.Load(reader); + var arr = obj.ToObject(); + + GuildFeature features = GuildFeature.None; + List experimental = new(); + + foreach (var item in arr) + { + if (Enum.TryParse(string.Concat(item.Split('_')), true, out var result)) + { + features |= result; + } + else + { + experimental.Add(item); + } + } + + return new GuildFeatures(features, experimental.ToArray()); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var guildFeatures = (GuildFeatures)value; + + var enumValues = Enum.GetValues(typeof(GuildFeature)); + + writer.WriteStartArray(); + + foreach (var enumValue in enumValues) + { + var val = (GuildFeature)enumValue; + if (val is GuildFeature.None) + continue; + + if (guildFeatures.Value.HasFlag(val)) + { + writer.WriteValue(FeatureToApiString(val)); + } + } + writer.WriteEndArray(); + } + + private string FeatureToApiString(GuildFeature feature) + { + var builder = new StringBuilder(); + var firstChar = true; + + foreach (var c in feature.ToString().ToCharArray()) + { + if (char.IsUpper(c)) + { + if (firstChar) + firstChar = false; + else + builder.Append("_"); + + builder.Append(c); + } + else + { + builder.Append(char.ToUpper(c)); + } + } + + return builder.ToString(); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/ImageConverter.cs b/src/Discord.Net.Rest/Net/Converters/ImageConverter.cs new file mode 100644 index 0000000..6f14868 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/ImageConverter.cs @@ -0,0 +1,54 @@ +using Newtonsoft.Json; +using System; +using System.IO; +using Model = Discord.API.Image; + +namespace Discord.Net.Converters +{ + internal class ImageConverter : JsonConverter + { + public static readonly ImageConverter Instance = new ImageConverter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + /// Cannot read from image. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new InvalidOperationException(); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + var image = (Model)value; + + if (image.Stream != null) + { + byte[] bytes; + int length; + if (image.Stream.CanSeek) + { + bytes = new byte[image.Stream.Length - image.Stream.Position]; + length = image.Stream.Read(bytes, 0, bytes.Length); + } + else + { + using (var cloneStream = new MemoryStream()) + { + image.Stream.CopyTo(cloneStream); + bytes = new byte[cloneStream.Length]; + cloneStream.Position = 0; + cloneStream.Read(bytes, 0, bytes.Length); + length = (int)cloneStream.Length; + } + } + + string base64 = Convert.ToBase64String(bytes, 0, length); + writer.WriteValue($"data:image/jpeg;base64,{base64}"); + } + else if (image.Hash != null) + writer.WriteValue(image.Hash); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs b/src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs new file mode 100644 index 0000000..4c4e344 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/InteractionConverter.cs @@ -0,0 +1,77 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; + +namespace Discord.Net.Converters +{ + internal class InteractionConverter : JsonConverter + { + public static InteractionConverter Instance => new InteractionConverter(); + + public override bool CanRead => true; + public override bool CanWrite => false; + public override bool CanConvert(Type objectType) => true; + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return null; + + var obj = JObject.Load(reader); + var interaction = new API.Interaction(); + + + // Remove the data property for manual deserialization + var result = obj.GetValue("data", StringComparison.OrdinalIgnoreCase); + result?.Parent.Remove(); + + // Populate the remaining properties. + using (var subReader = obj.CreateReader()) + { + serializer.Populate(subReader, interaction); + } + + // Process the Result property + if (result != null) + { + switch (interaction.Type) + { + case InteractionType.ApplicationCommand: + { + var appCommandData = new API.ApplicationCommandInteractionData(); + serializer.Populate(result.CreateReader(), appCommandData); + interaction.Data = appCommandData; + } + break; + case InteractionType.MessageComponent: + { + var messageComponent = new API.MessageComponentInteractionData(); + serializer.Populate(result.CreateReader(), messageComponent); + interaction.Data = messageComponent; + } + break; + case InteractionType.ApplicationCommandAutocomplete: + { + var autocompleteData = new API.AutocompleteInteractionData(); + serializer.Populate(result.CreateReader(), autocompleteData); + interaction.Data = autocompleteData; + } + break; + case InteractionType.ModalSubmit: + { + var modalData = new API.ModalInteractionData(); + serializer.Populate(result.CreateReader(), modalData); + interaction.Data = modalData; + } + break; + } + } + else + interaction.Data = Optional.Unspecified; + + return interaction; + } + + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => throw new NotImplementedException(); + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs b/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs new file mode 100644 index 0000000..7888219 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs @@ -0,0 +1,47 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; + +namespace Discord.Net.Converters +{ + internal class MessageComponentConverter : JsonConverter + { + public static MessageComponentConverter Instance => new MessageComponentConverter(); + + public override bool CanRead => true; + public override bool CanWrite => false; + public override bool CanConvert(Type objectType) => true; + public override void WriteJson(JsonWriter writer, + object value, JsonSerializer serializer) + { + serializer.Serialize(writer, value); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var jsonObject = JObject.Load(reader); + var messageComponent = default(IMessageComponent); + switch ((ComponentType)jsonObject["type"].Value()) + { + case ComponentType.ActionRow: + messageComponent = new API.ActionRowComponent(); + break; + case ComponentType.Button: + messageComponent = new API.ButtonComponent(); + break; + case ComponentType.SelectMenu: + case ComponentType.ChannelSelect: + case ComponentType.MentionableSelect: + case ComponentType.RoleSelect: + case ComponentType.UserSelect: + messageComponent = new API.SelectMenuComponent(); + break; + case ComponentType.TextInput: + messageComponent = new API.TextInputComponent(); + break; + } + serializer.Populate(jsonObject.CreateReader(), messageComponent); + return messageComponent; + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/NullableConverter.cs b/src/Discord.Net.Rest/Net/Converters/NullableConverter.cs new file mode 100644 index 0000000..ddbe21d --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/NullableConverter.cs @@ -0,0 +1,50 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.Net.Converters +{ + internal class NullableConverter : JsonConverter + where T : struct + { + private readonly JsonConverter _innerConverter; + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public NullableConverter(JsonConverter innerConverter) + { + _innerConverter = innerConverter; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + object value = reader.Value; + if (value == null) + return null; + else + { + T obj; + if (_innerConverter != null) + obj = (T)_innerConverter.ReadJson(reader, typeof(T), null, serializer); + else + obj = serializer.Deserialize(reader); + return obj; + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value == null) + writer.WriteNull(); + else + { + var nullable = (T?)value; + if (_innerConverter != null) + _innerConverter.WriteJson(writer, nullable.Value, serializer); + else + serializer.Serialize(writer, nullable.Value, typeof(T)); + } + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/OptionalConverter.cs b/src/Discord.Net.Rest/Net/Converters/OptionalConverter.cs new file mode 100644 index 0000000..d3d6191 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/OptionalConverter.cs @@ -0,0 +1,46 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.Net.Converters +{ + internal class OptionalConverter : JsonConverter + { + private readonly JsonConverter _innerConverter; + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public OptionalConverter(JsonConverter innerConverter) + { + _innerConverter = innerConverter; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + T obj; + // custom converters need to be able to safely fail; move this check in here to prevent wasteful casting when parsing primitives + if (_innerConverter != null) + { + object o = _innerConverter.ReadJson(reader, typeof(T), null, serializer); + if (o is Optional) + return o; + + obj = (T)o; + } + else + obj = serializer.Deserialize(reader); + + return new Optional(obj); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + value = ((Optional)value).Value; + if (_innerConverter != null) + _innerConverter.WriteJson(writer, value, serializer); + else + serializer.Serialize(writer, value, typeof(T)); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/SelectMenuDefaultValueTypeConverter.cs b/src/Discord.Net.Rest/Net/Converters/SelectMenuDefaultValueTypeConverter.cs new file mode 100644 index 0000000..32212fe --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/SelectMenuDefaultValueTypeConverter.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; + +using System; +using System.Globalization; + +namespace Discord.Net.Converters; + +internal class SelectMenuDefaultValueTypeConverter : JsonConverter +{ + public static readonly SelectMenuDefaultValueTypeConverter Instance = new (); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return Enum.TryParse((string)reader.Value, true, out var result) + ? result + : null; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(((SelectDefaultValueType)value).ToString().ToLower(CultureInfo.InvariantCulture)); + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/StringEntityConverter.cs b/src/Discord.Net.Rest/Net/Converters/StringEntityConverter.cs new file mode 100644 index 0000000..b78d351 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/StringEntityConverter.cs @@ -0,0 +1,27 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.Net.Converters +{ + internal class StringEntityConverter : JsonConverter + { + public static readonly StringEntityConverter Instance = new StringEntityConverter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => false; + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new InvalidOperationException(); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value != null) + writer.WriteValue((value as IEntity).Id); + else + writer.WriteNull(); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/UInt64Converter.cs b/src/Discord.Net.Rest/Net/Converters/UInt64Converter.cs new file mode 100644 index 0000000..d7655a3 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/UInt64Converter.cs @@ -0,0 +1,25 @@ +using Newtonsoft.Json; +using System; +using System.Globalization; + +namespace Discord.Net.Converters +{ + internal class UInt64Converter : JsonConverter + { + public static readonly UInt64Converter Instance = new UInt64Converter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return ulong.Parse(reader.Value?.ToString(), NumberStyles.None, CultureInfo.InvariantCulture); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(((ulong)value).ToString(CultureInfo.InvariantCulture)); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/UInt64EntityConverter.cs b/src/Discord.Net.Rest/Net/Converters/UInt64EntityConverter.cs new file mode 100644 index 0000000..25c7767 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/UInt64EntityConverter.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using System; +using System.Globalization; + +namespace Discord.Net.Converters +{ + internal class UInt64EntityConverter : JsonConverter + { + public static readonly UInt64EntityConverter Instance = new UInt64EntityConverter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => false; + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + throw new InvalidOperationException(); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value != null) + writer.WriteValue((value as IEntity).Id.ToString(CultureInfo.InvariantCulture)); + else + writer.WriteNull(); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/UInt64EntityOrIdConverter.cs b/src/Discord.Net.Rest/Net/Converters/UInt64EntityOrIdConverter.cs new file mode 100644 index 0000000..e555348 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/UInt64EntityOrIdConverter.cs @@ -0,0 +1,43 @@ +using Discord.API; +using Newtonsoft.Json; +using System; +using System.Globalization; + +namespace Discord.Net.Converters +{ + internal class UInt64EntityOrIdConverter : JsonConverter + { + private readonly JsonConverter _innerConverter; + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => false; + + public UInt64EntityOrIdConverter(JsonConverter innerConverter) + { + _innerConverter = innerConverter; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + switch (reader.TokenType) + { + case JsonToken.String: + case JsonToken.Integer: + return new EntityOrId(ulong.Parse(reader.ReadAsString(), NumberStyles.None, CultureInfo.InvariantCulture)); + default: + T obj; + if (_innerConverter != null) + obj = (T)_innerConverter.ReadJson(reader, typeof(T), null, serializer); + else + obj = serializer.Deserialize(reader); + return new EntityOrId(obj); + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException(); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/UnixTimestampConverter.cs b/src/Discord.Net.Rest/Net/Converters/UnixTimestampConverter.cs new file mode 100644 index 0000000..858d111 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/UnixTimestampConverter.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.Net.Converters +{ + public class UnixTimestampConverter : JsonConverter + { + public static readonly UnixTimestampConverter Instance = new UnixTimestampConverter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + // 1e13 unix ms = year 2286 + // necessary to prevent discord.js from sending values in the e15 and overflowing a DTO + private const long MaxSaneMs = 1_000_000_000_000_0; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + // Discord doesn't validate if timestamps contain decimals or not, and they also don't validate if timestamps are reasonably sized + if (reader.Value is double d && d < MaxSaneMs) + return new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero).AddMilliseconds(d); + else if (reader.Value is long l && l < MaxSaneMs) + return new DateTimeOffset(1970, 1, 1, 0, 0, 0, 0, TimeSpan.Zero).AddMilliseconds(l); + return Optional.Unspecified; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(((DateTimeOffset)value).ToString("O")); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Converters/UserStatusConverter.cs b/src/Discord.Net.Rest/Net/Converters/UserStatusConverter.cs new file mode 100644 index 0000000..8a13e79 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/UserStatusConverter.cs @@ -0,0 +1,52 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.Net.Converters +{ + internal class UserStatusConverter : JsonConverter + { + public static readonly UserStatusConverter Instance = new UserStatusConverter(); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + return (string)reader.Value switch + { + "online" => UserStatus.Online, + "idle" => UserStatus.Idle, + "dnd" => UserStatus.DoNotDisturb, + "invisible" => UserStatus.Invisible,//Should never happen + "offline" => UserStatus.Offline, + _ => throw new JsonSerializationException("Unknown user status"), + }; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + switch ((UserStatus)value) + { + case UserStatus.Online: + writer.WriteValue("online"); + break; + case UserStatus.Idle: + case UserStatus.AFK: + writer.WriteValue("idle"); + break; + case UserStatus.DoNotDisturb: + writer.WriteValue("dnd"); + break; + case UserStatus.Invisible: + writer.WriteValue("invisible"); + break; + case UserStatus.Offline: + writer.WriteValue("offline"); + break; + default: + throw new JsonSerializationException("Invalid user status"); + } + } + } +} diff --git a/src/Discord.Net.Rest/Net/DefaultRestClient.cs b/src/Discord.Net.Rest/Net/DefaultRestClient.cs new file mode 100644 index 0000000..7142ad0 --- /dev/null +++ b/src/Discord.Net.Rest/Net/DefaultRestClient.cs @@ -0,0 +1,196 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Rest +{ + internal sealed class DefaultRestClient : IRestClient, IDisposable + { + private const int HR_SECURECHANNELFAILED = -2146233079; + + private readonly HttpClient _client; + private readonly string _baseUrl; + private readonly JsonSerializer _errorDeserializer; + private CancellationToken _cancelToken; + private bool _isDisposed; + + public DefaultRestClient(string baseUrl, bool useProxy = false) + { + _baseUrl = baseUrl; + +#pragma warning disable IDISP014 + _client = new HttpClient(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + UseCookies = false, + UseProxy = useProxy, + }); +#pragma warning restore IDISP014 + SetHeader("accept-encoding", "gzip, deflate"); + + _cancelToken = CancellationToken.None; + _errorDeserializer = new JsonSerializer(); + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + _client.Dispose(); + _isDisposed = true; + } + } + public void Dispose() + { + Dispose(true); + } + + public void SetHeader(string key, string value) + { + _client.DefaultRequestHeaders.Remove(key); + if (value != null) + _client.DefaultRequestHeaders.Add(key, value); + } + public void SetCancelToken(CancellationToken cancelToken) + { + _cancelToken = cancelToken; + } + + public async Task SendAsync(string method, string endpoint, CancellationToken cancelToken, bool headerOnly, string reason = null, + IEnumerable>> requestHeaders = null) + { + string uri = Path.Combine(_baseUrl, endpoint); + using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) + { + if (reason != null) + restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); + if (requestHeaders != null) + foreach (var header in requestHeaders) + restRequest.Headers.Add(header.Key, header.Value); + return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); + } + } + public async Task SendAsync(string method, string endpoint, string json, CancellationToken cancelToken, bool headerOnly, string reason = null, + IEnumerable>> requestHeaders = null) + { + string uri = Path.Combine(_baseUrl, endpoint); + using (var restRequest = new HttpRequestMessage(GetMethod(method), uri)) + { + if (reason != null) + restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); + if (requestHeaders != null) + foreach (var header in requestHeaders) + restRequest.Headers.Add(header.Key, header.Value); + restRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); + return await SendInternalAsync(restRequest, cancelToken, headerOnly).ConfigureAwait(false); + } + } + + /// Unsupported param type. + public Task SendAsync(string method, string endpoint, IReadOnlyDictionary multipartParams, CancellationToken cancelToken, bool headerOnly, string reason = null, + IEnumerable>> requestHeaders = null) + { + string uri = Path.Combine(_baseUrl, endpoint); + + // HttpRequestMessage implements IDisposable but we do not need to dispose it as it merely disposes of its Content property, + // which we can do as needed. And regarding that, we do not want to take responsibility for disposing of content provided by + // the caller of this function, since it's possible that the caller wants to reuse it or is forced to reuse it because of a + // 429 response. Therefore, by convention, we only dispose the content objects created in this function (if any). + // + // See this comment explaining why this is safe: https://github.com/aspnet/Security/issues/886#issuecomment-229181249 + // See also the source for HttpRequestMessage: https://github.com/microsoft/referencesource/blob/master/System/net/System/Net/Http/HttpRequestMessage.cs +#pragma warning disable IDISP004 + var restRequest = new HttpRequestMessage(GetMethod(method), uri); +#pragma warning restore IDISP004 + + if (reason != null) + restRequest.Headers.Add("X-Audit-Log-Reason", Uri.EscapeDataString(reason)); + if (requestHeaders != null) + foreach (var header in requestHeaders) + restRequest.Headers.Add(header.Key, header.Value); + var content = new MultipartFormDataContent("Upload----" + DateTime.Now.ToString(CultureInfo.InvariantCulture)); + + static StreamContent GetStreamContent(Stream stream) + { + if (stream.CanSeek) + { + // Reset back to the beginning; it may have been used elsewhere or in a previous request. + stream.Position = 0; + } + +#pragma warning disable IDISP004 + return new StreamContent(stream); +#pragma warning restore IDISP004 + } + + foreach (var p in multipartParams ?? ImmutableDictionary.Empty) + { + switch (p.Value) + { +#pragma warning disable IDISP004 + case string stringValue: + { content.Add(new StringContent(stringValue, Encoding.UTF8, "text/plain"), p.Key); continue; } + case byte[] byteArrayValue: + { content.Add(new ByteArrayContent(byteArrayValue), p.Key); continue; } + case Stream streamValue: + { content.Add(GetStreamContent(streamValue), p.Key); continue; } + case MultipartFile fileValue: + { + var streamContent = GetStreamContent(fileValue.Stream); + + if (fileValue.ContentType != null) + streamContent.Headers.ContentType = new MediaTypeHeaderValue(fileValue.ContentType); + + content.Add(streamContent, p.Key, fileValue.Filename); +#pragma warning restore IDISP004 + + continue; + } + default: + throw new InvalidOperationException($"Unsupported param type \"{p.Value.GetType().Name}\"."); + } + } + + restRequest.Content = content; + return SendInternalAsync(restRequest, cancelToken, headerOnly); + } + + private async Task SendInternalAsync(HttpRequestMessage request, CancellationToken cancelToken, bool headerOnly) + { + using (var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_cancelToken, cancelToken)) + { + cancelToken = cancelTokenSource.Token; + HttpResponseMessage response = await _client.SendAsync(request, cancelToken).ConfigureAwait(false); + + var headers = response.Headers.ToDictionary(x => x.Key, x => x.Value.FirstOrDefault(), StringComparer.OrdinalIgnoreCase); + var stream = (!headerOnly || !response.IsSuccessStatusCode) ? await response.Content.ReadAsStreamAsync().ConfigureAwait(false) : null; + + return new RestResponse(response.StatusCode, headers, stream); + } + } + + private static readonly HttpMethod Patch = new HttpMethod("PATCH"); + private HttpMethod GetMethod(string method) + { + return method switch + { + "DELETE" => HttpMethod.Delete, + "GET" => HttpMethod.Get, + "PATCH" => Patch, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + _ => throw new ArgumentOutOfRangeException(nameof(method), $"Unknown HttpMethod: {method}"), + }; + } + } +} diff --git a/src/Discord.Net.Rest/Net/DefaultRestClientProvider.cs b/src/Discord.Net.Rest/Net/DefaultRestClientProvider.cs new file mode 100644 index 0000000..67b4709 --- /dev/null +++ b/src/Discord.Net.Rest/Net/DefaultRestClientProvider.cs @@ -0,0 +1,25 @@ +using System; + +namespace Discord.Net.Rest +{ + public static class DefaultRestClientProvider + { + public static readonly RestClientProvider Instance = Create(); + + /// The default RestClientProvider is not supported on this platform. + public static RestClientProvider Create(bool useProxy = false) + { + return url => + { + try + { + return new DefaultRestClient(url, useProxy); + } + catch (PlatformNotSupportedException ex) + { + throw new PlatformNotSupportedException("The default RestClientProvider is not supported on this platform.", ex); + } + }; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Array16.cs b/src/Discord.Net.Rest/Net/ED25519/Array16.cs new file mode 100644 index 0000000..fca8616 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Array16.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Net.ED25519 +{ + // Array16 Salsa20 state + // Array16 SHA-512 block + internal struct Array16 + { + public T x0; + public T x1; + public T x2; + public T x3; + public T x4; + public T x5; + public T x6; + public T x7; + public T x8; + public T x9; + public T x10; + public T x11; + public T x12; + public T x13; + public T x14; + public T x15; + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Array8.cs b/src/Discord.Net.Rest/Net/ED25519/Array8.cs new file mode 100644 index 0000000..b563ac2 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Array8.cs @@ -0,0 +1,18 @@ +using System; + +namespace Discord.Net.ED25519 +{ + // Array8 Poly1305 key + // Array8 SHA-512 state/output + internal struct Array8 + { + public T x0; + public T x1; + public T x2; + public T x3; + public T x4; + public T x5; + public T x6; + public T x7; + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/ByteIntegerConverter.cs b/src/Discord.Net.Rest/Net/ED25519/ByteIntegerConverter.cs new file mode 100644 index 0000000..40c7624 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/ByteIntegerConverter.cs @@ -0,0 +1,55 @@ +using System; + +namespace Discord.Net.ED25519 +{ + // Loops? Arrays? Never heard of that stuff + // Library avoids unnecessary heap allocations and unsafe code + // so this ugly code becomes necessary :( + internal static class ByteIntegerConverter + { + public static ulong LoadBigEndian64(byte[] buf, int offset) + { + return + (ulong)(buf[offset + 7]) + | (((ulong)(buf[offset + 6])) << 8) + | (((ulong)(buf[offset + 5])) << 16) + | (((ulong)(buf[offset + 4])) << 24) + | (((ulong)(buf[offset + 3])) << 32) + | (((ulong)(buf[offset + 2])) << 40) + | (((ulong)(buf[offset + 1])) << 48) + | (((ulong)(buf[offset + 0])) << 56); + } + + public static void StoreBigEndian64(byte[] buf, int offset, ulong value) + { + buf[offset + 7] = unchecked((byte)value); + buf[offset + 6] = unchecked((byte)(value >> 8)); + buf[offset + 5] = unchecked((byte)(value >> 16)); + buf[offset + 4] = unchecked((byte)(value >> 24)); + buf[offset + 3] = unchecked((byte)(value >> 32)); + buf[offset + 2] = unchecked((byte)(value >> 40)); + buf[offset + 1] = unchecked((byte)(value >> 48)); + buf[offset + 0] = unchecked((byte)(value >> 56)); + } + + public static void Array16LoadBigEndian64(out Array16 output, byte[] input, int inputOffset) + { + output.x0 = LoadBigEndian64(input, inputOffset + 0); + output.x1 = LoadBigEndian64(input, inputOffset + 8); + output.x2 = LoadBigEndian64(input, inputOffset + 16); + output.x3 = LoadBigEndian64(input, inputOffset + 24); + output.x4 = LoadBigEndian64(input, inputOffset + 32); + output.x5 = LoadBigEndian64(input, inputOffset + 40); + output.x6 = LoadBigEndian64(input, inputOffset + 48); + output.x7 = LoadBigEndian64(input, inputOffset + 56); + output.x8 = LoadBigEndian64(input, inputOffset + 64); + output.x9 = LoadBigEndian64(input, inputOffset + 72); + output.x10 = LoadBigEndian64(input, inputOffset + 80); + output.x11 = LoadBigEndian64(input, inputOffset + 88); + output.x12 = LoadBigEndian64(input, inputOffset + 96); + output.x13 = LoadBigEndian64(input, inputOffset + 104); + output.x14 = LoadBigEndian64(input, inputOffset + 112); + output.x15 = LoadBigEndian64(input, inputOffset + 120); + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/CryptoBytes.cs b/src/Discord.Net.Rest/Net/ED25519/CryptoBytes.cs new file mode 100644 index 0000000..2ca0644 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/CryptoBytes.cs @@ -0,0 +1,272 @@ +using System; +using System.Linq; +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace Discord.Net.ED25519 +{ + internal class CryptoBytes + { + /// + /// Comparison of two arrays. + /// + /// The runtime of this method does not depend on the contents of the arrays. Using constant time + /// prevents timing attacks that allow an attacker to learn if the arrays have a common prefix. + /// + /// It is important to use such a constant time comparison when verifying MACs. + /// + /// Byte array + /// Byte array + /// True if arrays are equal + public static bool ConstantTimeEquals(byte[] x, byte[] y) + { + if (x.Length != y.Length) + return false; + return InternalConstantTimeEquals(x, 0, y, 0, x.Length) != 0; + } + + /// + /// Comparison of two array segments. + /// + /// The runtime of this method does not depend on the contents of the arrays. Using constant time + /// prevents timing attacks that allow an attacker to learn if the arrays have a common prefix. + /// + /// It is important to use such a constant time comparison when verifying MACs. + /// + /// Byte array segment + /// Byte array segment + /// True if contents of x and y are equal + public static bool ConstantTimeEquals(ArraySegment x, ArraySegment y) + { + if (x.Count != y.Count) + return false; + return InternalConstantTimeEquals(x.Array, x.Offset, y.Array, y.Offset, x.Count) != 0; + } + + /// + /// Comparison of two byte sequences. + /// + /// The runtime of this method does not depend on the contents of the arrays. Using constant time + /// prevents timing attacks that allow an attacker to learn if the arrays have a common prefix. + /// + /// It is important to use such a constant time comparison when verifying MACs. + /// + /// Byte array + /// Offset of byte sequence in the x array + /// Byte array + /// Offset of byte sequence in the y array + /// Length of byte sequence + /// True if sequences are equal + public static bool ConstantTimeEquals(byte[] x, int xOffset, byte[] y, int yOffset, int length) + { + return InternalConstantTimeEquals(x, xOffset, y, yOffset, length) != 0; + } + + private static uint InternalConstantTimeEquals(byte[] x, int xOffset, byte[] y, int yOffset, int length) + { + int differentbits = 0; + for (int i = 0; i < length; i++) + differentbits |= x[xOffset + i] ^ y[yOffset + i]; + return (1 & (unchecked((uint)differentbits - 1) >> 8)); + } + + /// + /// Overwrites the contents of the array, wiping the previous content. + /// + /// Byte array + public static void Wipe(byte[] data) + { + InternalWipe(data, 0, data.Length); + } + + /// + /// Overwrites the contents of the array, wiping the previous content. + /// + /// Byte array + /// Index of byte sequence + /// Length of byte sequence + public static void Wipe(byte[] data, int offset, int length) + { + InternalWipe(data, offset, length); + } + + /// + /// Overwrites the contents of the array segment, wiping the previous content. + /// + /// Byte array segment + public static void Wipe(ArraySegment data) + { + InternalWipe(data.Array, data.Offset, data.Count); + } + + // Secure wiping is hard + // * the GC can move around and copy memory + // Perhaps this can be avoided by using unmanaged memory or by fixing the position of the array in memory + // * Swap files and error dumps can contain secret information + // It seems possible to lock memory in RAM, no idea about error dumps + // * Compiler could optimize out the wiping if it knows that data won't be read back + // I hope this is enough, suppressing inlining + // but perhaps `RtlSecureZeroMemory` is needed + [MethodImpl(MethodImplOptions.NoInlining)] + internal static void InternalWipe(byte[] data, int offset, int count) + { + Array.Clear(data, offset, count); + } + + // shallow wipe of structs + [MethodImpl(MethodImplOptions.NoInlining)] + internal static void InternalWipe(ref T data) + where T : struct + { + data = default(T); + } + + /// + /// Constant-time conversion of the bytes array to an upper-case hex string. + /// Please see http://stackoverflow.com/a/14333437/445517 for the detailed explanation + /// + /// Byte array + /// Hex representation of byte array + public static string ToHexStringUpper(byte[] data) + { + if (data == null) + return null; + char[] c = new char[data.Length * 2]; + int b; + for (int i = 0; i < data.Length; i++) + { + b = data[i] >> 4; + c[i * 2] = (char)(55 + b + (((b - 10) >> 31) & -7)); + b = data[i] & 0xF; + c[i * 2 + 1] = (char)(55 + b + (((b - 10) >> 31) & -7)); + } + return new string(c); + } + + /// + /// Constant-time conversion of the bytes array to an lower-case hex string. + /// Please see http://stackoverflow.com/a/14333437/445517 for the detailed explanation. + /// + /// Byte array + /// Hex representation of byte array + public static string ToHexStringLower(byte[] data) + { + if (data == null) + return null; + char[] c = new char[data.Length * 2]; + int b; + for (int i = 0; i < data.Length; i++) + { + b = data[i] >> 4; + c[i * 2] = (char)(87 + b + (((b - 10) >> 31) & -39)); + b = data[i] & 0xF; + c[i * 2 + 1] = (char)(87 + b + (((b - 10) >> 31) & -39)); + } + return new string(c); + } + + /// + /// Converts the hex string to bytes. Case insensitive. + /// + /// Hex encoded byte sequence + /// Byte array + public static byte[] FromHexString(string hexString) + { + if (hexString == null) + return null; + if (hexString.Length % 2 != 0) + throw new FormatException("The hex string is invalid because it has an odd length"); + var result = new byte[hexString.Length / 2]; + for (int i = 0; i < result.Length; i++) + result[i] = Convert.ToByte(hexString.Substring(i * 2, 2), 16); + return result; + } + + /// + /// Encodes the bytes with the Base64 encoding. + /// More compact than hex, but it is case-sensitive and uses the special characters `+`, `/` and `=`. + /// + /// Byte array + /// Base 64 encoded data + public static string ToBase64String(byte[] data) + { + if (data == null) + return null; + return Convert.ToBase64String(data); + } + + /// + /// Decodes a Base64 encoded string back to bytes. + /// + /// Base 64 encoded data + /// Byte array + public static byte[] FromBase64String(string base64String) + { + if (base64String == null) + return null; + return Convert.FromBase64String(base64String); + } + + private const string strDigits = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + /// + /// Encode a byte sequence as a base58-encoded string + /// + /// Byte sequence + /// Encoding result + public static string Base58Encode(byte[] input) + { + // Decode byte[] to BigInteger + BigInteger intData = 0; + for (int i = 0; i < input.Length; i++) + { + intData = intData * 256 + input[i]; + } + + // Encode BigInteger to Base58 string + string result = ""; + while (intData > 0) + { + int remainder = (int)(intData % 58); + intData /= 58; + result = strDigits[remainder] + result; + } + + // Append `1` for each leading 0 byte + for (int i = 0; i < input.Length && input[i] == 0; i++) + { + result = '1' + result; + } + return result; + } + + /// + /// // Decode a base58-encoded string into byte array + /// + /// Base58 data string + /// Byte array + public static byte[] Base58Decode(string input) + { + // Decode Base58 string to BigInteger + BigInteger intData = 0; + for (int i = 0; i < input.Length; i++) + { + int digit = strDigits.IndexOf(input[i]); //Slow + if (digit < 0) + throw new FormatException(string.Format("Invalid Base58 character `{0}` at position {1}", input[i], i)); + intData = intData * 58 + digit; + } + + // Encode BigInteger to byte[] + // Leading zero bytes get encoded as leading `1` characters + int leadingZeroCount = input.TakeWhile(c => c == '1').Count(); + var leadingZeros = Enumerable.Repeat((byte)0, leadingZeroCount); + var bytesWithoutLeadingZeros = + intData.ToByteArray() + .Reverse()// to big endian + .SkipWhile(b => b == 0);//strip sign byte + var result = leadingZeros.Concat(bytesWithoutLeadingZeros).ToArray(); + return result; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519.cs new file mode 100644 index 0000000..109620e --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Net.ED25519 +{ + internal static class Ed25519 + { + /// + /// Public Keys are 32 byte values. All possible values of this size a valid. + /// + public const int PublicKeySize = 32; + /// + /// Signatures are 64 byte values + /// + public const int SignatureSize = 64; + /// + /// Private key seeds are 32 byte arbitrary values. This is the form that should be generated and stored. + /// + public const int PrivateKeySeedSize = 32; + /// + /// A 64 byte expanded form of private key. This form is used internally to improve performance + /// + public const int ExpandedPrivateKeySize = 32 * 2; + + /// + /// Verify Ed25519 signature + /// + /// Signature bytes + /// Message + /// Public key + /// True if signature is valid, false if it's not + public static bool Verify(ArraySegment signature, ArraySegment message, ArraySegment publicKey) + { + if (signature.Count != SignatureSize) + throw new ArgumentException($"Sizeof signature doesnt match defined size of {SignatureSize}"); + + if (publicKey.Count != PublicKeySize) + throw new ArgumentException($"Sizeof public key doesnt match defined size of {PublicKeySize}"); + + return Ed25519Operations.crypto_sign_verify(signature.Array, signature.Offset, message.Array, message.Offset, message.Count, publicKey.Array, publicKey.Offset); + } + + /// + /// Verify Ed25519 signature + /// + /// Signature bytes + /// Message + /// Public key + /// True if signature is valid, false if it's not + public static bool Verify(byte[] signature, byte[] message, byte[] publicKey) + { + Preconditions.NotNull(signature, nameof(signature)); + Preconditions.NotNull(message, nameof(message)); + Preconditions.NotNull(publicKey, nameof(publicKey)); + if (signature.Length != SignatureSize) + throw new ArgumentException($"Sizeof signature doesnt match defined size of {SignatureSize}"); + + if (publicKey.Length != PublicKeySize) + throw new ArgumentException($"Sizeof public key doesnt match defined size of {PublicKeySize}"); + + return Ed25519Operations.crypto_sign_verify(signature, 0, message, 0, message.Length, publicKey, 0); + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Operations.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Operations.cs new file mode 100644 index 0000000..4d5ece1 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Operations.cs @@ -0,0 +1,45 @@ +using Discord.Net.ED25519.Ed25519Ref10; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Net.ED25519 +{ + internal class Ed25519Operations + { + public static bool crypto_sign_verify( + byte[] sig, int sigoffset, + byte[] m, int moffset, int mlen, + byte[] pk, int pkoffset) + { + byte[] h; + byte[] checkr = new byte[32]; + GroupElementP3 A; + GroupElementP2 R; + + if ((sig[sigoffset + 63] & 224) != 0) + return false; + if (GroupOperations.ge_frombytes_negate_vartime(out A, pk, pkoffset) != 0) + return false; + + var hasher = new Sha512(); + hasher.Update(sig, sigoffset, 32); + hasher.Update(pk, pkoffset, 32); + hasher.Update(m, moffset, mlen); + h = hasher.Finalize(); + + ScalarOperations.sc_reduce(h); + + var sm32 = new byte[32]; + Array.Copy(sig, sigoffset + 32, sm32, 0, 32); + GroupOperations.ge_double_scalarmult_vartime(out R, h, ref A, sm32); + GroupOperations.ge_tobytes(checkr, 0, ref R); + var result = CryptoBytes.ConstantTimeEquals(checkr, 0, sig, sigoffset, 32); + CryptoBytes.Wipe(h); + CryptoBytes.Wipe(checkr); + return result; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/FieldElement.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/FieldElement.cs new file mode 100644 index 0000000..d612ff5 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/FieldElement.cs @@ -0,0 +1,23 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal struct FieldElement + { + internal int x0, x1, x2, x3, x4, x5, x6, x7, x8, x9; + + internal FieldElement(params int[] elements) + { + x0 = elements[0]; + x1 = elements[1]; + x2 = elements[2]; + x3 = elements[3]; + x4 = elements[4]; + x5 = elements[5]; + x6 = elements[6]; + x7 = elements[7]; + x8 = elements[8]; + x9 = elements[9]; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/GroupElement.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/GroupElement.cs new file mode 100644 index 0000000..703d6ed --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/GroupElement.cs @@ -0,0 +1,63 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + /* + ge means group element. + + Here the group is the set of pairs (x,y) of field elements (see fe.h) + satisfying -x^2 + y^2 = 1 + d x^2y^2 + where d = -121665/121666. + + Representations: + ge_p2 (projective): (X:Y:Z) satisfying x=X/Z, y=Y/Z + ge_p3 (extended): (X:Y:Z:T) satisfying x=X/Z, y=Y/Z, XY=ZT + ge_p1p1 (completed): ((X:Z),(Y:T)) satisfying x=X/Z, y=Y/T + ge_precomp (Duif): (y+x,y-x,2dxy) + */ + + internal struct GroupElementP2 + { + public FieldElement X; + public FieldElement Y; + public FieldElement Z; + }; + + internal struct GroupElementP3 + { + public FieldElement X; + public FieldElement Y; + public FieldElement Z; + public FieldElement T; + }; + + internal struct GroupElementP1P1 + { + public FieldElement X; + public FieldElement Y; + public FieldElement Z; + public FieldElement T; + }; + + internal struct GroupElementPreComp + { + public FieldElement yplusx; + public FieldElement yminusx; + public FieldElement xy2d; + + public GroupElementPreComp(FieldElement yplusx, FieldElement yminusx, FieldElement xy2d) + { + this.yplusx = yplusx; + this.yminusx = yminusx; + this.xy2d = xy2d; + } + }; + + internal struct GroupElementCached + { + public FieldElement YplusX; + public FieldElement YminusX; + public FieldElement Z; + public FieldElement T2d; + }; +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/base.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/base.cs new file mode 100644 index 0000000..2a25504 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/base.cs @@ -0,0 +1,1355 @@ +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class LookupTables + { + /* base[i][j] = (j+1)*256^i*B */ + //32*8 + internal static GroupElementPreComp[][] Base = new GroupElementPreComp[][] + { + new[]{ + new GroupElementPreComp( + new FieldElement( 25967493,-14356035,29566456,3660896,-12694345,4014787,27544626,-11754271,-6079156,2047605 ), + new FieldElement( -12545711,934262,-2722910,3049990,-727428,9406986,12720692,5043384,19500929,-15469378 ), + new FieldElement( -8738181,4489570,9688441,-14785194,10184609,-12363380,29287919,11864899,-24514362,-4438546 ) + ), + new GroupElementPreComp( + new FieldElement( -12815894,-12976347,-21581243,11784320,-25355658,-2750717,-11717903,-3814571,-358445,-10211303 ), + new FieldElement( -21703237,6903825,27185491,6451973,-29577724,-9554005,-15616551,11189268,-26829678,-5319081 ), + new FieldElement( 26966642,11152617,32442495,15396054,14353839,-12752335,-3128826,-9541118,-15472047,-4166697 ) + ), + new GroupElementPreComp( + new FieldElement( 15636291,-9688557,24204773,-7912398,616977,-16685262,27787600,-14772189,28944400,-1550024 ), + new FieldElement( 16568933,4717097,-11556148,-1102322,15682896,-11807043,16354577,-11775962,7689662,11199574 ), + new FieldElement( 30464156,-5976125,-11779434,-15670865,23220365,15915852,7512774,10017326,-17749093,-9920357 ) + ), + new GroupElementPreComp( + new FieldElement( -17036878,13921892,10945806,-6033431,27105052,-16084379,-28926210,15006023,3284568,-6276540 ), + new FieldElement( 23599295,-8306047,-11193664,-7687416,13236774,10506355,7464579,9656445,13059162,10374397 ), + new FieldElement( 7798556,16710257,3033922,2874086,28997861,2835604,32406664,-3839045,-641708,-101325 ) + ), + new GroupElementPreComp( + new FieldElement( 10861363,11473154,27284546,1981175,-30064349,12577861,32867885,14515107,-15438304,10819380 ), + new FieldElement( 4708026,6336745,20377586,9066809,-11272109,6594696,-25653668,12483688,-12668491,5581306 ), + new FieldElement( 19563160,16186464,-29386857,4097519,10237984,-4348115,28542350,13850243,-23678021,-15815942 ) + ), + new GroupElementPreComp( + new FieldElement( -15371964,-12862754,32573250,4720197,-26436522,5875511,-19188627,-15224819,-9818940,-12085777 ), + new FieldElement( -8549212,109983,15149363,2178705,22900618,4543417,3044240,-15689887,1762328,14866737 ), + new FieldElement( -18199695,-15951423,-10473290,1707278,-17185920,3916101,-28236412,3959421,27914454,4383652 ) + ), + new GroupElementPreComp( + new FieldElement( 5153746,9909285,1723747,-2777874,30523605,5516873,19480852,5230134,-23952439,-15175766 ), + new FieldElement( -30269007,-3463509,7665486,10083793,28475525,1649722,20654025,16520125,30598449,7715701 ), + new FieldElement( 28881845,14381568,9657904,3680757,-20181635,7843316,-31400660,1370708,29794553,-1409300 ) + ), + new GroupElementPreComp( + new FieldElement( 14499471,-2729599,-33191113,-4254652,28494862,14271267,30290735,10876454,-33154098,2381726 ), + new FieldElement( -7195431,-2655363,-14730155,462251,-27724326,3941372,-6236617,3696005,-32300832,15351955 ), + new FieldElement( 27431194,8222322,16448760,-3907995,-18707002,11938355,-32961401,-2970515,29551813,10109425 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -13657040,-13155431,-31283750,11777098,21447386,6519384,-2378284,-1627556,10092783,-4764171 ), + new FieldElement( 27939166,14210322,4677035,16277044,-22964462,-12398139,-32508754,12005538,-17810127,12803510 ), + new FieldElement( 17228999,-15661624,-1233527,300140,-1224870,-11714777,30364213,-9038194,18016357,4397660 ) + ), + new GroupElementPreComp( + new FieldElement( -10958843,-7690207,4776341,-14954238,27850028,-15602212,-26619106,14544525,-17477504,982639 ), + new FieldElement( 29253598,15796703,-2863982,-9908884,10057023,3163536,7332899,-4120128,-21047696,9934963 ), + new FieldElement( 5793303,16271923,-24131614,-10116404,29188560,1206517,-14747930,4559895,-30123922,-10897950 ) + ), + new GroupElementPreComp( + new FieldElement( -27643952,-11493006,16282657,-11036493,28414021,-15012264,24191034,4541697,-13338309,5500568 ), + new FieldElement( 12650548,-1497113,9052871,11355358,-17680037,-8400164,-17430592,12264343,10874051,13524335 ), + new FieldElement( 25556948,-3045990,714651,2510400,23394682,-10415330,33119038,5080568,-22528059,5376628 ) + ), + new GroupElementPreComp( + new FieldElement( -26088264,-4011052,-17013699,-3537628,-6726793,1920897,-22321305,-9447443,4535768,1569007 ), + new FieldElement( -2255422,14606630,-21692440,-8039818,28430649,8775819,-30494562,3044290,31848280,12543772 ), + new FieldElement( -22028579,2943893,-31857513,6777306,13784462,-4292203,-27377195,-2062731,7718482,14474653 ) + ), + new GroupElementPreComp( + new FieldElement( 2385315,2454213,-22631320,46603,-4437935,-15680415,656965,-7236665,24316168,-5253567 ), + new FieldElement( 13741529,10911568,-33233417,-8603737,-20177830,-1033297,33040651,-13424532,-20729456,8321686 ), + new FieldElement( 21060490,-2212744,15712757,-4336099,1639040,10656336,23845965,-11874838,-9984458,608372 ) + ), + new GroupElementPreComp( + new FieldElement( -13672732,-15087586,-10889693,-7557059,-6036909,11305547,1123968,-6780577,27229399,23887 ), + new FieldElement( -23244140,-294205,-11744728,14712571,-29465699,-2029617,12797024,-6440308,-1633405,16678954 ), + new FieldElement( -29500620,4770662,-16054387,14001338,7830047,9564805,-1508144,-4795045,-17169265,4904953 ) + ), + new GroupElementPreComp( + new FieldElement( 24059557,14617003,19037157,-15039908,19766093,-14906429,5169211,16191880,2128236,-4326833 ), + new FieldElement( -16981152,4124966,-8540610,-10653797,30336522,-14105247,-29806336,916033,-6882542,-2986532 ), + new FieldElement( -22630907,12419372,-7134229,-7473371,-16478904,16739175,285431,2763829,15736322,4143876 ) + ), + new GroupElementPreComp( + new FieldElement( 2379352,11839345,-4110402,-5988665,11274298,794957,212801,-14594663,23527084,-16458268 ), + new FieldElement( 33431127,-11130478,-17838966,-15626900,8909499,8376530,-32625340,4087881,-15188911,-14416214 ), + new FieldElement( 1767683,7197987,-13205226,-2022635,-13091350,448826,5799055,4357868,-4774191,-16323038 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 6721966,13833823,-23523388,-1551314,26354293,-11863321,23365147,-3949732,7390890,2759800 ), + new FieldElement( 4409041,2052381,23373853,10530217,7676779,-12885954,21302353,-4264057,1244380,-12919645 ), + new FieldElement( -4421239,7169619,4982368,-2957590,30256825,-2777540,14086413,9208236,15886429,16489664 ) + ), + new GroupElementPreComp( + new FieldElement( 1996075,10375649,14346367,13311202,-6874135,-16438411,-13693198,398369,-30606455,-712933 ), + new FieldElement( -25307465,9795880,-2777414,14878809,-33531835,14780363,13348553,12076947,-30836462,5113182 ), + new FieldElement( -17770784,11797796,31950843,13929123,-25888302,12288344,-30341101,-7336386,13847711,5387222 ) + ), + new GroupElementPreComp( + new FieldElement( -18582163,-3416217,17824843,-2340966,22744343,-10442611,8763061,3617786,-19600662,10370991 ), + new FieldElement( 20246567,-14369378,22358229,-543712,18507283,-10413996,14554437,-8746092,32232924,16763880 ), + new FieldElement( 9648505,10094563,26416693,14745928,-30374318,-6472621,11094161,15689506,3140038,-16510092 ) + ), + new GroupElementPreComp( + new FieldElement( -16160072,5472695,31895588,4744994,8823515,10365685,-27224800,9448613,-28774454,366295 ), + new FieldElement( 19153450,11523972,-11096490,-6503142,-24647631,5420647,28344573,8041113,719605,11671788 ), + new FieldElement( 8678025,2694440,-6808014,2517372,4964326,11152271,-15432916,-15266516,27000813,-10195553 ) + ), + new GroupElementPreComp( + new FieldElement( -15157904,7134312,8639287,-2814877,-7235688,10421742,564065,5336097,6750977,-14521026 ), + new FieldElement( 11836410,-3979488,26297894,16080799,23455045,15735944,1695823,-8819122,8169720,16220347 ), + new FieldElement( -18115838,8653647,17578566,-6092619,-8025777,-16012763,-11144307,-2627664,-5990708,-14166033 ) + ), + new GroupElementPreComp( + new FieldElement( -23308498,-10968312,15213228,-10081214,-30853605,-11050004,27884329,2847284,2655861,1738395 ), + new FieldElement( -27537433,-14253021,-25336301,-8002780,-9370762,8129821,21651608,-3239336,-19087449,-11005278 ), + new FieldElement( 1533110,3437855,23735889,459276,29970501,11335377,26030092,5821408,10478196,8544890 ) + ), + new GroupElementPreComp( + new FieldElement( 32173121,-16129311,24896207,3921497,22579056,-3410854,19270449,12217473,17789017,-3395995 ), + new FieldElement( -30552961,-2228401,-15578829,-10147201,13243889,517024,15479401,-3853233,30460520,1052596 ), + new FieldElement( -11614875,13323618,32618793,8175907,-15230173,12596687,27491595,-4612359,3179268,-9478891 ) + ), + new GroupElementPreComp( + new FieldElement( 31947069,-14366651,-4640583,-15339921,-15125977,-6039709,-14756777,-16411740,19072640,-9511060 ), + new FieldElement( 11685058,11822410,3158003,-13952594,33402194,-4165066,5977896,-5215017,473099,5040608 ), + new FieldElement( -20290863,8198642,-27410132,11602123,1290375,-2799760,28326862,1721092,-19558642,-3131606 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 7881532,10687937,7578723,7738378,-18951012,-2553952,21820786,8076149,-27868496,11538389 ), + new FieldElement( -19935666,3899861,18283497,-6801568,-15728660,-11249211,8754525,7446702,-5676054,5797016 ), + new FieldElement( -11295600,-3793569,-15782110,-7964573,12708869,-8456199,2014099,-9050574,-2369172,-5877341 ) + ), + new GroupElementPreComp( + new FieldElement( -22472376,-11568741,-27682020,1146375,18956691,16640559,1192730,-3714199,15123619,10811505 ), + new FieldElement( 14352098,-3419715,-18942044,10822655,32750596,4699007,-70363,15776356,-28886779,-11974553 ), + new FieldElement( -28241164,-8072475,-4978962,-5315317,29416931,1847569,-20654173,-16484855,4714547,-9600655 ) + ), + new GroupElementPreComp( + new FieldElement( 15200332,8368572,19679101,15970074,-31872674,1959451,24611599,-4543832,-11745876,12340220 ), + new FieldElement( 12876937,-10480056,33134381,6590940,-6307776,14872440,9613953,8241152,15370987,9608631 ), + new FieldElement( -4143277,-12014408,8446281,-391603,4407738,13629032,-7724868,15866074,-28210621,-8814099 ) + ), + new GroupElementPreComp( + new FieldElement( 26660628,-15677655,8393734,358047,-7401291,992988,-23904233,858697,20571223,8420556 ), + new FieldElement( 14620715,13067227,-15447274,8264467,14106269,15080814,33531827,12516406,-21574435,-12476749 ), + new FieldElement( 236881,10476226,57258,-14677024,6472998,2466984,17258519,7256740,8791136,15069930 ) + ), + new GroupElementPreComp( + new FieldElement( 1276410,-9371918,22949635,-16322807,-23493039,-5702186,14711875,4874229,-30663140,-2331391 ), + new FieldElement( 5855666,4990204,-13711848,7294284,-7804282,1924647,-1423175,-7912378,-33069337,9234253 ), + new FieldElement( 20590503,-9018988,31529744,-7352666,-2706834,10650548,31559055,-11609587,18979186,13396066 ) + ), + new GroupElementPreComp( + new FieldElement( 24474287,4968103,22267082,4407354,24063882,-8325180,-18816887,13594782,33514650,7021958 ), + new FieldElement( -11566906,-6565505,-21365085,15928892,-26158305,4315421,-25948728,-3916677,-21480480,12868082 ), + new FieldElement( -28635013,13504661,19988037,-2132761,21078225,6443208,-21446107,2244500,-12455797,-8089383 ) + ), + new GroupElementPreComp( + new FieldElement( -30595528,13793479,-5852820,319136,-25723172,-6263899,33086546,8957937,-15233648,5540521 ), + new FieldElement( -11630176,-11503902,-8119500,-7643073,2620056,1022908,-23710744,-1568984,-16128528,-14962807 ), + new FieldElement( 23152971,775386,27395463,14006635,-9701118,4649512,1689819,892185,-11513277,-15205948 ) + ), + new GroupElementPreComp( + new FieldElement( 9770129,9586738,26496094,4324120,1556511,-3550024,27453819,4763127,-19179614,5867134 ), + new FieldElement( -32765025,1927590,31726409,-4753295,23962434,-16019500,27846559,5931263,-29749703,-16108455 ), + new FieldElement( 27461885,-2977536,22380810,1815854,-23033753,-3031938,7283490,-15148073,-19526700,7734629 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -8010264,-9590817,-11120403,6196038,29344158,-13430885,7585295,-3176626,18549497,15302069 ), + new FieldElement( -32658337,-6171222,-7672793,-11051681,6258878,13504381,10458790,-6418461,-8872242,8424746 ), + new FieldElement( 24687205,8613276,-30667046,-3233545,1863892,-1830544,19206234,7134917,-11284482,-828919 ) + ), + new GroupElementPreComp( + new FieldElement( 11334899,-9218022,8025293,12707519,17523892,-10476071,10243738,-14685461,-5066034,16498837 ), + new FieldElement( 8911542,6887158,-9584260,-6958590,11145641,-9543680,17303925,-14124238,6536641,10543906 ), + new FieldElement( -28946384,15479763,-17466835,568876,-1497683,11223454,-2669190,-16625574,-27235709,8876771 ) + ), + new GroupElementPreComp( + new FieldElement( -25742899,-12566864,-15649966,-846607,-33026686,-796288,-33481822,15824474,-604426,-9039817 ), + new FieldElement( 10330056,70051,7957388,-9002667,9764902,15609756,27698697,-4890037,1657394,3084098 ), + new FieldElement( 10477963,-7470260,12119566,-13250805,29016247,-5365589,31280319,14396151,-30233575,15272409 ) + ), + new GroupElementPreComp( + new FieldElement( -12288309,3169463,28813183,16658753,25116432,-5630466,-25173957,-12636138,-25014757,1950504 ), + new FieldElement( -26180358,9489187,11053416,-14746161,-31053720,5825630,-8384306,-8767532,15341279,8373727 ), + new FieldElement( 28685821,7759505,-14378516,-12002860,-31971820,4079242,298136,-10232602,-2878207,15190420 ) + ), + new GroupElementPreComp( + new FieldElement( -32932876,13806336,-14337485,-15794431,-24004620,10940928,8669718,2742393,-26033313,-6875003 ), + new FieldElement( -1580388,-11729417,-25979658,-11445023,-17411874,-10912854,9291594,-16247779,-12154742,6048605 ), + new FieldElement( -30305315,14843444,1539301,11864366,20201677,1900163,13934231,5128323,11213262,9168384 ) + ), + new GroupElementPreComp( + new FieldElement( -26280513,11007847,19408960,-940758,-18592965,-4328580,-5088060,-11105150,20470157,-16398701 ), + new FieldElement( -23136053,9282192,14855179,-15390078,-7362815,-14408560,-22783952,14461608,14042978,5230683 ), + new FieldElement( 29969567,-2741594,-16711867,-8552442,9175486,-2468974,21556951,3506042,-5933891,-12449708 ) + ), + new GroupElementPreComp( + new FieldElement( -3144746,8744661,19704003,4581278,-20430686,6830683,-21284170,8971513,-28539189,15326563 ), + new FieldElement( -19464629,10110288,-17262528,-3503892,-23500387,1355669,-15523050,15300988,-20514118,9168260 ), + new FieldElement( -5353335,4488613,-23803248,16314347,7780487,-15638939,-28948358,9601605,33087103,-9011387 ) + ), + new GroupElementPreComp( + new FieldElement( -19443170,-15512900,-20797467,-12445323,-29824447,10229461,-27444329,-15000531,-5996870,15664672 ), + new FieldElement( 23294591,-16632613,-22650781,-8470978,27844204,11461195,13099750,-2460356,18151676,13417686 ), + new FieldElement( -24722913,-4176517,-31150679,5988919,-26858785,6685065,1661597,-12551441,15271676,-15452665 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 11433042,-13228665,8239631,-5279517,-1985436,-725718,-18698764,2167544,-6921301,-13440182 ), + new FieldElement( -31436171,15575146,30436815,12192228,-22463353,9395379,-9917708,-8638997,12215110,12028277 ), + new FieldElement( 14098400,6555944,23007258,5757252,-15427832,-12950502,30123440,4617780,-16900089,-655628 ) + ), + new GroupElementPreComp( + new FieldElement( -4026201,-15240835,11893168,13718664,-14809462,1847385,-15819999,10154009,23973261,-12684474 ), + new FieldElement( -26531820,-3695990,-1908898,2534301,-31870557,-16550355,18341390,-11419951,32013174,-10103539 ), + new FieldElement( -25479301,10876443,-11771086,-14625140,-12369567,1838104,21911214,6354752,4425632,-837822 ) + ), + new GroupElementPreComp( + new FieldElement( -10433389,-14612966,22229858,-3091047,-13191166,776729,-17415375,-12020462,4725005,14044970 ), + new FieldElement( 19268650,-7304421,1555349,8692754,-21474059,-9910664,6347390,-1411784,-19522291,-16109756 ), + new FieldElement( -24864089,12986008,-10898878,-5558584,-11312371,-148526,19541418,8180106,9282262,10282508 ) + ), + new GroupElementPreComp( + new FieldElement( -26205082,4428547,-8661196,-13194263,4098402,-14165257,15522535,8372215,5542595,-10702683 ), + new FieldElement( -10562541,14895633,26814552,-16673850,-17480754,-2489360,-2781891,6993761,-18093885,10114655 ), + new FieldElement( -20107055,-929418,31422704,10427861,-7110749,6150669,-29091755,-11529146,25953725,-106158 ) + ), + new GroupElementPreComp( + new FieldElement( -4234397,-8039292,-9119125,3046000,2101609,-12607294,19390020,6094296,-3315279,12831125 ), + new FieldElement( -15998678,7578152,5310217,14408357,-33548620,-224739,31575954,6326196,7381791,-2421839 ), + new FieldElement( -20902779,3296811,24736065,-16328389,18374254,7318640,6295303,8082724,-15362489,12339664 ) + ), + new GroupElementPreComp( + new FieldElement( 27724736,2291157,6088201,-14184798,1792727,5857634,13848414,15768922,25091167,14856294 ), + new FieldElement( -18866652,8331043,24373479,8541013,-701998,-9269457,12927300,-12695493,-22182473,-9012899 ), + new FieldElement( -11423429,-5421590,11632845,3405020,30536730,-11674039,-27260765,13866390,30146206,9142070 ) + ), + new GroupElementPreComp( + new FieldElement( 3924129,-15307516,-13817122,-10054960,12291820,-668366,-27702774,9326384,-8237858,4171294 ), + new FieldElement( -15921940,16037937,6713787,16606682,-21612135,2790944,26396185,3731949,345228,-5462949 ), + new FieldElement( -21327538,13448259,25284571,1143661,20614966,-8849387,2031539,-12391231,-16253183,-13582083 ) + ), + new GroupElementPreComp( + new FieldElement( 31016211,-16722429,26371392,-14451233,-5027349,14854137,17477601,3842657,28012650,-16405420 ), + new FieldElement( -5075835,9368966,-8562079,-4600902,-15249953,6970560,-9189873,16292057,-8867157,3507940 ), + new FieldElement( 29439664,3537914,23333589,6997794,-17555561,-11018068,-15209202,-15051267,-9164929,6580396 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -12185861,-7679788,16438269,10826160,-8696817,-6235611,17860444,-9273846,-2095802,9304567 ), + new FieldElement( 20714564,-4336911,29088195,7406487,11426967,-5095705,14792667,-14608617,5289421,-477127 ), + new FieldElement( -16665533,-10650790,-6160345,-13305760,9192020,-1802462,17271490,12349094,26939669,-3752294 ) + ), + new GroupElementPreComp( + new FieldElement( -12889898,9373458,31595848,16374215,21471720,13221525,-27283495,-12348559,-3698806,117887 ), + new FieldElement( 22263325,-6560050,3984570,-11174646,-15114008,-566785,28311253,5358056,-23319780,541964 ), + new FieldElement( 16259219,3261970,2309254,-15534474,-16885711,-4581916,24134070,-16705829,-13337066,-13552195 ) + ), + new GroupElementPreComp( + new FieldElement( 9378160,-13140186,-22845982,-12745264,28198281,-7244098,-2399684,-717351,690426,14876244 ), + new FieldElement( 24977353,-314384,-8223969,-13465086,28432343,-1176353,-13068804,-12297348,-22380984,6618999 ), + new FieldElement( -1538174,11685646,12944378,13682314,-24389511,-14413193,8044829,-13817328,32239829,-5652762 ) + ), + new GroupElementPreComp( + new FieldElement( -18603066,4762990,-926250,8885304,-28412480,-3187315,9781647,-10350059,32779359,5095274 ), + new FieldElement( -33008130,-5214506,-32264887,-3685216,9460461,-9327423,-24601656,14506724,21639561,-2630236 ), + new FieldElement( -16400943,-13112215,25239338,15531969,3987758,-4499318,-1289502,-6863535,17874574,558605 ) + ), + new GroupElementPreComp( + new FieldElement( -13600129,10240081,9171883,16131053,-20869254,9599700,33499487,5080151,2085892,5119761 ), + new FieldElement( -22205145,-2519528,-16381601,414691,-25019550,2170430,30634760,-8363614,-31999993,-5759884 ), + new FieldElement( -6845704,15791202,8550074,-1312654,29928809,-12092256,27534430,-7192145,-22351378,12961482 ) + ), + new GroupElementPreComp( + new FieldElement( -24492060,-9570771,10368194,11582341,-23397293,-2245287,16533930,8206996,-30194652,-5159638 ), + new FieldElement( -11121496,-3382234,2307366,6362031,-135455,8868177,-16835630,7031275,7589640,8945490 ), + new FieldElement( -32152748,8917967,6661220,-11677616,-1192060,-15793393,7251489,-11182180,24099109,-14456170 ) + ), + new GroupElementPreComp( + new FieldElement( 5019558,-7907470,4244127,-14714356,-26933272,6453165,-19118182,-13289025,-6231896,-10280736 ), + new FieldElement( 10853594,10721687,26480089,5861829,-22995819,1972175,-1866647,-10557898,-3363451,-6441124 ), + new FieldElement( -17002408,5906790,221599,-6563147,7828208,-13248918,24362661,-2008168,-13866408,7421392 ) + ), + new GroupElementPreComp( + new FieldElement( 8139927,-6546497,32257646,-5890546,30375719,1886181,-21175108,15441252,28826358,-4123029 ), + new FieldElement( 6267086,9695052,7709135,-16603597,-32869068,-1886135,14795160,-7840124,13746021,-1742048 ), + new FieldElement( 28584902,7787108,-6732942,-15050729,22846041,-7571236,-3181936,-363524,4771362,-8419958 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 24949256,6376279,-27466481,-8174608,-18646154,-9930606,33543569,-12141695,3569627,11342593 ), + new FieldElement( 26514989,4740088,27912651,3697550,19331575,-11472339,6809886,4608608,7325975,-14801071 ), + new FieldElement( -11618399,-14554430,-24321212,7655128,-1369274,5214312,-27400540,10258390,-17646694,-8186692 ) + ), + new GroupElementPreComp( + new FieldElement( 11431204,15823007,26570245,14329124,18029990,4796082,-31446179,15580664,9280358,-3973687 ), + new FieldElement( -160783,-10326257,-22855316,-4304997,-20861367,-13621002,-32810901,-11181622,-15545091,4387441 ), + new FieldElement( -20799378,12194512,3937617,-5805892,-27154820,9340370,-24513992,8548137,20617071,-7482001 ) + ), + new GroupElementPreComp( + new FieldElement( -938825,-3930586,-8714311,16124718,24603125,-6225393,-13775352,-11875822,24345683,10325460 ), + new FieldElement( -19855277,-1568885,-22202708,8714034,14007766,6928528,16318175,-1010689,4766743,3552007 ), + new FieldElement( -21751364,-16730916,1351763,-803421,-4009670,3950935,3217514,14481909,10988822,-3994762 ) + ), + new GroupElementPreComp( + new FieldElement( 15564307,-14311570,3101243,5684148,30446780,-8051356,12677127,-6505343,-8295852,13296005 ), + new FieldElement( -9442290,6624296,-30298964,-11913677,-4670981,-2057379,31521204,9614054,-30000824,12074674 ), + new FieldElement( 4771191,-135239,14290749,-13089852,27992298,14998318,-1413936,-1556716,29832613,-16391035 ) + ), + new GroupElementPreComp( + new FieldElement( 7064884,-7541174,-19161962,-5067537,-18891269,-2912736,25825242,5293297,-27122660,13101590 ), + new FieldElement( -2298563,2439670,-7466610,1719965,-27267541,-16328445,32512469,-5317593,-30356070,-4190957 ), + new FieldElement( -30006540,10162316,-33180176,3981723,-16482138,-13070044,14413974,9515896,19568978,9628812 ) + ), + new GroupElementPreComp( + new FieldElement( 33053803,199357,15894591,1583059,27380243,-4580435,-17838894,-6106839,-6291786,3437740 ), + new FieldElement( -18978877,3884493,19469877,12726490,15913552,13614290,-22961733,70104,7463304,4176122 ), + new FieldElement( -27124001,10659917,11482427,-16070381,12771467,-6635117,-32719404,-5322751,24216882,5944158 ) + ), + new GroupElementPreComp( + new FieldElement( 8894125,7450974,-2664149,-9765752,-28080517,-12389115,19345746,14680796,11632993,5847885 ), + new FieldElement( 26942781,-2315317,9129564,-4906607,26024105,11769399,-11518837,6367194,-9727230,4782140 ), + new FieldElement( 19916461,-4828410,-22910704,-11414391,25606324,-5972441,33253853,8220911,6358847,-1873857 ) + ), + new GroupElementPreComp( + new FieldElement( 801428,-2081702,16569428,11065167,29875704,96627,7908388,-4480480,-13538503,1387155 ), + new FieldElement( 19646058,5720633,-11416706,12814209,11607948,12749789,14147075,15156355,-21866831,11835260 ), + new FieldElement( 19299512,1155910,28703737,14890794,2925026,7269399,26121523,15467869,-26560550,5052483 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -3017432,10058206,1980837,3964243,22160966,12322533,-6431123,-12618185,12228557,-7003677 ), + new FieldElement( 32944382,14922211,-22844894,5188528,21913450,-8719943,4001465,13238564,-6114803,8653815 ), + new FieldElement( 22865569,-4652735,27603668,-12545395,14348958,8234005,24808405,5719875,28483275,2841751 ) + ), + new GroupElementPreComp( + new FieldElement( -16420968,-1113305,-327719,-12107856,21886282,-15552774,-1887966,-315658,19932058,-12739203 ), + new FieldElement( -11656086,10087521,-8864888,-5536143,-19278573,-3055912,3999228,13239134,-4777469,-13910208 ), + new FieldElement( 1382174,-11694719,17266790,9194690,-13324356,9720081,20403944,11284705,-14013818,3093230 ) + ), + new GroupElementPreComp( + new FieldElement( 16650921,-11037932,-1064178,1570629,-8329746,7352753,-302424,16271225,-24049421,-6691850 ), + new FieldElement( -21911077,-5927941,-4611316,-5560156,-31744103,-10785293,24123614,15193618,-21652117,-16739389 ), + new FieldElement( -9935934,-4289447,-25279823,4372842,2087473,10399484,31870908,14690798,17361620,11864968 ) + ), + new GroupElementPreComp( + new FieldElement( -11307610,6210372,13206574,5806320,-29017692,-13967200,-12331205,-7486601,-25578460,-16240689 ), + new FieldElement( 14668462,-12270235,26039039,15305210,25515617,4542480,10453892,6577524,9145645,-6443880 ), + new FieldElement( 5974874,3053895,-9433049,-10385191,-31865124,3225009,-7972642,3936128,-5652273,-3050304 ) + ), + new GroupElementPreComp( + new FieldElement( 30625386,-4729400,-25555961,-12792866,-20484575,7695099,17097188,-16303496,-27999779,1803632 ), + new FieldElement( -3553091,9865099,-5228566,4272701,-5673832,-16689700,14911344,12196514,-21405489,7047412 ), + new FieldElement( 20093277,9920966,-11138194,-5343857,13161587,12044805,-32856851,4124601,-32343828,-10257566 ) + ), + new GroupElementPreComp( + new FieldElement( -20788824,14084654,-13531713,7842147,19119038,-13822605,4752377,-8714640,-21679658,2288038 ), + new FieldElement( -26819236,-3283715,29965059,3039786,-14473765,2540457,29457502,14625692,-24819617,12570232 ), + new FieldElement( -1063558,-11551823,16920318,12494842,1278292,-5869109,-21159943,-3498680,-11974704,4724943 ) + ), + new GroupElementPreComp( + new FieldElement( 17960970,-11775534,-4140968,-9702530,-8876562,-1410617,-12907383,-8659932,-29576300,1903856 ), + new FieldElement( 23134274,-14279132,-10681997,-1611936,20684485,15770816,-12989750,3190296,26955097,14109738 ), + new FieldElement( 15308788,5320727,-30113809,-14318877,22902008,7767164,29425325,-11277562,31960942,11934971 ) + ), + new GroupElementPreComp( + new FieldElement( -27395711,8435796,4109644,12222639,-24627868,14818669,20638173,4875028,10491392,1379718 ), + new FieldElement( -13159415,9197841,3875503,-8936108,-1383712,-5879801,33518459,16176658,21432314,12180697 ), + new FieldElement( -11787308,11500838,13787581,-13832590,-22430679,10140205,1465425,12689540,-10301319,-13872883 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 5414091,-15386041,-21007664,9643570,12834970,1186149,-2622916,-1342231,26128231,6032912 ), + new FieldElement( -26337395,-13766162,32496025,-13653919,17847801,-12669156,3604025,8316894,-25875034,-10437358 ), + new FieldElement( 3296484,6223048,24680646,-12246460,-23052020,5903205,-8862297,-4639164,12376617,3188849 ) + ), + new GroupElementPreComp( + new FieldElement( 29190488,-14659046,27549113,-1183516,3520066,-10697301,32049515,-7309113,-16109234,-9852307 ), + new FieldElement( -14744486,-9309156,735818,-598978,-20407687,-5057904,25246078,-15795669,18640741,-960977 ), + new FieldElement( -6928835,-16430795,10361374,5642961,4910474,12345252,-31638386,-494430,10530747,1053335 ) + ), + new GroupElementPreComp( + new FieldElement( -29265967,-14186805,-13538216,-12117373,-19457059,-10655384,-31462369,-2948985,24018831,15026644 ), + new FieldElement( -22592535,-3145277,-2289276,5953843,-13440189,9425631,25310643,13003497,-2314791,-15145616 ), + new FieldElement( -27419985,-603321,-8043984,-1669117,-26092265,13987819,-27297622,187899,-23166419,-2531735 ) + ), + new GroupElementPreComp( + new FieldElement( -21744398,-13810475,1844840,5021428,-10434399,-15911473,9716667,16266922,-5070217,726099 ), + new FieldElement( 29370922,-6053998,7334071,-15342259,9385287,2247707,-13661962,-4839461,30007388,-15823341 ), + new FieldElement( -936379,16086691,23751945,-543318,-1167538,-5189036,9137109,730663,9835848,4555336 ) + ), + new GroupElementPreComp( + new FieldElement( -23376435,1410446,-22253753,-12899614,30867635,15826977,17693930,544696,-11985298,12422646 ), + new FieldElement( 31117226,-12215734,-13502838,6561947,-9876867,-12757670,-5118685,-4096706,29120153,13924425 ), + new FieldElement( -17400879,-14233209,19675799,-2734756,-11006962,-5858820,-9383939,-11317700,7240931,-237388 ) + ), + new GroupElementPreComp( + new FieldElement( -31361739,-11346780,-15007447,-5856218,-22453340,-12152771,1222336,4389483,3293637,-15551743 ), + new FieldElement( -16684801,-14444245,11038544,11054958,-13801175,-3338533,-24319580,7733547,12796905,-6335822 ), + new FieldElement( -8759414,-10817836,-25418864,10783769,-30615557,-9746811,-28253339,3647836,3222231,-11160462 ) + ), + new GroupElementPreComp( + new FieldElement( 18606113,1693100,-25448386,-15170272,4112353,10045021,23603893,-2048234,-7550776,2484985 ), + new FieldElement( 9255317,-3131197,-12156162,-1004256,13098013,-9214866,16377220,-2102812,-19802075,-3034702 ), + new FieldElement( -22729289,7496160,-5742199,11329249,19991973,-3347502,-31718148,9936966,-30097688,-10618797 ) + ), + new GroupElementPreComp( + new FieldElement( 21878590,-5001297,4338336,13643897,-3036865,13160960,19708896,5415497,-7360503,-4109293 ), + new FieldElement( 27736861,10103576,12500508,8502413,-3413016,-9633558,10436918,-1550276,-23659143,-8132100 ), + new FieldElement( 19492550,-12104365,-29681976,-852630,-3208171,12403437,30066266,8367329,13243957,8709688 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 12015105,2801261,28198131,10151021,24818120,-4743133,-11194191,-5645734,5150968,7274186 ), + new FieldElement( 2831366,-12492146,1478975,6122054,23825128,-12733586,31097299,6083058,31021603,-9793610 ), + new FieldElement( -2529932,-2229646,445613,10720828,-13849527,-11505937,-23507731,16354465,15067285,-14147707 ) + ), + new GroupElementPreComp( + new FieldElement( 7840942,14037873,-33364863,15934016,-728213,-3642706,21403988,1057586,-19379462,-12403220 ), + new FieldElement( 915865,-16469274,15608285,-8789130,-24357026,6060030,-17371319,8410997,-7220461,16527025 ), + new FieldElement( 32922597,-556987,20336074,-16184568,10903705,-5384487,16957574,52992,23834301,6588044 ) + ), + new GroupElementPreComp( + new FieldElement( 32752030,11232950,3381995,-8714866,22652988,-10744103,17159699,16689107,-20314580,-1305992 ), + new FieldElement( -4689649,9166776,-25710296,-10847306,11576752,12733943,7924251,-2752281,1976123,-7249027 ), + new FieldElement( 21251222,16309901,-2983015,-6783122,30810597,12967303,156041,-3371252,12331345,-8237197 ) + ), + new GroupElementPreComp( + new FieldElement( 8651614,-4477032,-16085636,-4996994,13002507,2950805,29054427,-5106970,10008136,-4667901 ), + new FieldElement( 31486080,15114593,-14261250,12951354,14369431,-7387845,16347321,-13662089,8684155,-10532952 ), + new FieldElement( 19443825,11385320,24468943,-9659068,-23919258,2187569,-26263207,-6086921,31316348,14219878 ) + ), + new GroupElementPreComp( + new FieldElement( -28594490,1193785,32245219,11392485,31092169,15722801,27146014,6992409,29126555,9207390 ), + new FieldElement( 32382935,1110093,18477781,11028262,-27411763,-7548111,-4980517,10843782,-7957600,-14435730 ), + new FieldElement( 2814918,7836403,27519878,-7868156,-20894015,-11553689,-21494559,8550130,28346258,1994730 ) + ), + new GroupElementPreComp( + new FieldElement( -19578299,8085545,-14000519,-3948622,2785838,-16231307,-19516951,7174894,22628102,8115180 ), + new FieldElement( -30405132,955511,-11133838,-15078069,-32447087,-13278079,-25651578,3317160,-9943017,930272 ), + new FieldElement( -15303681,-6833769,28856490,1357446,23421993,1057177,24091212,-1388970,-22765376,-10650715 ) + ), + new GroupElementPreComp( + new FieldElement( -22751231,-5303997,-12907607,-12768866,-15811511,-7797053,-14839018,-16554220,-1867018,8398970 ), + new FieldElement( -31969310,2106403,-4736360,1362501,12813763,16200670,22981545,-6291273,18009408,-15772772 ), + new FieldElement( -17220923,-9545221,-27784654,14166835,29815394,7444469,29551787,-3727419,19288549,1325865 ) + ), + new GroupElementPreComp( + new FieldElement( 15100157,-15835752,-23923978,-1005098,-26450192,15509408,12376730,-3479146,33166107,-8042750 ), + new FieldElement( 20909231,13023121,-9209752,16251778,-5778415,-8094914,12412151,10018715,2213263,-13878373 ), + new FieldElement( 32529814,-11074689,30361439,-16689753,-9135940,1513226,22922121,6382134,-5766928,8371348 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 9923462,11271500,12616794,3544722,-29998368,-1721626,12891687,-8193132,-26442943,10486144 ), + new FieldElement( -22597207,-7012665,8587003,-8257861,4084309,-12970062,361726,2610596,-23921530,-11455195 ), + new FieldElement( 5408411,-1136691,-4969122,10561668,24145918,14240566,31319731,-4235541,19985175,-3436086 ) + ), + new GroupElementPreComp( + new FieldElement( -13994457,16616821,14549246,3341099,32155958,13648976,-17577068,8849297,65030,8370684 ), + new FieldElement( -8320926,-12049626,31204563,5839400,-20627288,-1057277,-19442942,6922164,12743482,-9800518 ), + new FieldElement( -2361371,12678785,28815050,4759974,-23893047,4884717,23783145,11038569,18800704,255233 ) + ), + new GroupElementPreComp( + new FieldElement( -5269658,-1773886,13957886,7990715,23132995,728773,13393847,9066957,19258688,-14753793 ), + new FieldElement( -2936654,-10827535,-10432089,14516793,-3640786,4372541,-31934921,2209390,-1524053,2055794 ), + new FieldElement( 580882,16705327,5468415,-2683018,-30926419,-14696000,-7203346,-8994389,-30021019,7394435 ) + ), + new GroupElementPreComp( + new FieldElement( 23838809,1822728,-15738443,15242727,8318092,-3733104,-21672180,-3492205,-4821741,14799921 ), + new FieldElement( 13345610,9759151,3371034,-16137791,16353039,8577942,31129804,13496856,-9056018,7402518 ), + new FieldElement( 2286874,-4435931,-20042458,-2008336,-13696227,5038122,11006906,-15760352,8205061,1607563 ) + ), + new GroupElementPreComp( + new FieldElement( 14414086,-8002132,3331830,-3208217,22249151,-5594188,18364661,-2906958,30019587,-9029278 ), + new FieldElement( -27688051,1585953,-10775053,931069,-29120221,-11002319,-14410829,12029093,9944378,8024 ), + new FieldElement( 4368715,-3709630,29874200,-15022983,-20230386,-11410704,-16114594,-999085,-8142388,5640030 ) + ), + new GroupElementPreComp( + new FieldElement( 10299610,13746483,11661824,16234854,7630238,5998374,9809887,-16694564,15219798,-14327783 ), + new FieldElement( 27425505,-5719081,3055006,10660664,23458024,595578,-15398605,-1173195,-18342183,9742717 ), + new FieldElement( 6744077,2427284,26042789,2720740,-847906,1118974,32324614,7406442,12420155,1994844 ) + ), + new GroupElementPreComp( + new FieldElement( 14012521,-5024720,-18384453,-9578469,-26485342,-3936439,-13033478,-10909803,24319929,-6446333 ), + new FieldElement( 16412690,-4507367,10772641,15929391,-17068788,-4658621,10555945,-10484049,-30102368,-4739048 ), + new FieldElement( 22397382,-7767684,-9293161,-12792868,17166287,-9755136,-27333065,6199366,21880021,-12250760 ) + ), + new GroupElementPreComp( + new FieldElement( -4283307,5368523,-31117018,8163389,-30323063,3209128,16557151,8890729,8840445,4957760 ), + new FieldElement( -15447727,709327,-6919446,-10870178,-29777922,6522332,-21720181,12130072,-14796503,5005757 ), + new FieldElement( -2114751,-14308128,23019042,15765735,-25269683,6002752,10183197,-13239326,-16395286,-2176112 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -19025756,1632005,13466291,-7995100,-23640451,16573537,-32013908,-3057104,22208662,2000468 ), + new FieldElement( 3065073,-1412761,-25598674,-361432,-17683065,-5703415,-8164212,11248527,-3691214,-7414184 ), + new FieldElement( 10379208,-6045554,8877319,1473647,-29291284,-12507580,16690915,2553332,-3132688,16400289 ) + ), + new GroupElementPreComp( + new FieldElement( 15716668,1254266,-18472690,7446274,-8448918,6344164,-22097271,-7285580,26894937,9132066 ), + new FieldElement( 24158887,12938817,11085297,-8177598,-28063478,-4457083,-30576463,64452,-6817084,-2692882 ), + new FieldElement( 13488534,7794716,22236231,5989356,25426474,-12578208,2350710,-3418511,-4688006,2364226 ) + ), + new GroupElementPreComp( + new FieldElement( 16335052,9132434,25640582,6678888,1725628,8517937,-11807024,-11697457,15445875,-7798101 ), + new FieldElement( 29004207,-7867081,28661402,-640412,-12794003,-7943086,31863255,-4135540,-278050,-15759279 ), + new FieldElement( -6122061,-14866665,-28614905,14569919,-10857999,-3591829,10343412,-6976290,-29828287,-10815811 ) + ), + new GroupElementPreComp( + new FieldElement( 27081650,3463984,14099042,-4517604,1616303,-6205604,29542636,15372179,17293797,960709 ), + new FieldElement( 20263915,11434237,-5765435,11236810,13505955,-10857102,-16111345,6493122,-19384511,7639714 ), + new FieldElement( -2830798,-14839232,25403038,-8215196,-8317012,-16173699,18006287,-16043750,29994677,-15808121 ) + ), + new GroupElementPreComp( + new FieldElement( 9769828,5202651,-24157398,-13631392,-28051003,-11561624,-24613141,-13860782,-31184575,709464 ), + new FieldElement( 12286395,13076066,-21775189,-1176622,-25003198,4057652,-32018128,-8890874,16102007,13205847 ), + new FieldElement( 13733362,5599946,10557076,3195751,-5557991,8536970,-25540170,8525972,10151379,10394400 ) + ), + new GroupElementPreComp( + new FieldElement( 4024660,-16137551,22436262,12276534,-9099015,-2686099,19698229,11743039,-33302334,8934414 ), + new FieldElement( -15879800,-4525240,-8580747,-2934061,14634845,-698278,-9449077,3137094,-11536886,11721158 ), + new FieldElement( 17555939,-5013938,8268606,2331751,-22738815,9761013,9319229,8835153,-9205489,-1280045 ) + ), + new GroupElementPreComp( + new FieldElement( -461409,-7830014,20614118,16688288,-7514766,-4807119,22300304,505429,6108462,-6183415 ), + new FieldElement( -5070281,12367917,-30663534,3234473,32617080,-8422642,29880583,-13483331,-26898490,-7867459 ), + new FieldElement( -31975283,5726539,26934134,10237677,-3173717,-605053,24199304,3795095,7592688,-14992079 ) + ), + new GroupElementPreComp( + new FieldElement( 21594432,-14964228,17466408,-4077222,32537084,2739898,6407723,12018833,-28256052,4298412 ), + new FieldElement( -20650503,-11961496,-27236275,570498,3767144,-1717540,13891942,-1569194,13717174,10805743 ), + new FieldElement( -14676630,-15644296,15287174,11927123,24177847,-8175568,-796431,14860609,-26938930,-5863836 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 12962541,5311799,-10060768,11658280,18855286,-7954201,13286263,-12808704,-4381056,9882022 ), + new FieldElement( 18512079,11319350,-20123124,15090309,18818594,5271736,-22727904,3666879,-23967430,-3299429 ), + new FieldElement( -6789020,-3146043,16192429,13241070,15898607,-14206114,-10084880,-6661110,-2403099,5276065 ) + ), + new GroupElementPreComp( + new FieldElement( 30169808,-5317648,26306206,-11750859,27814964,7069267,7152851,3684982,1449224,13082861 ), + new FieldElement( 10342826,3098505,2119311,193222,25702612,12233820,23697382,15056736,-21016438,-8202000 ), + new FieldElement( -33150110,3261608,22745853,7948688,19370557,-15177665,-26171976,6482814,-10300080,-11060101 ) + ), + new GroupElementPreComp( + new FieldElement( 32869458,-5408545,25609743,15678670,-10687769,-15471071,26112421,2521008,-22664288,6904815 ), + new FieldElement( 29506923,4457497,3377935,-9796444,-30510046,12935080,1561737,3841096,-29003639,-6657642 ), + new FieldElement( 10340844,-6630377,-18656632,-2278430,12621151,-13339055,30878497,-11824370,-25584551,5181966 ) + ), + new GroupElementPreComp( + new FieldElement( 25940115,-12658025,17324188,-10307374,-8671468,15029094,24396252,-16450922,-2322852,-12388574 ), + new FieldElement( -21765684,9916823,-1300409,4079498,-1028346,11909559,1782390,12641087,20603771,-6561742 ), + new FieldElement( -18882287,-11673380,24849422,11501709,13161720,-4768874,1925523,11914390,4662781,7820689 ) + ), + new GroupElementPreComp( + new FieldElement( 12241050,-425982,8132691,9393934,32846760,-1599620,29749456,12172924,16136752,15264020 ), + new FieldElement( -10349955,-14680563,-8211979,2330220,-17662549,-14545780,10658213,6671822,19012087,3772772 ), + new FieldElement( 3753511,-3421066,10617074,2028709,14841030,-6721664,28718732,-15762884,20527771,12988982 ) + ), + new GroupElementPreComp( + new FieldElement( -14822485,-5797269,-3707987,12689773,-898983,-10914866,-24183046,-10564943,3299665,-12424953 ), + new FieldElement( -16777703,-15253301,-9642417,4978983,3308785,8755439,6943197,6461331,-25583147,8991218 ), + new FieldElement( -17226263,1816362,-1673288,-6086439,31783888,-8175991,-32948145,7417950,-30242287,1507265 ) + ), + new GroupElementPreComp( + new FieldElement( 29692663,6829891,-10498800,4334896,20945975,-11906496,-28887608,8209391,14606362,-10647073 ), + new FieldElement( -3481570,8707081,32188102,5672294,22096700,1711240,-33020695,9761487,4170404,-2085325 ), + new FieldElement( -11587470,14855945,-4127778,-1531857,-26649089,15084046,22186522,16002000,-14276837,-8400798 ) + ), + new GroupElementPreComp( + new FieldElement( -4811456,13761029,-31703877,-2483919,-3312471,7869047,-7113572,-9620092,13240845,10965870 ), + new FieldElement( -7742563,-8256762,-14768334,-13656260,-23232383,12387166,4498947,14147411,29514390,4302863 ), + new FieldElement( -13413405,-12407859,20757302,-13801832,14785143,8976368,-5061276,-2144373,17846988,-13971927 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -2244452,-754728,-4597030,-1066309,-6247172,1455299,-21647728,-9214789,-5222701,12650267 ), + new FieldElement( -9906797,-16070310,21134160,12198166,-27064575,708126,387813,13770293,-19134326,10958663 ), + new FieldElement( 22470984,12369526,23446014,-5441109,-21520802,-9698723,-11772496,-11574455,-25083830,4271862 ) + ), + new GroupElementPreComp( + new FieldElement( -25169565,-10053642,-19909332,15361595,-5984358,2159192,75375,-4278529,-32526221,8469673 ), + new FieldElement( 15854970,4148314,-8893890,7259002,11666551,13824734,-30531198,2697372,24154791,-9460943 ), + new FieldElement( 15446137,-15806644,29759747,14019369,30811221,-9610191,-31582008,12840104,24913809,9815020 ) + ), + new GroupElementPreComp( + new FieldElement( -4709286,-5614269,-31841498,-12288893,-14443537,10799414,-9103676,13438769,18735128,9466238 ), + new FieldElement( 11933045,9281483,5081055,-5183824,-2628162,-4905629,-7727821,-10896103,-22728655,16199064 ), + new FieldElement( 14576810,379472,-26786533,-8317236,-29426508,-10812974,-102766,1876699,30801119,2164795 ) + ), + new GroupElementPreComp( + new FieldElement( 15995086,3199873,13672555,13712240,-19378835,-4647646,-13081610,-15496269,-13492807,1268052 ), + new FieldElement( -10290614,-3659039,-3286592,10948818,23037027,3794475,-3470338,-12600221,-17055369,3565904 ), + new FieldElement( 29210088,-9419337,-5919792,-4952785,10834811,-13327726,-16512102,-10820713,-27162222,-14030531 ) + ), + new GroupElementPreComp( + new FieldElement( -13161890,15508588,16663704,-8156150,-28349942,9019123,-29183421,-3769423,2244111,-14001979 ), + new FieldElement( -5152875,-3800936,-9306475,-6071583,16243069,14684434,-25673088,-16180800,13491506,4641841 ), + new FieldElement( 10813417,643330,-19188515,-728916,30292062,-16600078,27548447,-7721242,14476989,-12767431 ) + ), + new GroupElementPreComp( + new FieldElement( 10292079,9984945,6481436,8279905,-7251514,7032743,27282937,-1644259,-27912810,12651324 ), + new FieldElement( -31185513,-813383,22271204,11835308,10201545,15351028,17099662,3988035,21721536,-3148940 ), + new FieldElement( 10202177,-6545839,-31373232,-9574638,-32150642,-8119683,-12906320,3852694,13216206,14842320 ) + ), + new GroupElementPreComp( + new FieldElement( -15815640,-10601066,-6538952,-7258995,-6984659,-6581778,-31500847,13765824,-27434397,9900184 ), + new FieldElement( 14465505,-13833331,-32133984,-14738873,-27443187,12990492,33046193,15796406,-7051866,-8040114 ), + new FieldElement( 30924417,-8279620,6359016,-12816335,16508377,9071735,-25488601,15413635,9524356,-7018878 ) + ), + new GroupElementPreComp( + new FieldElement( 12274201,-13175547,32627641,-1785326,6736625,13267305,5237659,-5109483,15663516,4035784 ), + new FieldElement( -2951309,8903985,17349946,601635,-16432815,-4612556,-13732739,-15889334,-22258478,4659091 ), + new FieldElement( -16916263,-4952973,-30393711,-15158821,20774812,15897498,5736189,15026997,-2178256,-13455585 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -8858980,-2219056,28571666,-10155518,-474467,-10105698,-3801496,278095,23440562,-290208 ), + new FieldElement( 10226241,-5928702,15139956,120818,-14867693,5218603,32937275,11551483,-16571960,-7442864 ), + new FieldElement( 17932739,-12437276,-24039557,10749060,11316803,7535897,22503767,5561594,-3646624,3898661 ) + ), + new GroupElementPreComp( + new FieldElement( 7749907,-969567,-16339731,-16464,-25018111,15122143,-1573531,7152530,21831162,1245233 ), + new FieldElement( 26958459,-14658026,4314586,8346991,-5677764,11960072,-32589295,-620035,-30402091,-16716212 ), + new FieldElement( -12165896,9166947,33491384,13673479,29787085,13096535,6280834,14587357,-22338025,13987525 ) + ), + new GroupElementPreComp( + new FieldElement( -24349909,7778775,21116000,15572597,-4833266,-5357778,-4300898,-5124639,-7469781,-2858068 ), + new FieldElement( 9681908,-6737123,-31951644,13591838,-6883821,386950,31622781,6439245,-14581012,4091397 ), + new FieldElement( -8426427,1470727,-28109679,-1596990,3978627,-5123623,-19622683,12092163,29077877,-14741988 ) + ), + new GroupElementPreComp( + new FieldElement( 5269168,-6859726,-13230211,-8020715,25932563,1763552,-5606110,-5505881,-20017847,2357889 ), + new FieldElement( 32264008,-15407652,-5387735,-1160093,-2091322,-3946900,23104804,-12869908,5727338,189038 ), + new FieldElement( 14609123,-8954470,-6000566,-16622781,-14577387,-7743898,-26745169,10942115,-25888931,-14884697 ) + ), + new GroupElementPreComp( + new FieldElement( 20513500,5557931,-15604613,7829531,26413943,-2019404,-21378968,7471781,13913677,-5137875 ), + new FieldElement( -25574376,11967826,29233242,12948236,-6754465,4713227,-8940970,14059180,12878652,8511905 ), + new FieldElement( -25656801,3393631,-2955415,-7075526,-2250709,9366908,-30223418,6812974,5568676,-3127656 ) + ), + new GroupElementPreComp( + new FieldElement( 11630004,12144454,2116339,13606037,27378885,15676917,-17408753,-13504373,-14395196,8070818 ), + new FieldElement( 27117696,-10007378,-31282771,-5570088,1127282,12772488,-29845906,10483306,-11552749,-1028714 ), + new FieldElement( 10637467,-5688064,5674781,1072708,-26343588,-6982302,-1683975,9177853,-27493162,15431203 ) + ), + new GroupElementPreComp( + new FieldElement( 20525145,10892566,-12742472,12779443,-29493034,16150075,-28240519,14943142,-15056790,-7935931 ), + new FieldElement( -30024462,5626926,-551567,-9981087,753598,11981191,25244767,-3239766,-3356550,9594024 ), + new FieldElement( -23752644,2636870,-5163910,-10103818,585134,7877383,11345683,-6492290,13352335,-10977084 ) + ), + new GroupElementPreComp( + new FieldElement( -1931799,-5407458,3304649,-12884869,17015806,-4877091,-29783850,-7752482,-13215537,-319204 ), + new FieldElement( 20239939,6607058,6203985,3483793,-18386976,-779229,-20723742,15077870,-22750759,14523817 ), + new FieldElement( 27406042,-6041657,27423596,-4497394,4996214,10002360,-28842031,-4545494,-30172742,-4805667 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 11374242,12660715,17861383,-12540833,10935568,1099227,-13886076,-9091740,-27727044,11358504 ), + new FieldElement( -12730809,10311867,1510375,10778093,-2119455,-9145702,32676003,11149336,-26123651,4985768 ), + new FieldElement( -19096303,341147,-6197485,-239033,15756973,-8796662,-983043,13794114,-19414307,-15621255 ) + ), + new GroupElementPreComp( + new FieldElement( 6490081,11940286,25495923,-7726360,8668373,-8751316,3367603,6970005,-1691065,-9004790 ), + new FieldElement( 1656497,13457317,15370807,6364910,13605745,8362338,-19174622,-5475723,-16796596,-5031438 ), + new FieldElement( -22273315,-13524424,-64685,-4334223,-18605636,-10921968,-20571065,-7007978,-99853,-10237333 ) + ), + new GroupElementPreComp( + new FieldElement( 17747465,10039260,19368299,-4050591,-20630635,-16041286,31992683,-15857976,-29260363,-5511971 ), + new FieldElement( 31932027,-4986141,-19612382,16366580,22023614,88450,11371999,-3744247,4882242,-10626905 ), + new FieldElement( 29796507,37186,19818052,10115756,-11829032,3352736,18551198,3272828,-5190932,-4162409 ) + ), + new GroupElementPreComp( + new FieldElement( 12501286,4044383,-8612957,-13392385,-32430052,5136599,-19230378,-3529697,330070,-3659409 ), + new FieldElement( 6384877,2899513,17807477,7663917,-2358888,12363165,25366522,-8573892,-271295,12071499 ), + new FieldElement( -8365515,-4042521,25133448,-4517355,-6211027,2265927,-32769618,1936675,-5159697,3829363 ) + ), + new GroupElementPreComp( + new FieldElement( 28425966,-5835433,-577090,-4697198,-14217555,6870930,7921550,-6567787,26333140,14267664 ), + new FieldElement( -11067219,11871231,27385719,-10559544,-4585914,-11189312,10004786,-8709488,-21761224,8930324 ), + new FieldElement( -21197785,-16396035,25654216,-1725397,12282012,11008919,1541940,4757911,-26491501,-16408940 ) + ), + new GroupElementPreComp( + new FieldElement( 13537262,-7759490,-20604840,10961927,-5922820,-13218065,-13156584,6217254,-15943699,13814990 ), + new FieldElement( -17422573,15157790,18705543,29619,24409717,-260476,27361681,9257833,-1956526,-1776914 ), + new FieldElement( -25045300,-10191966,15366585,15166509,-13105086,8423556,-29171540,12361135,-18685978,4578290 ) + ), + new GroupElementPreComp( + new FieldElement( 24579768,3711570,1342322,-11180126,-27005135,14124956,-22544529,14074919,21964432,8235257 ), + new FieldElement( -6528613,-2411497,9442966,-5925588,12025640,-1487420,-2981514,-1669206,13006806,2355433 ), + new FieldElement( -16304899,-13605259,-6632427,-5142349,16974359,-10911083,27202044,1719366,1141648,-12796236 ) + ), + new GroupElementPreComp( + new FieldElement( -12863944,-13219986,-8318266,-11018091,-6810145,-4843894,13475066,-3133972,32674895,13715045 ), + new FieldElement( 11423335,-5468059,32344216,8962751,24989809,9241752,-13265253,16086212,-28740881,-15642093 ), + new FieldElement( -1409668,12530728,-6368726,10847387,19531186,-14132160,-11709148,7791794,-27245943,4383347 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -28970898,5271447,-1266009,-9736989,-12455236,16732599,-4862407,-4906449,27193557,6245191 ), + new FieldElement( -15193956,5362278,-1783893,2695834,4960227,12840725,23061898,3260492,22510453,8577507 ), + new FieldElement( -12632451,11257346,-32692994,13548177,-721004,10879011,31168030,13952092,-29571492,-3635906 ) + ), + new GroupElementPreComp( + new FieldElement( 3877321,-9572739,32416692,5405324,-11004407,-13656635,3759769,11935320,5611860,8164018 ), + new FieldElement( -16275802,14667797,15906460,12155291,-22111149,-9039718,32003002,-8832289,5773085,-8422109 ), + new FieldElement( -23788118,-8254300,1950875,8937633,18686727,16459170,-905725,12376320,31632953,190926 ) + ), + new GroupElementPreComp( + new FieldElement( -24593607,-16138885,-8423991,13378746,14162407,6901328,-8288749,4508564,-25341555,-3627528 ), + new FieldElement( 8884438,-5884009,6023974,10104341,-6881569,-4941533,18722941,-14786005,-1672488,827625 ), + new FieldElement( -32720583,-16289296,-32503547,7101210,13354605,2659080,-1800575,-14108036,-24878478,1541286 ) + ), + new GroupElementPreComp( + new FieldElement( 2901347,-1117687,3880376,-10059388,-17620940,-3612781,-21802117,-3567481,20456845,-1885033 ), + new FieldElement( 27019610,12299467,-13658288,-1603234,-12861660,-4861471,-19540150,-5016058,29439641,15138866 ), + new FieldElement( 21536104,-6626420,-32447818,-10690208,-22408077,5175814,-5420040,-16361163,7779328,109896 ) + ), + new GroupElementPreComp( + new FieldElement( 30279744,14648750,-8044871,6425558,13639621,-743509,28698390,12180118,23177719,-554075 ), + new FieldElement( 26572847,3405927,-31701700,12890905,-19265668,5335866,-6493768,2378492,4439158,-13279347 ), + new FieldElement( -22716706,3489070,-9225266,-332753,18875722,-1140095,14819434,-12731527,-17717757,-5461437 ) + ), + new GroupElementPreComp( + new FieldElement( -5056483,16566551,15953661,3767752,-10436499,15627060,-820954,2177225,8550082,-15114165 ), + new FieldElement( -18473302,16596775,-381660,15663611,22860960,15585581,-27844109,-3582739,-23260460,-8428588 ), + new FieldElement( -32480551,15707275,-8205912,-5652081,29464558,2713815,-22725137,15860482,-21902570,1494193 ) + ), + new GroupElementPreComp( + new FieldElement( -19562091,-14087393,-25583872,-9299552,13127842,759709,21923482,16529112,8742704,12967017 ), + new FieldElement( -28464899,1553205,32536856,-10473729,-24691605,-406174,-8914625,-2933896,-29903758,15553883 ), + new FieldElement( 21877909,3230008,9881174,10539357,-4797115,2841332,11543572,14513274,19375923,-12647961 ) + ), + new GroupElementPreComp( + new FieldElement( 8832269,-14495485,13253511,5137575,5037871,4078777,24880818,-6222716,2862653,9455043 ), + new FieldElement( 29306751,5123106,20245049,-14149889,9592566,8447059,-2077124,-2990080,15511449,4789663 ), + new FieldElement( -20679756,7004547,8824831,-9434977,-4045704,-3750736,-5754762,108893,23513200,16652362 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -33256173,4144782,-4476029,-6579123,10770039,-7155542,-6650416,-12936300,-18319198,10212860 ), + new FieldElement( 2756081,8598110,7383731,-6859892,22312759,-1105012,21179801,2600940,-9988298,-12506466 ), + new FieldElement( -24645692,13317462,-30449259,-15653928,21365574,-10869657,11344424,864440,-2499677,-16710063 ) + ), + new GroupElementPreComp( + new FieldElement( -26432803,6148329,-17184412,-14474154,18782929,-275997,-22561534,211300,2719757,4940997 ), + new FieldElement( -1323882,3911313,-6948744,14759765,-30027150,7851207,21690126,8518463,26699843,5276295 ), + new FieldElement( -13149873,-6429067,9396249,365013,24703301,-10488939,1321586,149635,-15452774,7159369 ) + ), + new GroupElementPreComp( + new FieldElement( 9987780,-3404759,17507962,9505530,9731535,-2165514,22356009,8312176,22477218,-8403385 ), + new FieldElement( 18155857,-16504990,19744716,9006923,15154154,-10538976,24256460,-4864995,-22548173,9334109 ), + new FieldElement( 2986088,-4911893,10776628,-3473844,10620590,-7083203,-21413845,14253545,-22587149,536906 ) + ), + new GroupElementPreComp( + new FieldElement( 4377756,8115836,24567078,15495314,11625074,13064599,7390551,10589625,10838060,-15420424 ), + new FieldElement( -19342404,867880,9277171,-3218459,-14431572,-1986443,19295826,-15796950,6378260,699185 ), + new FieldElement( 7895026,4057113,-7081772,-13077756,-17886831,-323126,-716039,15693155,-5045064,-13373962 ) + ), + new GroupElementPreComp( + new FieldElement( -7737563,-5869402,-14566319,-7406919,11385654,13201616,31730678,-10962840,-3918636,-9669325 ), + new FieldElement( 10188286,-15770834,-7336361,13427543,22223443,14896287,30743455,7116568,-21786507,5427593 ), + new FieldElement( 696102,13206899,27047647,-10632082,15285305,-9853179,10798490,-4578720,19236243,12477404 ) + ), + new GroupElementPreComp( + new FieldElement( -11229439,11243796,-17054270,-8040865,-788228,-8167967,-3897669,11180504,-23169516,7733644 ), + new FieldElement( 17800790,-14036179,-27000429,-11766671,23887827,3149671,23466177,-10538171,10322027,15313801 ), + new FieldElement( 26246234,11968874,32263343,-5468728,6830755,-13323031,-15794704,-101982,-24449242,10890804 ) + ), + new GroupElementPreComp( + new FieldElement( -31365647,10271363,-12660625,-6267268,16690207,-13062544,-14982212,16484931,25180797,-5334884 ), + new FieldElement( -586574,10376444,-32586414,-11286356,19801893,10997610,2276632,9482883,316878,13820577 ), + new FieldElement( -9882808,-4510367,-2115506,16457136,-11100081,11674996,30756178,-7515054,30696930,-3712849 ) + ), + new GroupElementPreComp( + new FieldElement( 32988917,-9603412,12499366,7910787,-10617257,-11931514,-7342816,-9985397,-32349517,7392473 ), + new FieldElement( -8855661,15927861,9866406,-3649411,-2396914,-16655781,-30409476,-9134995,25112947,-2926644 ), + new FieldElement( -2504044,-436966,25621774,-5678772,15085042,-5479877,-24884878,-13526194,5537438,-13914319 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -11225584,2320285,-9584280,10149187,-33444663,5808648,-14876251,-1729667,31234590,6090599 ), + new FieldElement( -9633316,116426,26083934,2897444,-6364437,-2688086,609721,15878753,-6970405,-9034768 ), + new FieldElement( -27757857,247744,-15194774,-9002551,23288161,-10011936,-23869595,6503646,20650474,1804084 ) + ), + new GroupElementPreComp( + new FieldElement( -27589786,15456424,8972517,8469608,15640622,4439847,3121995,-10329713,27842616,-202328 ), + new FieldElement( -15306973,2839644,22530074,10026331,4602058,5048462,28248656,5031932,-11375082,12714369 ), + new FieldElement( 20807691,-7270825,29286141,11421711,-27876523,-13868230,-21227475,1035546,-19733229,12796920 ) + ), + new GroupElementPreComp( + new FieldElement( 12076899,-14301286,-8785001,-11848922,-25012791,16400684,-17591495,-12899438,3480665,-15182815 ), + new FieldElement( -32361549,5457597,28548107,7833186,7303070,-11953545,-24363064,-15921875,-33374054,2771025 ), + new FieldElement( -21389266,421932,26597266,6860826,22486084,-6737172,-17137485,-4210226,-24552282,15673397 ) + ), + new GroupElementPreComp( + new FieldElement( -20184622,2338216,19788685,-9620956,-4001265,-8740893,-20271184,4733254,3727144,-12934448 ), + new FieldElement( 6120119,814863,-11794402,-622716,6812205,-15747771,2019594,7975683,31123697,-10958981 ), + new FieldElement( 30069250,-11435332,30434654,2958439,18399564,-976289,12296869,9204260,-16432438,9648165 ) + ), + new GroupElementPreComp( + new FieldElement( 32705432,-1550977,30705658,7451065,-11805606,9631813,3305266,5248604,-26008332,-11377501 ), + new FieldElement( 17219865,2375039,-31570947,-5575615,-19459679,9219903,294711,15298639,2662509,-16297073 ), + new FieldElement( -1172927,-7558695,-4366770,-4287744,-21346413,-8434326,32087529,-1222777,32247248,-14389861 ) + ), + new GroupElementPreComp( + new FieldElement( 14312628,1221556,17395390,-8700143,-4945741,-8684635,-28197744,-9637817,-16027623,-13378845 ), + new FieldElement( -1428825,-9678990,-9235681,6549687,-7383069,-468664,23046502,9803137,17597934,2346211 ), + new FieldElement( 18510800,15337574,26171504,981392,-22241552,7827556,-23491134,-11323352,3059833,-11782870 ) + ), + new GroupElementPreComp( + new FieldElement( 10141598,6082907,17829293,-1947643,9830092,13613136,-25556636,-5544586,-33502212,3592096 ), + new FieldElement( 33114168,-15889352,-26525686,-13343397,33076705,8716171,1151462,1521897,-982665,-6837803 ), + new FieldElement( -32939165,-4255815,23947181,-324178,-33072974,-12305637,-16637686,3891704,26353178,693168 ) + ), + new GroupElementPreComp( + new FieldElement( 30374239,1595580,-16884039,13186931,4600344,406904,9585294,-400668,31375464,14369965 ), + new FieldElement( -14370654,-7772529,1510301,6434173,-18784789,-6262728,32732230,-13108839,17901441,16011505 ), + new FieldElement( 18171223,-11934626,-12500402,15197122,-11038147,-15230035,-19172240,-16046376,8764035,12309598 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 5975908,-5243188,-19459362,-9681747,-11541277,14015782,-23665757,1228319,17544096,-10593782 ), + new FieldElement( 5811932,-1715293,3442887,-2269310,-18367348,-8359541,-18044043,-15410127,-5565381,12348900 ), + new FieldElement( -31399660,11407555,25755363,6891399,-3256938,14872274,-24849353,8141295,-10632534,-585479 ) + ), + new GroupElementPreComp( + new FieldElement( -12675304,694026,-5076145,13300344,14015258,-14451394,-9698672,-11329050,30944593,1130208 ), + new FieldElement( 8247766,-6710942,-26562381,-7709309,-14401939,-14648910,4652152,2488540,23550156,-271232 ), + new FieldElement( 17294316,-3788438,7026748,15626851,22990044,113481,2267737,-5908146,-408818,-137719 ) + ), + new GroupElementPreComp( + new FieldElement( 16091085,-16253926,18599252,7340678,2137637,-1221657,-3364161,14550936,3260525,-7166271 ), + new FieldElement( -4910104,-13332887,18550887,10864893,-16459325,-7291596,-23028869,-13204905,-12748722,2701326 ), + new FieldElement( -8574695,16099415,4629974,-16340524,-20786213,-6005432,-10018363,9276971,11329923,1862132 ) + ), + new GroupElementPreComp( + new FieldElement( 14763076,-15903608,-30918270,3689867,3511892,10313526,-21951088,12219231,-9037963,-940300 ), + new FieldElement( 8894987,-3446094,6150753,3013931,301220,15693451,-31981216,-2909717,-15438168,11595570 ), + new FieldElement( 15214962,3537601,-26238722,-14058872,4418657,-15230761,13947276,10730794,-13489462,-4363670 ) + ), + new GroupElementPreComp( + new FieldElement( -2538306,7682793,32759013,263109,-29984731,-7955452,-22332124,-10188635,977108,699994 ), + new FieldElement( -12466472,4195084,-9211532,550904,-15565337,12917920,19118110,-439841,-30534533,-14337913 ), + new FieldElement( 31788461,-14507657,4799989,7372237,8808585,-14747943,9408237,-10051775,12493932,-5409317 ) + ), + new GroupElementPreComp( + new FieldElement( -25680606,5260744,-19235809,-6284470,-3695942,16566087,27218280,2607121,29375955,6024730 ), + new FieldElement( 842132,-2794693,-4763381,-8722815,26332018,-12405641,11831880,6985184,-9940361,2854096 ), + new FieldElement( -4847262,-7969331,2516242,-5847713,9695691,-7221186,16512645,960770,12121869,16648078 ) + ), + new GroupElementPreComp( + new FieldElement( -15218652,14667096,-13336229,2013717,30598287,-464137,-31504922,-7882064,20237806,2838411 ), + new FieldElement( -19288047,4453152,15298546,-16178388,22115043,-15972604,12544294,-13470457,1068881,-12499905 ), + new FieldElement( -9558883,-16518835,33238498,13506958,30505848,-1114596,-8486907,-2630053,12521378,4845654 ) + ), + new GroupElementPreComp( + new FieldElement( -28198521,10744108,-2958380,10199664,7759311,-13088600,3409348,-873400,-6482306,-12885870 ), + new FieldElement( -23561822,6230156,-20382013,10655314,-24040585,-11621172,10477734,-1240216,-3113227,13974498 ), + new FieldElement( 12966261,15550616,-32038948,-1615346,21025980,-629444,5642325,7188737,18895762,12629579 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 14741879,-14946887,22177208,-11721237,1279741,8058600,11758140,789443,32195181,3895677 ), + new FieldElement( 10758205,15755439,-4509950,9243698,-4879422,6879879,-2204575,-3566119,-8982069,4429647 ), + new FieldElement( -2453894,15725973,-20436342,-10410672,-5803908,-11040220,-7135870,-11642895,18047436,-15281743 ) + ), + new GroupElementPreComp( + new FieldElement( -25173001,-11307165,29759956,11776784,-22262383,-15820455,10993114,-12850837,-17620701,-9408468 ), + new FieldElement( 21987233,700364,-24505048,14972008,-7774265,-5718395,32155026,2581431,-29958985,8773375 ), + new FieldElement( -25568350,454463,-13211935,16126715,25240068,8594567,20656846,12017935,-7874389,-13920155 ) + ), + new GroupElementPreComp( + new FieldElement( 6028182,6263078,-31011806,-11301710,-818919,2461772,-31841174,-5468042,-1721788,-2776725 ), + new FieldElement( -12278994,16624277,987579,-5922598,32908203,1248608,7719845,-4166698,28408820,6816612 ), + new FieldElement( -10358094,-8237829,19549651,-12169222,22082623,16147817,20613181,13982702,-10339570,5067943 ) + ), + new GroupElementPreComp( + new FieldElement( -30505967,-3821767,12074681,13582412,-19877972,2443951,-19719286,12746132,5331210,-10105944 ), + new FieldElement( 30528811,3601899,-1957090,4619785,-27361822,-15436388,24180793,-12570394,27679908,-1648928 ), + new FieldElement( 9402404,-13957065,32834043,10838634,-26580150,-13237195,26653274,-8685565,22611444,-12715406 ) + ), + new GroupElementPreComp( + new FieldElement( 22190590,1118029,22736441,15130463,-30460692,-5991321,19189625,-4648942,4854859,6622139 ), + new FieldElement( -8310738,-2953450,-8262579,-3388049,-10401731,-271929,13424426,-3567227,26404409,13001963 ), + new FieldElement( -31241838,-15415700,-2994250,8939346,11562230,-12840670,-26064365,-11621720,-15405155,11020693 ) + ), + new GroupElementPreComp( + new FieldElement( 1866042,-7949489,-7898649,-10301010,12483315,13477547,3175636,-12424163,28761762,1406734 ), + new FieldElement( -448555,-1777666,13018551,3194501,-9580420,-11161737,24760585,-4347088,25577411,-13378680 ), + new FieldElement( -24290378,4759345,-690653,-1852816,2066747,10693769,-29595790,9884936,-9368926,4745410 ) + ), + new GroupElementPreComp( + new FieldElement( -9141284,6049714,-19531061,-4341411,-31260798,9944276,-15462008,-11311852,10931924,-11931931 ), + new FieldElement( -16561513,14112680,-8012645,4817318,-8040464,-11414606,-22853429,10856641,-20470770,13434654 ), + new FieldElement( 22759489,-10073434,-16766264,-1871422,13637442,-10168091,1765144,-12654326,28445307,-5364710 ) + ), + new GroupElementPreComp( + new FieldElement( 29875063,12493613,2795536,-3786330,1710620,15181182,-10195717,-8788675,9074234,1167180 ), + new FieldElement( -26205683,11014233,-9842651,-2635485,-26908120,7532294,-18716888,-9535498,3843903,9367684 ), + new FieldElement( -10969595,-6403711,9591134,9582310,11349256,108879,16235123,8601684,-139197,4242895 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 22092954,-13191123,-2042793,-11968512,32186753,-11517388,-6574341,2470660,-27417366,16625501 ), + new FieldElement( -11057722,3042016,13770083,-9257922,584236,-544855,-7770857,2602725,-27351616,14247413 ), + new FieldElement( 6314175,-10264892,-32772502,15957557,-10157730,168750,-8618807,14290061,27108877,-1180880 ) + ), + new GroupElementPreComp( + new FieldElement( -8586597,-7170966,13241782,10960156,-32991015,-13794596,33547976,-11058889,-27148451,981874 ), + new FieldElement( 22833440,9293594,-32649448,-13618667,-9136966,14756819,-22928859,-13970780,-10479804,-16197962 ), + new FieldElement( -7768587,3326786,-28111797,10783824,19178761,14905060,22680049,13906969,-15933690,3797899 ) + ), + new GroupElementPreComp( + new FieldElement( 21721356,-4212746,-12206123,9310182,-3882239,-13653110,23740224,-2709232,20491983,-8042152 ), + new FieldElement( 9209270,-15135055,-13256557,-6167798,-731016,15289673,25947805,15286587,30997318,-6703063 ), + new FieldElement( 7392032,16618386,23946583,-8039892,-13265164,-1533858,-14197445,-2321576,17649998,-250080 ) + ), + new GroupElementPreComp( + new FieldElement( -9301088,-14193827,30609526,-3049543,-25175069,-1283752,-15241566,-9525724,-2233253,7662146 ), + new FieldElement( -17558673,1763594,-33114336,15908610,-30040870,-12174295,7335080,-8472199,-3174674,3440183 ), + new FieldElement( -19889700,-5977008,-24111293,-9688870,10799743,-16571957,40450,-4431835,4862400,1133 ) + ), + new GroupElementPreComp( + new FieldElement( -32856209,-7873957,-5422389,14860950,-16319031,7956142,7258061,311861,-30594991,-7379421 ), + new FieldElement( -3773428,-1565936,28985340,7499440,24445838,9325937,29727763,16527196,18278453,15405622 ), + new FieldElement( -4381906,8508652,-19898366,-3674424,-5984453,15149970,-13313598,843523,-21875062,13626197 ) + ), + new GroupElementPreComp( + new FieldElement( 2281448,-13487055,-10915418,-2609910,1879358,16164207,-10783882,3953792,13340839,15928663 ), + new FieldElement( 31727126,-7179855,-18437503,-8283652,2875793,-16390330,-25269894,-7014826,-23452306,5964753 ), + new FieldElement( 4100420,-5959452,-17179337,6017714,-18705837,12227141,-26684835,11344144,2538215,-7570755 ) + ), + new GroupElementPreComp( + new FieldElement( -9433605,6123113,11159803,-2156608,30016280,14966241,-20474983,1485421,-629256,-15958862 ), + new FieldElement( -26804558,4260919,11851389,9658551,-32017107,16367492,-20205425,-13191288,11659922,-11115118 ), + new FieldElement( 26180396,10015009,-30844224,-8581293,5418197,9480663,2231568,-10170080,33100372,-1306171 ) + ), + new GroupElementPreComp( + new FieldElement( 15121113,-5201871,-10389905,15427821,-27509937,-15992507,21670947,4486675,-5931810,-14466380 ), + new FieldElement( 16166486,-9483733,-11104130,6023908,-31926798,-1364923,2340060,-16254968,-10735770,-10039824 ), + new FieldElement( 28042865,-3557089,-12126526,12259706,-3717498,-6945899,6766453,-8689599,18036436,5803270 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -817581,6763912,11803561,1585585,10958447,-2671165,23855391,4598332,-6159431,-14117438 ), + new FieldElement( -31031306,-14256194,17332029,-2383520,31312682,-5967183,696309,50292,-20095739,11763584 ), + new FieldElement( -594563,-2514283,-32234153,12643980,12650761,14811489,665117,-12613632,-19773211,-10713562 ) + ), + new GroupElementPreComp( + new FieldElement( 30464590,-11262872,-4127476,-12734478,19835327,-7105613,-24396175,2075773,-17020157,992471 ), + new FieldElement( 18357185,-6994433,7766382,16342475,-29324918,411174,14578841,8080033,-11574335,-10601610 ), + new FieldElement( 19598397,10334610,12555054,2555664,18821899,-10339780,21873263,16014234,26224780,16452269 ) + ), + new GroupElementPreComp( + new FieldElement( -30223925,5145196,5944548,16385966,3976735,2009897,-11377804,-7618186,-20533829,3698650 ), + new FieldElement( 14187449,3448569,-10636236,-10810935,-22663880,-3433596,7268410,-10890444,27394301,12015369 ), + new FieldElement( 19695761,16087646,28032085,12999827,6817792,11427614,20244189,-1312777,-13259127,-3402461 ) + ), + new GroupElementPreComp( + new FieldElement( 30860103,12735208,-1888245,-4699734,-16974906,2256940,-8166013,12298312,-8550524,-10393462 ), + new FieldElement( -5719826,-11245325,-1910649,15569035,26642876,-7587760,-5789354,-15118654,-4976164,12651793 ), + new FieldElement( -2848395,9953421,11531313,-5282879,26895123,-12697089,-13118820,-16517902,9768698,-2533218 ) + ), + new GroupElementPreComp( + new FieldElement( -24719459,1894651,-287698,-4704085,15348719,-8156530,32767513,12765450,4940095,10678226 ), + new FieldElement( 18860224,15980149,-18987240,-1562570,-26233012,-11071856,-7843882,13944024,-24372348,16582019 ), + new FieldElement( -15504260,4970268,-29893044,4175593,-20993212,-2199756,-11704054,15444560,-11003761,7989037 ) + ), + new GroupElementPreComp( + new FieldElement( 31490452,5568061,-2412803,2182383,-32336847,4531686,-32078269,6200206,-19686113,-14800171 ), + new FieldElement( -17308668,-15879940,-31522777,-2831,-32887382,16375549,8680158,-16371713,28550068,-6857132 ), + new FieldElement( -28126887,-5688091,16837845,-1820458,-6850681,12700016,-30039981,4364038,1155602,5988841 ) + ), + new GroupElementPreComp( + new FieldElement( 21890435,-13272907,-12624011,12154349,-7831873,15300496,23148983,-4470481,24618407,8283181 ), + new FieldElement( -33136107,-10512751,9975416,6841041,-31559793,16356536,3070187,-7025928,1466169,10740210 ), + new FieldElement( -1509399,-15488185,-13503385,-10655916,32799044,909394,-13938903,-5779719,-32164649,-15327040 ) + ), + new GroupElementPreComp( + new FieldElement( 3960823,-14267803,-28026090,-15918051,-19404858,13146868,15567327,951507,-3260321,-573935 ), + new FieldElement( 24740841,5052253,-30094131,8961361,25877428,6165135,-24368180,14397372,-7380369,-6144105 ), + new FieldElement( -28888365,3510803,-28103278,-1158478,-11238128,-10631454,-15441463,-14453128,-1625486,-6494814 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 793299,-9230478,8836302,-6235707,-27360908,-2369593,33152843,-4885251,-9906200,-621852 ), + new FieldElement( 5666233,525582,20782575,-8038419,-24538499,14657740,16099374,1468826,-6171428,-15186581 ), + new FieldElement( -4859255,-3779343,-2917758,-6748019,7778750,11688288,-30404353,-9871238,-1558923,-9863646 ) + ), + new GroupElementPreComp( + new FieldElement( 10896332,-7719704,824275,472601,-19460308,3009587,25248958,14783338,-30581476,-15757844 ), + new FieldElement( 10566929,12612572,-31944212,11118703,-12633376,12362879,21752402,8822496,24003793,14264025 ), + new FieldElement( 27713862,-7355973,-11008240,9227530,27050101,2504721,23886875,-13117525,13958495,-5732453 ) + ), + new GroupElementPreComp( + new FieldElement( -23481610,4867226,-27247128,3900521,29838369,-8212291,-31889399,-10041781,7340521,-15410068 ), + new FieldElement( 4646514,-8011124,-22766023,-11532654,23184553,8566613,31366726,-1381061,-15066784,-10375192 ), + new FieldElement( -17270517,12723032,-16993061,14878794,21619651,-6197576,27584817,3093888,-8843694,3849921 ) + ), + new GroupElementPreComp( + new FieldElement( -9064912,2103172,25561640,-15125738,-5239824,9582958,32477045,-9017955,5002294,-15550259 ), + new FieldElement( -12057553,-11177906,21115585,-13365155,8808712,-12030708,16489530,13378448,-25845716,12741426 ), + new FieldElement( -5946367,10645103,-30911586,15390284,-3286982,-7118677,24306472,15852464,28834118,-7646072 ) + ), + new GroupElementPreComp( + new FieldElement( -17335748,-9107057,-24531279,9434953,-8472084,-583362,-13090771,455841,20461858,5491305 ), + new FieldElement( 13669248,-16095482,-12481974,-10203039,-14569770,-11893198,-24995986,11293807,-28588204,-9421832 ), + new FieldElement( 28497928,6272777,-33022994,14470570,8906179,-1225630,18504674,-14165166,29867745,-8795943 ) + ), + new GroupElementPreComp( + new FieldElement( -16207023,13517196,-27799630,-13697798,24009064,-6373891,-6367600,-13175392,22853429,-4012011 ), + new FieldElement( 24191378,16712145,-13931797,15217831,14542237,1646131,18603514,-11037887,12876623,-2112447 ), + new FieldElement( 17902668,4518229,-411702,-2829247,26878217,5258055,-12860753,608397,16031844,3723494 ) + ), + new GroupElementPreComp( + new FieldElement( -28632773,12763728,-20446446,7577504,33001348,-13017745,17558842,-7872890,23896954,-4314245 ), + new FieldElement( -20005381,-12011952,31520464,605201,2543521,5991821,-2945064,7229064,-9919646,-8826859 ), + new FieldElement( 28816045,298879,-28165016,-15920938,19000928,-1665890,-12680833,-2949325,-18051778,-2082915 ) + ), + new GroupElementPreComp( + new FieldElement( 16000882,-344896,3493092,-11447198,-29504595,-13159789,12577740,16041268,-19715240,7847707 ), + new FieldElement( 10151868,10572098,27312476,7922682,14825339,4723128,-32855931,-6519018,-10020567,3852848 ), + new FieldElement( -11430470,15697596,-21121557,-4420647,5386314,15063598,16514493,-15932110,29330899,-15076224 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -25499735,-4378794,-15222908,-6901211,16615731,2051784,3303702,15490,-27548796,12314391 ), + new FieldElement( 15683520,-6003043,18109120,-9980648,15337968,-5997823,-16717435,15921866,16103996,-3731215 ), + new FieldElement( -23169824,-10781249,13588192,-1628807,-3798557,-1074929,-19273607,5402699,-29815713,-9841101 ) + ), + new GroupElementPreComp( + new FieldElement( 23190676,2384583,-32714340,3462154,-29903655,-1529132,-11266856,8911517,-25205859,2739713 ), + new FieldElement( 21374101,-3554250,-33524649,9874411,15377179,11831242,-33529904,6134907,4931255,11987849 ), + new FieldElement( -7732,-2978858,-16223486,7277597,105524,-322051,-31480539,13861388,-30076310,10117930 ) + ), + new GroupElementPreComp( + new FieldElement( -29501170,-10744872,-26163768,13051539,-25625564,5089643,-6325503,6704079,12890019,15728940 ), + new FieldElement( -21972360,-11771379,-951059,-4418840,14704840,2695116,903376,-10428139,12885167,8311031 ), + new FieldElement( -17516482,5352194,10384213,-13811658,7506451,13453191,26423267,4384730,1888765,-5435404 ) + ), + new GroupElementPreComp( + new FieldElement( -25817338,-3107312,-13494599,-3182506,30896459,-13921729,-32251644,-12707869,-19464434,-3340243 ), + new FieldElement( -23607977,-2665774,-526091,4651136,5765089,4618330,6092245,14845197,17151279,-9854116 ), + new FieldElement( -24830458,-12733720,-15165978,10367250,-29530908,-265356,22825805,-7087279,-16866484,16176525 ) + ), + new GroupElementPreComp( + new FieldElement( -23583256,6564961,20063689,3798228,-4740178,7359225,2006182,-10363426,-28746253,-10197509 ), + new FieldElement( -10626600,-4486402,-13320562,-5125317,3432136,-6393229,23632037,-1940610,32808310,1099883 ), + new FieldElement( 15030977,5768825,-27451236,-2887299,-6427378,-15361371,-15277896,-6809350,2051441,-15225865 ) + ), + new GroupElementPreComp( + new FieldElement( -3362323,-7239372,7517890,9824992,23555850,295369,5148398,-14154188,-22686354,16633660 ), + new FieldElement( 4577086,-16752288,13249841,-15304328,19958763,-14537274,18559670,-10759549,8402478,-9864273 ), + new FieldElement( -28406330,-1051581,-26790155,-907698,-17212414,-11030789,9453451,-14980072,17983010,9967138 ) + ), + new GroupElementPreComp( + new FieldElement( -25762494,6524722,26585488,9969270,24709298,1220360,-1677990,7806337,17507396,3651560 ), + new FieldElement( -10420457,-4118111,14584639,15971087,-15768321,8861010,26556809,-5574557,-18553322,-11357135 ), + new FieldElement( 2839101,14284142,4029895,3472686,14402957,12689363,-26642121,8459447,-5605463,-7621941 ) + ), + new GroupElementPreComp( + new FieldElement( -4839289,-3535444,9744961,2871048,25113978,3187018,-25110813,-849066,17258084,-7977739 ), + new FieldElement( 18164541,-10595176,-17154882,-1542417,19237078,-9745295,23357533,-15217008,26908270,12150756 ), + new FieldElement( -30264870,-7647865,5112249,-7036672,-1499807,-6974257,43168,-5537701,-32302074,16215819 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -6898905,9824394,-12304779,-4401089,-31397141,-6276835,32574489,12532905,-7503072,-8675347 ), + new FieldElement( -27343522,-16515468,-27151524,-10722951,946346,16291093,254968,7168080,21676107,-1943028 ), + new FieldElement( 21260961,-8424752,-16831886,-11920822,-23677961,3968121,-3651949,-6215466,-3556191,-7913075 ) + ), + new GroupElementPreComp( + new FieldElement( 16544754,13250366,-16804428,15546242,-4583003,12757258,-2462308,-8680336,-18907032,-9662799 ), + new FieldElement( -2415239,-15577728,18312303,4964443,-15272530,-12653564,26820651,16690659,25459437,-4564609 ), + new FieldElement( -25144690,11425020,28423002,-11020557,-6144921,-15826224,9142795,-2391602,-6432418,-1644817 ) + ), + new GroupElementPreComp( + new FieldElement( -23104652,6253476,16964147,-3768872,-25113972,-12296437,-27457225,-16344658,6335692,7249989 ), + new FieldElement( -30333227,13979675,7503222,-12368314,-11956721,-4621693,-30272269,2682242,25993170,-12478523 ), + new FieldElement( 4364628,5930691,32304656,-10044554,-8054781,15091131,22857016,-10598955,31820368,15075278 ) + ), + new GroupElementPreComp( + new FieldElement( 31879134,-8918693,17258761,90626,-8041836,-4917709,24162788,-9650886,-17970238,12833045 ), + new FieldElement( 19073683,14851414,-24403169,-11860168,7625278,11091125,-19619190,2074449,-9413939,14905377 ), + new FieldElement( 24483667,-11935567,-2518866,-11547418,-1553130,15355506,-25282080,9253129,27628530,-7555480 ) + ), + new GroupElementPreComp( + new FieldElement( 17597607,8340603,19355617,552187,26198470,-3176583,4593324,-9157582,-14110875,15297016 ), + new FieldElement( 510886,14337390,-31785257,16638632,6328095,2713355,-20217417,-11864220,8683221,2921426 ), + new FieldElement( 18606791,11874196,27155355,-5281482,-24031742,6265446,-25178240,-1278924,4674690,13890525 ) + ), + new GroupElementPreComp( + new FieldElement( 13609624,13069022,-27372361,-13055908,24360586,9592974,14977157,9835105,4389687,288396 ), + new FieldElement( 9922506,-519394,13613107,5883594,-18758345,-434263,-12304062,8317628,23388070,16052080 ), + new FieldElement( 12720016,11937594,-31970060,-5028689,26900120,8561328,-20155687,-11632979,-14754271,-10812892 ) + ), + new GroupElementPreComp( + new FieldElement( 15961858,14150409,26716931,-665832,-22794328,13603569,11829573,7467844,-28822128,929275 ), + new FieldElement( 11038231,-11582396,-27310482,-7316562,-10498527,-16307831,-23479533,-9371869,-21393143,2465074 ), + new FieldElement( 20017163,-4323226,27915242,1529148,12396362,15675764,13817261,-9658066,2463391,-4622140 ) + ), + new GroupElementPreComp( + new FieldElement( -16358878,-12663911,-12065183,4996454,-1256422,1073572,9583558,12851107,4003896,12673717 ), + new FieldElement( -1731589,-15155870,-3262930,16143082,19294135,13385325,14741514,-9103726,7903886,2348101 ), + new FieldElement( 24536016,-16515207,12715592,-3862155,1511293,10047386,-3842346,-7129159,-28377538,10048127 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -12622226,-6204820,30718825,2591312,-10617028,12192840,18873298,-7297090,-32297756,15221632 ), + new FieldElement( -26478122,-11103864,11546244,-1852483,9180880,7656409,-21343950,2095755,29769758,6593415 ), + new FieldElement( -31994208,-2907461,4176912,3264766,12538965,-868111,26312345,-6118678,30958054,8292160 ) + ), + new GroupElementPreComp( + new FieldElement( 31429822,-13959116,29173532,15632448,12174511,-2760094,32808831,3977186,26143136,-3148876 ), + new FieldElement( 22648901,1402143,-22799984,13746059,7936347,365344,-8668633,-1674433,-3758243,-2304625 ), + new FieldElement( -15491917,8012313,-2514730,-12702462,-23965846,-10254029,-1612713,-1535569,-16664475,8194478 ) + ), + new GroupElementPreComp( + new FieldElement( 27338066,-7507420,-7414224,10140405,-19026427,-6589889,27277191,8855376,28572286,3005164 ), + new FieldElement( 26287124,4821776,25476601,-4145903,-3764513,-15788984,-18008582,1182479,-26094821,-13079595 ), + new FieldElement( -7171154,3178080,23970071,6201893,-17195577,-4489192,-21876275,-13982627,32208683,-1198248 ) + ), + new GroupElementPreComp( + new FieldElement( -16657702,2817643,-10286362,14811298,6024667,13349505,-27315504,-10497842,-27672585,-11539858 ), + new FieldElement( 15941029,-9405932,-21367050,8062055,31876073,-238629,-15278393,-1444429,15397331,-4130193 ), + new FieldElement( 8934485,-13485467,-23286397,-13423241,-32446090,14047986,31170398,-1441021,-27505566,15087184 ) + ), + new GroupElementPreComp( + new FieldElement( -18357243,-2156491,24524913,-16677868,15520427,-6360776,-15502406,11461896,16788528,-5868942 ), + new FieldElement( -1947386,16013773,21750665,3714552,-17401782,-16055433,-3770287,-10323320,31322514,-11615635 ), + new FieldElement( 21426655,-5650218,-13648287,-5347537,-28812189,-4920970,-18275391,-14621414,13040862,-12112948 ) + ), + new GroupElementPreComp( + new FieldElement( 11293895,12478086,-27136401,15083750,-29307421,14748872,14555558,-13417103,1613711,4896935 ), + new FieldElement( -25894883,15323294,-8489791,-8057900,25967126,-13425460,2825960,-4897045,-23971776,-11267415 ), + new FieldElement( -15924766,-5229880,-17443532,6410664,3622847,10243618,20615400,12405433,-23753030,-8436416 ) + ), + new GroupElementPreComp( + new FieldElement( -7091295,12556208,-20191352,9025187,-17072479,4333801,4378436,2432030,23097949,-566018 ), + new FieldElement( 4565804,-16025654,20084412,-7842817,1724999,189254,24767264,10103221,-18512313,2424778 ), + new FieldElement( 366633,-11976806,8173090,-6890119,30788634,5745705,-7168678,1344109,-3642553,12412659 ) + ), + new GroupElementPreComp( + new FieldElement( -24001791,7690286,14929416,-168257,-32210835,-13412986,24162697,-15326504,-3141501,11179385 ), + new FieldElement( 18289522,-14724954,8056945,16430056,-21729724,7842514,-6001441,-1486897,-18684645,-11443503 ), + new FieldElement( 476239,6601091,-6152790,-9723375,17503545,-4863900,27672959,13403813,11052904,5219329 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 20678546,-8375738,-32671898,8849123,-5009758,14574752,31186971,-3973730,9014762,-8579056 ), + new FieldElement( -13644050,-10350239,-15962508,5075808,-1514661,-11534600,-33102500,9160280,8473550,-3256838 ), + new FieldElement( 24900749,14435722,17209120,-15292541,-22592275,9878983,-7689309,-16335821,-24568481,11788948 ) + ), + new GroupElementPreComp( + new FieldElement( -3118155,-11395194,-13802089,14797441,9652448,-6845904,-20037437,10410733,-24568470,-1458691 ), + new FieldElement( -15659161,16736706,-22467150,10215878,-9097177,7563911,11871841,-12505194,-18513325,8464118 ), + new FieldElement( -23400612,8348507,-14585951,-861714,-3950205,-6373419,14325289,8628612,33313881,-8370517 ) + ), + new GroupElementPreComp( + new FieldElement( -20186973,-4967935,22367356,5271547,-1097117,-4788838,-24805667,-10236854,-8940735,-5818269 ), + new FieldElement( -6948785,-1795212,-32625683,-16021179,32635414,-7374245,15989197,-12838188,28358192,-4253904 ), + new FieldElement( -23561781,-2799059,-32351682,-1661963,-9147719,10429267,-16637684,4072016,-5351664,5596589 ) + ), + new GroupElementPreComp( + new FieldElement( -28236598,-3390048,12312896,6213178,3117142,16078565,29266239,2557221,1768301,15373193 ), + new FieldElement( -7243358,-3246960,-4593467,-7553353,-127927,-912245,-1090902,-4504991,-24660491,3442910 ), + new FieldElement( -30210571,5124043,14181784,8197961,18964734,-11939093,22597931,7176455,-18585478,13365930 ) + ), + new GroupElementPreComp( + new FieldElement( -7877390,-1499958,8324673,4690079,6261860,890446,24538107,-8570186,-9689599,-3031667 ), + new FieldElement( 25008904,-10771599,-4305031,-9638010,16265036,15721635,683793,-11823784,15723479,-15163481 ), + new FieldElement( -9660625,12374379,-27006999,-7026148,-7724114,-12314514,11879682,5400171,519526,-1235876 ) + ), + new GroupElementPreComp( + new FieldElement( 22258397,-16332233,-7869817,14613016,-22520255,-2950923,-20353881,7315967,16648397,7605640 ), + new FieldElement( -8081308,-8464597,-8223311,9719710,19259459,-15348212,23994942,-5281555,-9468848,4763278 ), + new FieldElement( -21699244,9220969,-15730624,1084137,-25476107,-2852390,31088447,-7764523,-11356529,728112 ) + ), + new GroupElementPreComp( + new FieldElement( 26047220,-11751471,-6900323,-16521798,24092068,9158119,-4273545,-12555558,-29365436,-5498272 ), + new FieldElement( 17510331,-322857,5854289,8403524,17133918,-3112612,-28111007,12327945,10750447,10014012 ), + new FieldElement( -10312768,3936952,9156313,-8897683,16498692,-994647,-27481051,-666732,3424691,7540221 ) + ), + new GroupElementPreComp( + new FieldElement( 30322361,-6964110,11361005,-4143317,7433304,4989748,-7071422,-16317219,-9244265,15258046 ), + new FieldElement( 13054562,-2779497,19155474,469045,-12482797,4566042,5631406,2711395,1062915,-5136345 ), + new FieldElement( -19240248,-11254599,-29509029,-7499965,-5835763,13005411,-6066489,12194497,32960380,1459310 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 19852034,7027924,23669353,10020366,8586503,-6657907,394197,-6101885,18638003,-11174937 ), + new FieldElement( 31395534,15098109,26581030,8030562,-16527914,-5007134,9012486,-7584354,-6643087,-5442636 ), + new FieldElement( -9192165,-2347377,-1997099,4529534,25766844,607986,-13222,9677543,-32294889,-6456008 ) + ), + new GroupElementPreComp( + new FieldElement( -2444496,-149937,29348902,8186665,1873760,12489863,-30934579,-7839692,-7852844,-8138429 ), + new FieldElement( -15236356,-15433509,7766470,746860,26346930,-10221762,-27333451,10754588,-9431476,5203576 ), + new FieldElement( 31834314,14135496,-770007,5159118,20917671,-16768096,-7467973,-7337524,31809243,7347066 ) + ), + new GroupElementPreComp( + new FieldElement( -9606723,-11874240,20414459,13033986,13716524,-11691881,19797970,-12211255,15192876,-2087490 ), + new FieldElement( -12663563,-2181719,1168162,-3804809,26747877,-14138091,10609330,12694420,33473243,-13382104 ), + new FieldElement( 33184999,11180355,15832085,-11385430,-1633671,225884,15089336,-11023903,-6135662,14480053 ) + ), + new GroupElementPreComp( + new FieldElement( 31308717,-5619998,31030840,-1897099,15674547,-6582883,5496208,13685227,27595050,8737275 ), + new FieldElement( -20318852,-15150239,10933843,-16178022,8335352,-7546022,-31008351,-12610604,26498114,66511 ), + new FieldElement( 22644454,-8761729,-16671776,4884562,-3105614,-13559366,30540766,-4286747,-13327787,-7515095 ) + ), + new GroupElementPreComp( + new FieldElement( -28017847,9834845,18617207,-2681312,-3401956,-13307506,8205540,13585437,-17127465,15115439 ), + new FieldElement( 23711543,-672915,31206561,-8362711,6164647,-9709987,-33535882,-1426096,8236921,16492939 ), + new FieldElement( -23910559,-13515526,-26299483,-4503841,25005590,-7687270,19574902,10071562,6708380,-6222424 ) + ), + new GroupElementPreComp( + new FieldElement( 2101391,-4930054,19702731,2367575,-15427167,1047675,5301017,9328700,29955601,-11678310 ), + new FieldElement( 3096359,9271816,-21620864,-15521844,-14847996,-7592937,-25892142,-12635595,-9917575,6216608 ), + new FieldElement( -32615849,338663,-25195611,2510422,-29213566,-13820213,24822830,-6146567,-26767480,7525079 ) + ), + new GroupElementPreComp( + new FieldElement( -23066649,-13985623,16133487,-7896178,-3389565,778788,-910336,-2782495,-19386633,11994101 ), + new FieldElement( 21691500,-13624626,-641331,-14367021,3285881,-3483596,-25064666,9718258,-7477437,13381418 ), + new FieldElement( 18445390,-4202236,14979846,11622458,-1727110,-3582980,23111648,-6375247,28535282,15779576 ) + ), + new GroupElementPreComp( + new FieldElement( 30098053,3089662,-9234387,16662135,-21306940,11308411,-14068454,12021730,9955285,-16303356 ), + new FieldElement( 9734894,-14576830,-7473633,-9138735,2060392,11313496,-18426029,9924399,20194861,13380996 ), + new FieldElement( -26378102,-7965207,-22167821,15789297,-18055342,-6168792,-1984914,15707771,26342023,10146099 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( -26016874,-219943,21339191,-41388,19745256,-2878700,-29637280,2227040,21612326,-545728 ), + new FieldElement( -13077387,1184228,23562814,-5970442,-20351244,-6348714,25764461,12243797,-20856566,11649658 ), + new FieldElement( -10031494,11262626,27384172,2271902,26947504,-15997771,39944,6114064,33514190,2333242 ) + ), + new GroupElementPreComp( + new FieldElement( -21433588,-12421821,8119782,7219913,-21830522,-9016134,-6679750,-12670638,24350578,-13450001 ), + new FieldElement( -4116307,-11271533,-23886186,4843615,-30088339,690623,-31536088,-10406836,8317860,12352766 ), + new FieldElement( 18200138,-14475911,-33087759,-2696619,-23702521,-9102511,-23552096,-2287550,20712163,6719373 ) + ), + new GroupElementPreComp( + new FieldElement( 26656208,6075253,-7858556,1886072,-28344043,4262326,11117530,-3763210,26224235,-3297458 ), + new FieldElement( -17168938,-14854097,-3395676,-16369877,-19954045,14050420,21728352,9493610,18620611,-16428628 ), + new FieldElement( -13323321,13325349,11432106,5964811,18609221,6062965,-5269471,-9725556,-30701573,-16479657 ) + ), + new GroupElementPreComp( + new FieldElement( -23860538,-11233159,26961357,1640861,-32413112,-16737940,12248509,-5240639,13735342,1934062 ), + new FieldElement( 25089769,6742589,17081145,-13406266,21909293,-16067981,-15136294,-3765346,-21277997,5473616 ), + new FieldElement( 31883677,-7961101,1083432,-11572403,22828471,13290673,-7125085,12469656,29111212,-5451014 ) + ), + new GroupElementPreComp( + new FieldElement( 24244947,-15050407,-26262976,2791540,-14997599,16666678,24367466,6388839,-10295587,452383 ), + new FieldElement( -25640782,-3417841,5217916,16224624,19987036,-4082269,-24236251,-5915248,15766062,8407814 ), + new FieldElement( -20406999,13990231,15495425,16395525,5377168,15166495,-8917023,-4388953,-8067909,2276718 ) + ), + new GroupElementPreComp( + new FieldElement( 30157918,12924066,-17712050,9245753,19895028,3368142,-23827587,5096219,22740376,-7303417 ), + new FieldElement( 2041139,-14256350,7783687,13876377,-25946985,-13352459,24051124,13742383,-15637599,13295222 ), + new FieldElement( 33338237,-8505733,12532113,7977527,9106186,-1715251,-17720195,-4612972,-4451357,-14669444 ) + ), + new GroupElementPreComp( + new FieldElement( -20045281,5454097,-14346548,6447146,28862071,1883651,-2469266,-4141880,7770569,9620597 ), + new FieldElement( 23208068,7979712,33071466,8149229,1758231,-10834995,30945528,-1694323,-33502340,-14767970 ), + new FieldElement( 1439958,-16270480,-1079989,-793782,4625402,10647766,-5043801,1220118,30494170,-11440799 ) + ), + new GroupElementPreComp( + new FieldElement( -5037580,-13028295,-2970559,-3061767,15640974,-6701666,-26739026,926050,-1684339,-13333647 ), + new FieldElement( 13908495,-3549272,30919928,-6273825,-21521863,7989039,9021034,9078865,3353509,4033511 ), + new FieldElement( -29663431,-15113610,32259991,-344482,24295849,-12912123,23161163,8839127,27485041,7356032 ) + ), + }, + new[]{ + new GroupElementPreComp( + new FieldElement( 9661027,705443,11980065,-5370154,-1628543,14661173,-6346142,2625015,28431036,-16771834 ), + new FieldElement( -23839233,-8311415,-25945511,7480958,-17681669,-8354183,-22545972,14150565,15970762,4099461 ), + new FieldElement( 29262576,16756590,26350592,-8793563,8529671,-11208050,13617293,-9937143,11465739,8317062 ) + ), + new GroupElementPreComp( + new FieldElement( -25493081,-6962928,32500200,-9419051,-23038724,-2302222,14898637,3848455,20969334,-5157516 ), + new FieldElement( -20384450,-14347713,-18336405,13884722,-33039454,2842114,-21610826,-3649888,11177095,14989547 ), + new FieldElement( -24496721,-11716016,16959896,2278463,12066309,10137771,13515641,2581286,-28487508,9930240 ) + ), + new GroupElementPreComp( + new FieldElement( -17751622,-2097826,16544300,-13009300,-15914807,-14949081,18345767,-13403753,16291481,-5314038 ), + new FieldElement( -33229194,2553288,32678213,9875984,8534129,6889387,-9676774,6957617,4368891,9788741 ), + new FieldElement( 16660756,7281060,-10830758,12911820,20108584,-8101676,-21722536,-8613148,16250552,-11111103 ) + ), + new GroupElementPreComp( + new FieldElement( -19765507,2390526,-16551031,14161980,1905286,6414907,4689584,10604807,-30190403,4782747 ), + new FieldElement( -1354539,14736941,-7367442,-13292886,7710542,-14155590,-9981571,4383045,22546403,437323 ), + new FieldElement( 31665577,-12180464,-16186830,1491339,-18368625,3294682,27343084,2786261,-30633590,-14097016 ) + ), + new GroupElementPreComp( + new FieldElement( -14467279,-683715,-33374107,7448552,19294360,14334329,-19690631,2355319,-19284671,-6114373 ), + new FieldElement( 15121312,-15796162,6377020,-6031361,-10798111,-12957845,18952177,15496498,-29380133,11754228 ), + new FieldElement( -2637277,-13483075,8488727,-14303896,12728761,-1622493,7141596,11724556,22761615,-10134141 ) + ), + new GroupElementPreComp( + new FieldElement( 16918416,11729663,-18083579,3022987,-31015732,-13339659,-28741185,-12227393,32851222,11717399 ), + new FieldElement( 11166634,7338049,-6722523,4531520,-29468672,-7302055,31474879,3483633,-1193175,-4030831 ), + new FieldElement( -185635,9921305,31456609,-13536438,-12013818,13348923,33142652,6546660,-19985279,-3948376 ) + ), + new GroupElementPreComp( + new FieldElement( -32460596,11266712,-11197107,-7899103,31703694,3855903,-8537131,-12833048,-30772034,-15486313 ), + new FieldElement( -18006477,12709068,3991746,-6479188,-21491523,-10550425,-31135347,-16049879,10928917,3011958 ), + new FieldElement( -6957757,-15594337,31696059,334240,29576716,14796075,-30831056,-12805180,18008031,10258577 ) + ), + new GroupElementPreComp( + new FieldElement( -22448644,15655569,7018479,-4410003,-30314266,-1201591,-1853465,1367120,25127874,6671743 ), + new FieldElement( 29701166,-14373934,-10878120,9279288,-17568,13127210,21382910,11042292,25838796,4642684 ), + new FieldElement( -20430234,14955537,-24126347,8124619,-5369288,-5990470,30468147,-13900640,18423289,4177476 ) + ) + } + }; + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/base2.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/base2.cs new file mode 100644 index 0000000..34a92a1 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/base2.cs @@ -0,0 +1,50 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class LookupTables + { + internal static readonly GroupElementPreComp[] Base2 = new GroupElementPreComp[]{ + new GroupElementPreComp( + new FieldElement( 25967493,-14356035,29566456,3660896,-12694345,4014787,27544626,-11754271,-6079156,2047605 ), + new FieldElement( -12545711,934262,-2722910,3049990,-727428,9406986,12720692,5043384,19500929,-15469378 ), + new FieldElement( -8738181,4489570,9688441,-14785194,10184609,-12363380,29287919,11864899,-24514362,-4438546 ) + ), + new GroupElementPreComp( + new FieldElement( 15636291,-9688557,24204773,-7912398,616977,-16685262,27787600,-14772189,28944400,-1550024 ), + new FieldElement( 16568933,4717097,-11556148,-1102322,15682896,-11807043,16354577,-11775962,7689662,11199574 ), + new FieldElement( 30464156,-5976125,-11779434,-15670865,23220365,15915852,7512774,10017326,-17749093,-9920357 ) + ), + new GroupElementPreComp( + new FieldElement( 10861363,11473154,27284546,1981175,-30064349,12577861,32867885,14515107,-15438304,10819380 ), + new FieldElement( 4708026,6336745,20377586,9066809,-11272109,6594696,-25653668,12483688,-12668491,5581306 ), + new FieldElement( 19563160,16186464,-29386857,4097519,10237984,-4348115,28542350,13850243,-23678021,-15815942 ) + ), + new GroupElementPreComp( + new FieldElement( 5153746,9909285,1723747,-2777874,30523605,5516873,19480852,5230134,-23952439,-15175766 ), + new FieldElement( -30269007,-3463509,7665486,10083793,28475525,1649722,20654025,16520125,30598449,7715701 ), + new FieldElement( 28881845,14381568,9657904,3680757,-20181635,7843316,-31400660,1370708,29794553,-1409300 ) + ), + new GroupElementPreComp( + new FieldElement( -22518993,-6692182,14201702,-8745502,-23510406,8844726,18474211,-1361450,-13062696,13821877 ), + new FieldElement( -6455177,-7839871,3374702,-4740862,-27098617,-10571707,31655028,-7212327,18853322,-14220951 ), + new FieldElement( 4566830,-12963868,-28974889,-12240689,-7602672,-2830569,-8514358,-10431137,2207753,-3209784 ) + ), + new GroupElementPreComp( + new FieldElement( -25154831,-4185821,29681144,7868801,-6854661,-9423865,-12437364,-663000,-31111463,-16132436 ), + new FieldElement( 25576264,-2703214,7349804,-11814844,16472782,9300885,3844789,15725684,171356,6466918 ), + new FieldElement( 23103977,13316479,9739013,-16149481,817875,-15038942,8965339,-14088058,-30714912,16193877 ) + ), + new GroupElementPreComp( + new FieldElement( -33521811,3180713,-2394130,14003687,-16903474,-16270840,17238398,4729455,-18074513,9256800 ), + new FieldElement( -25182317,-4174131,32336398,5036987,-21236817,11360617,22616405,9761698,-19827198,630305 ), + new FieldElement( -13720693,2639453,-24237460,-7406481,9494427,-5774029,-6554551,-15960994,-2449256,-14291300 ) + ), + new GroupElementPreComp( + new FieldElement( -3151181,-5046075,9282714,6866145,-31907062,-863023,-18940575,15033784,25105118,-7894876 ), + new FieldElement( -24326370,15950226,-31801215,-14592823,-11662737,-5090925,1573892,-2625887,2198790,-15804619 ), + new FieldElement( -3099351,10324967,-2241613,7453183,-5446979,-2735503,-13812022,-16236442,-32461234,-12290683 ) + ) + }; + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/d.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/d.cs new file mode 100644 index 0000000..e02ff8a --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/d.cs @@ -0,0 +1,9 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class LookupTables + { + internal static FieldElement d = new FieldElement(-10913610, 13857413, -15372611, 6949391, 114729, -8787816, -6275908, -3247719, -18696448, -12055116); + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/d2.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/d2.cs new file mode 100644 index 0000000..adf5049 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/d2.cs @@ -0,0 +1,9 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class LookupTables + { + internal static FieldElement d2 = new FieldElement(-21827239, -5839606, -30745221, 13898782, 229458, 15978800, -12551817, -6495438, 29715968, 9444199); + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_0.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_0.cs new file mode 100644 index 0000000..e4f31c0 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_0.cs @@ -0,0 +1,12 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + public static void fe_0(out FieldElement h) + { + h = default(FieldElement); + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_1.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_1.cs new file mode 100644 index 0000000..5eba3f5 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_1.cs @@ -0,0 +1,13 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + public static void fe_1(out FieldElement h) + { + h = default(FieldElement); + h.x0 = 1; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_add.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_add.cs new file mode 100644 index 0000000..aac2f6e --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_add.cs @@ -0,0 +1,64 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* + h = f + g + Can overlap h with f or g. + + Preconditions: + |f| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. + |g| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. + + Postconditions: + |h| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc. + */ + //void fe_add(fe h,const fe f,const fe g) + internal static void fe_add(out FieldElement h, ref FieldElement f, ref FieldElement g) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + int g0 = g.x0; + int g1 = g.x1; + int g2 = g.x2; + int g3 = g.x3; + int g4 = g.x4; + int g5 = g.x5; + int g6 = g.x6; + int g7 = g.x7; + int g8 = g.x8; + int g9 = g.x9; + int h0 = f0 + g0; + int h1 = f1 + g1; + int h2 = f2 + g2; + int h3 = f3 + g3; + int h4 = f4 + g4; + int h5 = f5 + g5; + int h6 = f6 + g6; + int h7 = f7 + g7; + int h8 = f8 + g8; + int h9 = f9 + g9; + + h.x0 = h0; + h.x1 = h1; + h.x2 = h2; + h.x3 = h3; + h.x4 = h4; + h.x5 = h5; + h.x6 = h6; + h.x7 = h7; + h.x8 = h8; + h.x9 = h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_cmov.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_cmov.cs new file mode 100644 index 0000000..c6555a7 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_cmov.cs @@ -0,0 +1,71 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* + Replace (f,g) with (g,g) if b == 1; + replace (f,g) with (f,g) if b == 0. + + Preconditions: b in {0,1}. + */ + + //void fe_cmov(fe f,const fe g,unsigned int b) + internal static void fe_cmov(ref FieldElement f, ref FieldElement g, int b) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + int g0 = g.x0; + int g1 = g.x1; + int g2 = g.x2; + int g3 = g.x3; + int g4 = g.x4; + int g5 = g.x5; + int g6 = g.x6; + int g7 = g.x7; + int g8 = g.x8; + int g9 = g.x9; + int x0 = f0 ^ g0; + int x1 = f1 ^ g1; + int x2 = f2 ^ g2; + int x3 = f3 ^ g3; + int x4 = f4 ^ g4; + int x5 = f5 ^ g5; + int x6 = f6 ^ g6; + int x7 = f7 ^ g7; + int x8 = f8 ^ g8; + int x9 = f9 ^ g9; + + b = -b; + x0 &= b; + x1 &= b; + x2 &= b; + x3 &= b; + x4 &= b; + x5 &= b; + x6 &= b; + x7 &= b; + x8 &= b; + x9 &= b; + f.x0 = f0 ^ x0; + f.x1 = f1 ^ x1; + f.x2 = f2 ^ x2; + f.x3 = f3 ^ x3; + f.x4 = f4 ^ x4; + f.x5 = f5 ^ x5; + f.x6 = f6 ^ x6; + f.x7 = f7 ^ x7; + f.x8 = f8 ^ x8; + f.x9 = f9 ^ x9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_cswap.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_cswap.cs new file mode 100644 index 0000000..50815db --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_cswap.cs @@ -0,0 +1,79 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* + Replace (f,g) with (g,f) if b == 1; + replace (f,g) with (f,g) if b == 0. + + Preconditions: b in {0,1}. + */ + public static void fe_cswap(ref FieldElement f, ref FieldElement g, uint b) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + int g0 = g.x0; + int g1 = g.x1; + int g2 = g.x2; + int g3 = g.x3; + int g4 = g.x4; + int g5 = g.x5; + int g6 = g.x6; + int g7 = g.x7; + int g8 = g.x8; + int g9 = g.x9; + int x0 = f0 ^ g0; + int x1 = f1 ^ g1; + int x2 = f2 ^ g2; + int x3 = f3 ^ g3; + int x4 = f4 ^ g4; + int x5 = f5 ^ g5; + int x6 = f6 ^ g6; + int x7 = f7 ^ g7; + int x8 = f8 ^ g8; + int x9 = f9 ^ g9; + + int negb = unchecked((int)-b); + x0 &= negb; + x1 &= negb; + x2 &= negb; + x3 &= negb; + x4 &= negb; + x5 &= negb; + x6 &= negb; + x7 &= negb; + x8 &= negb; + x9 &= negb; + f.x0 = f0 ^ x0; + f.x1 = f1 ^ x1; + f.x2 = f2 ^ x2; + f.x3 = f3 ^ x3; + f.x4 = f4 ^ x4; + f.x5 = f5 ^ x5; + f.x6 = f6 ^ x6; + f.x7 = f7 ^ x7; + f.x8 = f8 ^ x8; + f.x9 = f9 ^ x9; + g.x0 = g0 ^ x0; + g.x1 = g1 ^ x1; + g.x2 = g2 ^ x2; + g.x3 = g3 ^ x3; + g.x4 = g4 ^ x4; + g.x5 = g5 ^ x5; + g.x6 = g6 ^ x6; + g.x7 = g7 ^ x7; + g.x8 = g8 ^ x8; + g.x9 = g9 ^ x9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_frombytes.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_frombytes.cs new file mode 100644 index 0000000..4d6cafb --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_frombytes.cs @@ -0,0 +1,142 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + private static long load_3(byte[] data, int offset) + { + uint result; + result = data[offset + 0]; + result |= (uint)data[offset + 1] << 8; + result |= (uint)data[offset + 2] << 16; + return (long)(ulong)result; + } + + private static long load_4(byte[] data, int offset) + { + uint result; + result = data[offset + 0]; + result |= (uint)data[offset + 1] << 8; + result |= (uint)data[offset + 2] << 16; + result |= (uint)data[offset + 3] << 24; + return (long)(ulong)result; + } + + // Ignores top bit of h. + internal static void fe_frombytes(out FieldElement h, byte[] data, int offset) + { + var h0 = load_4(data, offset); + var h1 = load_3(data, offset + 4) << 6; + var h2 = load_3(data, offset + 7) << 5; + var h3 = load_3(data, offset + 10) << 3; + var h4 = load_3(data, offset + 13) << 2; + var h5 = load_4(data, offset + 16); + var h6 = load_3(data, offset + 20) << 7; + var h7 = load_3(data, offset + 23) << 5; + var h8 = load_3(data, offset + 26) << 4; + var h9 = (load_3(data, offset + 29) & 8388607) << 2; + + var carry9 = (h9 + (1 << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + var carry1 = (h1 + (1 << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry3 = (h3 + (1 << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry5 = (h5 + (1 << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + var carry7 = (h7 + (1 << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + var carry0 = (h0 + (1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry2 = (h2 + (1 << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry4 = (h4 + (1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry6 = (h6 + (1 << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + var carry8 = (h8 + (1 << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + + h.x0 = (int)h0; + h.x1 = (int)h1; + h.x2 = (int)h2; + h.x3 = (int)h3; + h.x4 = (int)h4; + h.x5 = (int)h5; + h.x6 = (int)h6; + h.x7 = (int)h7; + h.x8 = (int)h8; + h.x9 = (int)h9; + } + + // does NOT ignore top bit + internal static void fe_frombytes2(out FieldElement h, byte[] data, int offset) + { + var h0 = load_4(data, offset); + var h1 = load_3(data, offset + 4) << 6; + var h2 = load_3(data, offset + 7) << 5; + var h3 = load_3(data, offset + 10) << 3; + var h4 = load_3(data, offset + 13) << 2; + var h5 = load_4(data, offset + 16); + var h6 = load_3(data, offset + 20) << 7; + var h7 = load_3(data, offset + 23) << 5; + var h8 = load_3(data, offset + 26) << 4; + var h9 = load_3(data, offset + 29) << 2; + + var carry9 = (h9 + (1 << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + var carry1 = (h1 + (1 << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry3 = (h3 + (1 << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry5 = (h5 + (1 << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + var carry7 = (h7 + (1 << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + var carry0 = (h0 + (1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry2 = (h2 + (1 << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry4 = (h4 + (1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry6 = (h6 + (1 << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + var carry8 = (h8 + (1 << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + + h.x0 = (int)h0; + h.x1 = (int)h1; + h.x2 = (int)h2; + h.x3 = (int)h3; + h.x4 = (int)h4; + h.x5 = (int)h5; + h.x6 = (int)h6; + h.x7 = (int)h7; + h.x8 = (int)h8; + h.x9 = (int)h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_invert.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_invert.cs new file mode 100644 index 0000000..d97ff5b --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_invert.cs @@ -0,0 +1,146 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + internal static void fe_invert(out FieldElement result, ref FieldElement z) + { + FieldElement t0, t1, t2, t3; + int i; + + /* qhasm: z2 = z1^2^1 */ + /* asm 1: fe_sq(>z2=fe#1,z2=fe#1,>z2=fe#1); */ + /* asm 2: fe_sq(>z2=t0,z2=t0,>z2=t0); */ + fe_sq(out t0, ref z); //for (i = 1; i < 1; ++i) fe_sq(out t0, ref t0); + + /* qhasm: z8 = z2^2^2 */ + /* asm 1: fe_sq(>z8=fe#2,z8=fe#2,>z8=fe#2); */ + /* asm 2: fe_sq(>z8=t1,z8=t1,>z8=t1); */ + fe_sq(out t1, ref t0); + for (i = 1; i < 2; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z9 = z1*z8 */ + /* asm 1: fe_mul(>z9=fe#2,z9=t1,z11=fe#1,z11=t0,z22=fe#3,z22=fe#3,>z22=fe#3); */ + /* asm 2: fe_sq(>z22=t2,z22=t2,>z22=t2); */ + fe_sq(out t2, ref t0); //for (i = 1; i < 1; ++i) fe_sq(out t2, ref t2); + + /* qhasm: z_5_0 = z9*z22 */ + /* asm 1: fe_mul(>z_5_0=fe#2,z_5_0=t1,z_10_5=fe#3,z_10_5=fe#3,>z_10_5=fe#3); */ + /* asm 2: fe_sq(>z_10_5=t2,z_10_5=t2,>z_10_5=t2); */ + fe_sq(out t2, ref t1); + for (i = 1; i < 5; ++i) + fe_sq(out t2, ref t2); + + /* qhasm: z_10_0 = z_10_5*z_5_0 */ + /* asm 1: fe_mul(>z_10_0=fe#2,z_10_0=t1,z_20_10=fe#3,z_20_10=fe#3,>z_20_10=fe#3); */ + /* asm 2: fe_sq(>z_20_10=t2,z_20_10=t2,>z_20_10=t2); */ + fe_sq(out t2, ref t1); + for (i = 1; i < 10; ++i) + fe_sq(out t2, ref t2); + + /* qhasm: z_20_0 = z_20_10*z_10_0 */ + /* asm 1: fe_mul(>z_20_0=fe#3,z_20_0=t2,z_40_20=fe#4,z_40_20=fe#4,>z_40_20=fe#4); */ + /* asm 2: fe_sq(>z_40_20=t3,z_40_20=t3,>z_40_20=t3); */ + fe_sq(out t3, ref t2); + for (i = 1; i < 20; ++i) + fe_sq(out t3, ref t3); + + /* qhasm: z_40_0 = z_40_20*z_20_0 */ + /* asm 1: fe_mul(>z_40_0=fe#3,z_40_0=t2,z_50_10=fe#3,z_50_10=fe#3,>z_50_10=fe#3); */ + /* asm 2: fe_sq(>z_50_10=t2,z_50_10=t2,>z_50_10=t2); */ + fe_sq(out t2, ref t2); + for (i = 1; i < 10; ++i) + fe_sq(out t2, ref t2); + + /* qhasm: z_50_0 = z_50_10*z_10_0 */ + /* asm 1: fe_mul(>z_50_0=fe#2,z_50_0=t1,z_100_50=fe#3,z_100_50=fe#3,>z_100_50=fe#3); */ + /* asm 2: fe_sq(>z_100_50=t2,z_100_50=t2,>z_100_50=t2); */ + fe_sq(out t2, ref t1); + for (i = 1; i < 50; ++i) + fe_sq(out t2, ref t2); + + /* qhasm: z_100_0 = z_100_50*z_50_0 */ + /* asm 1: fe_mul(>z_100_0=fe#3,z_100_0=t2,z_200_100=fe#4,z_200_100=fe#4,>z_200_100=fe#4); */ + /* asm 2: fe_sq(>z_200_100=t3,z_200_100=t3,>z_200_100=t3); */ + fe_sq(out t3, ref t2); + for (i = 1; i < 100; ++i) + fe_sq(out t3, ref t3); + + /* qhasm: z_200_0 = z_200_100*z_100_0 */ + /* asm 1: fe_mul(>z_200_0=fe#3,z_200_0=t2,z_250_50=fe#3,z_250_50=fe#3,>z_250_50=fe#3); */ + /* asm 2: fe_sq(>z_250_50=t2,z_250_50=t2,>z_250_50=t2); */ + fe_sq(out t2, ref t2); + for (i = 1; i < 50; ++i) + fe_sq(out t2, ref t2); + + /* qhasm: z_250_0 = z_250_50*z_50_0 */ + /* asm 1: fe_mul(>z_250_0=fe#2,z_250_0=t1,z_255_5=fe#2,z_255_5=fe#2,>z_255_5=fe#2); */ + /* asm 2: fe_sq(>z_255_5=t1,z_255_5=t1,>z_255_5=t1); */ + fe_sq(out t1, ref t1); + for (i = 1; i < 5; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z_255_21 = z_255_5*z11 */ + /* asm 1: fe_mul(>z_255_21=fe#12,z_255_21=out,> 31) ^ 1); + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_mul.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_mul.cs new file mode 100644 index 0000000..fca6544 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_mul.cs @@ -0,0 +1,287 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* + h = f * g + Can overlap h with f or g. + + Preconditions: + |f| bounded by 1.65*2^26,1.65*2^25,1.65*2^26,1.65*2^25,etc. + |g| bounded by 1.65*2^26,1.65*2^25,1.65*2^26,1.65*2^25,etc. + + Postconditions: + |h| bounded by 1.01*2^25,1.01*2^24,1.01*2^25,1.01*2^24,etc. + */ + + /* + Notes on implementation strategy: + + Using schoolbook multiplication. + Karatsuba would save a little in some cost models. + + Most multiplications by 2 and 19 are 32-bit precomputations; + cheaper than 64-bit postcomputations. + + There is one remaining multiplication by 19 in the carry chain; + one *19 precomputation can be merged into this, + but the resulting data flow is considerably less clean. + + There are 12 carries below. + 10 of them are 2-way parallelizable and vectorizable. + Can get away with 11 carries, but then data flow is much deeper. + + With tighter constraints on inputs can squeeze carries into int32. + */ + + internal static void fe_mul(out FieldElement h, ref FieldElement f, ref FieldElement g) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + int g0 = g.x0; + int g1 = g.x1; + int g2 = g.x2; + int g3 = g.x3; + int g4 = g.x4; + int g5 = g.x5; + int g6 = g.x6; + int g7 = g.x7; + int g8 = g.x8; + int g9 = g.x9; + + int g1_19 = 19 * g1; /* 1.959375*2^29 */ + int g2_19 = 19 * g2; /* 1.959375*2^30; still ok */ + int g3_19 = 19 * g3; + int g4_19 = 19 * g4; + int g5_19 = 19 * g5; + int g6_19 = 19 * g6; + int g7_19 = 19 * g7; + int g8_19 = 19 * g8; + int g9_19 = 19 * g9; + + int f1_2 = 2 * f1; + int f3_2 = 2 * f3; + int f5_2 = 2 * f5; + int f7_2 = 2 * f7; + int f9_2 = 2 * f9; + + long f0g0 = f0 * (long)g0; + long f0g1 = f0 * (long)g1; + long f0g2 = f0 * (long)g2; + long f0g3 = f0 * (long)g3; + long f0g4 = f0 * (long)g4; + long f0g5 = f0 * (long)g5; + long f0g6 = f0 * (long)g6; + long f0g7 = f0 * (long)g7; + long f0g8 = f0 * (long)g8; + long f0g9 = f0 * (long)g9; + long f1g0 = f1 * (long)g0; + long f1g1_2 = f1_2 * (long)g1; + long f1g2 = f1 * (long)g2; + long f1g3_2 = f1_2 * (long)g3; + long f1g4 = f1 * (long)g4; + long f1g5_2 = f1_2 * (long)g5; + long f1g6 = f1 * (long)g6; + long f1g7_2 = f1_2 * (long)g7; + long f1g8 = f1 * (long)g8; + long f1g9_38 = f1_2 * (long)g9_19; + long f2g0 = f2 * (long)g0; + long f2g1 = f2 * (long)g1; + long f2g2 = f2 * (long)g2; + long f2g3 = f2 * (long)g3; + long f2g4 = f2 * (long)g4; + long f2g5 = f2 * (long)g5; + long f2g6 = f2 * (long)g6; + long f2g7 = f2 * (long)g7; + long f2g8_19 = f2 * (long)g8_19; + long f2g9_19 = f2 * (long)g9_19; + long f3g0 = f3 * (long)g0; + long f3g1_2 = f3_2 * (long)g1; + long f3g2 = f3 * (long)g2; + long f3g3_2 = f3_2 * (long)g3; + long f3g4 = f3 * (long)g4; + long f3g5_2 = f3_2 * (long)g5; + long f3g6 = f3 * (long)g6; + long f3g7_38 = f3_2 * (long)g7_19; + long f3g8_19 = f3 * (long)g8_19; + long f3g9_38 = f3_2 * (long)g9_19; + long f4g0 = f4 * (long)g0; + long f4g1 = f4 * (long)g1; + long f4g2 = f4 * (long)g2; + long f4g3 = f4 * (long)g3; + long f4g4 = f4 * (long)g4; + long f4g5 = f4 * (long)g5; + long f4g6_19 = f4 * (long)g6_19; + long f4g7_19 = f4 * (long)g7_19; + long f4g8_19 = f4 * (long)g8_19; + long f4g9_19 = f4 * (long)g9_19; + long f5g0 = f5 * (long)g0; + long f5g1_2 = f5_2 * (long)g1; + long f5g2 = f5 * (long)g2; + long f5g3_2 = f5_2 * (long)g3; + long f5g4 = f5 * (long)g4; + long f5g5_38 = f5_2 * (long)g5_19; + long f5g6_19 = f5 * (long)g6_19; + long f5g7_38 = f5_2 * (long)g7_19; + long f5g8_19 = f5 * (long)g8_19; + long f5g9_38 = f5_2 * (long)g9_19; + long f6g0 = f6 * (long)g0; + long f6g1 = f6 * (long)g1; + long f6g2 = f6 * (long)g2; + long f6g3 = f6 * (long)g3; + long f6g4_19 = f6 * (long)g4_19; + long f6g5_19 = f6 * (long)g5_19; + long f6g6_19 = f6 * (long)g6_19; + long f6g7_19 = f6 * (long)g7_19; + long f6g8_19 = f6 * (long)g8_19; + long f6g9_19 = f6 * (long)g9_19; + long f7g0 = f7 * (long)g0; + long f7g1_2 = f7_2 * (long)g1; + long f7g2 = f7 * (long)g2; + long f7g3_38 = f7_2 * (long)g3_19; + long f7g4_19 = f7 * (long)g4_19; + long f7g5_38 = f7_2 * (long)g5_19; + long f7g6_19 = f7 * (long)g6_19; + long f7g7_38 = f7_2 * (long)g7_19; + long f7g8_19 = f7 * (long)g8_19; + long f7g9_38 = f7_2 * (long)g9_19; + long f8g0 = f8 * (long)g0; + long f8g1 = f8 * (long)g1; + long f8g2_19 = f8 * (long)g2_19; + long f8g3_19 = f8 * (long)g3_19; + long f8g4_19 = f8 * (long)g4_19; + long f8g5_19 = f8 * (long)g5_19; + long f8g6_19 = f8 * (long)g6_19; + long f8g7_19 = f8 * (long)g7_19; + long f8g8_19 = f8 * (long)g8_19; + long f8g9_19 = f8 * (long)g9_19; + long f9g0 = f9 * (long)g0; + long f9g1_38 = f9_2 * (long)g1_19; + long f9g2_19 = f9 * (long)g2_19; + long f9g3_38 = f9_2 * (long)g3_19; + long f9g4_19 = f9 * (long)g4_19; + long f9g5_38 = f9_2 * (long)g5_19; + long f9g6_19 = f9 * (long)g6_19; + long f9g7_38 = f9_2 * (long)g7_19; + long f9g8_19 = f9 * (long)g8_19; + long f9g9_38 = f9_2 * (long)g9_19; + + long h0 = f0g0 + f1g9_38 + f2g8_19 + f3g7_38 + f4g6_19 + f5g5_38 + f6g4_19 + f7g3_38 + f8g2_19 + f9g1_38; + long h1 = f0g1 + f1g0 + f2g9_19 + f3g8_19 + f4g7_19 + f5g6_19 + f6g5_19 + f7g4_19 + f8g3_19 + f9g2_19; + long h2 = f0g2 + f1g1_2 + f2g0 + f3g9_38 + f4g8_19 + f5g7_38 + f6g6_19 + f7g5_38 + f8g4_19 + f9g3_38; + long h3 = f0g3 + f1g2 + f2g1 + f3g0 + f4g9_19 + f5g8_19 + f6g7_19 + f7g6_19 + f8g5_19 + f9g4_19; + long h4 = f0g4 + f1g3_2 + f2g2 + f3g1_2 + f4g0 + f5g9_38 + f6g8_19 + f7g7_38 + f8g6_19 + f9g5_38; + long h5 = f0g5 + f1g4 + f2g3 + f3g2 + f4g1 + f5g0 + f6g9_19 + f7g8_19 + f8g7_19 + f9g6_19; + long h6 = f0g6 + f1g5_2 + f2g4 + f3g3_2 + f4g2 + f5g1_2 + f6g0 + f7g9_38 + f8g8_19 + f9g7_38; + long h7 = f0g7 + f1g6 + f2g5 + f3g4 + f4g3 + f5g2 + f6g1 + f7g0 + f8g9_19 + f9g8_19; + long h8 = f0g8 + f1g7_2 + f2g6 + f3g5_2 + f4g4 + f5g3_2 + f6g2 + f7g1_2 + f8g0 + f9g9_38; + long h9 = f0g9 + f1g8 + f2g7 + f3g6 + f4g5 + f5g4 + f6g3 + f7g2 + f8g1 + f9g0; + + long carry0; + long carry1; + long carry2; + long carry3; + long carry4; + long carry5; + long carry6; + long carry7; + long carry8; + long carry9; + + /* + |h0| <= (1.65*1.65*2^52*(1+19+19+19+19)+1.65*1.65*2^50*(38+38+38+38+38)) + i.e. |h0| <= 1.4*2^60; narrower ranges for h2, h4, h6, h8 + |h1| <= (1.65*1.65*2^51*(1+1+19+19+19+19+19+19+19+19)) + i.e. |h1| <= 1.7*2^59; narrower ranges for h3, h5, h7, h9 + */ + + carry0 = (h0 + (long)(1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + carry4 = (h4 + (long)(1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + /* |h0| <= 2^25 */ + /* |h4| <= 2^25 */ + /* |h1| <= 1.71*2^59 */ + /* |h5| <= 1.71*2^59 */ + + carry1 = (h1 + (long)(1 << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + carry5 = (h5 + (long)(1 << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + /* |h1| <= 2^24; from now on fits into int32 */ + /* |h5| <= 2^24; from now on fits into int32 */ + /* |h2| <= 1.41*2^60 */ + /* |h6| <= 1.41*2^60 */ + + carry2 = (h2 + (long)(1 << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + carry6 = (h6 + (long)(1 << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + /* |h2| <= 2^25; from now on fits into int32 unchanged */ + /* |h6| <= 2^25; from now on fits into int32 unchanged */ + /* |h3| <= 1.71*2^59 */ + /* |h7| <= 1.71*2^59 */ + + carry3 = (h3 + (long)(1 << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + carry7 = (h7 + (long)(1 << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + /* |h3| <= 2^24; from now on fits into int32 unchanged */ + /* |h7| <= 2^24; from now on fits into int32 unchanged */ + /* |h4| <= 1.72*2^34 */ + /* |h8| <= 1.41*2^60 */ + + carry4 = (h4 + (long)(1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + carry8 = (h8 + (long)(1 << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + /* |h4| <= 2^25; from now on fits into int32 unchanged */ + /* |h8| <= 2^25; from now on fits into int32 unchanged */ + /* |h5| <= 1.01*2^24 */ + /* |h9| <= 1.71*2^59 */ + + carry9 = (h9 + (long)(1 << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + /* |h9| <= 2^24; from now on fits into int32 unchanged */ + /* |h0| <= 1.1*2^39 */ + + carry0 = (h0 + (long)(1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + /* |h0| <= 2^25; from now on fits into int32 unchanged */ + /* |h1| <= 1.01*2^24 */ + + h.x0 = (int)h0; + h.x1 = (int)h1; + h.x2 = (int)h2; + h.x3 = (int)h3; + h.x4 = (int)h4; + h.x5 = (int)h5; + h.x6 = (int)h6; + h.x7 = (int)h7; + h.x8 = (int)h8; + h.x9 = (int)h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_mul121666.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_mul121666.cs new file mode 100644 index 0000000..8f0a516 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_mul121666.cs @@ -0,0 +1,87 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + + /* + h = f * 121666 + Can overlap h with f. + + Preconditions: + |f| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc. + + Postconditions: + |h| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. + */ + + public static void fe_mul121666(out FieldElement h, ref FieldElement f) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + + var h0 = f0 * 121666L; + var h1 = f1 * 121666L; + var h2 = f2 * 121666L; + var h3 = f3 * 121666L; + var h4 = f4 * 121666L; + var h5 = f5 * 121666L; + var h6 = f6 * 121666L; + var h7 = f7 * 121666L; + var h8 = f8 * 121666L; + var h9 = f9 * 121666L; + + var carry9 = (h9 + (1 << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + var carry1 = (h1 + (1 << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry3 = (h3 + (1 << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry5 = (h5 + (1 << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + var carry7 = (h7 + (1 << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + var carry0 = (h0 + (1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry2 = (h2 + (1 << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry4 = (h4 + (1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry6 = (h6 + (1 << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + var carry8 = (h8 + (1 << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + + h.x0 = (int)h0; + h.x1 = (int)h1; + h.x2 = (int)h2; + h.x3 = (int)h3; + h.x4 = (int)h4; + h.x5 = (int)h5; + h.x6 = (int)h6; + h.x7 = (int)h7; + h.x8 = (int)h8; + h.x9 = (int)h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_neg.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_neg.cs new file mode 100644 index 0000000..1d62d12 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_neg.cs @@ -0,0 +1,51 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* + h = -f + + Preconditions: + |f| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. + + Postconditions: + |h| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. + */ + internal static void fe_neg(out FieldElement h, ref FieldElement f) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + int h0 = -f0; + int h1 = -f1; + int h2 = -f2; + int h3 = -f3; + int h4 = -f4; + int h5 = -f5; + int h6 = -f6; + int h7 = -f7; + int h8 = -f8; + int h9 = -f9; + + h.x0 = h0; + h.x1 = h1; + h.x2 = h2; + h.x3 = h3; + h.x4 = h4; + h.x5 = h5; + h.x6 = h6; + h.x7 = h7; + h.x8 = h8; + h.x9 = h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_pow22523.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_pow22523.cs new file mode 100644 index 0000000..04f3bc8 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_pow22523.cs @@ -0,0 +1,143 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + internal static void fe_pow22523(out FieldElement result, ref FieldElement z) + { + FieldElement t0, t1, t2; + int i; + + /* qhasm: z2 = z1^2^1 */ + /* asm 1: fe_sq(>z2=fe#1,z2=fe#1,>z2=fe#1); */ + /* asm 2: fe_sq(>z2=t0,z2=t0,>z2=t0); */ + fe_sq(out t0, ref z); //for (i = 1; i < 1; ++i) fe_sq(out t0, ref t0); + + /* qhasm: z8 = z2^2^2 */ + /* asm 1: fe_sq(>z8=fe#2,z8=fe#2,>z8=fe#2); */ + /* asm 2: fe_sq(>z8=t1,z8=t1,>z8=t1); */ + fe_sq(out t1, ref t0); + for (i = 1; i < 2; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z9 = z1*z8 */ + /* asm 1: fe_mul(>z9=fe#2,z9=t1,z11=fe#1,z11=t0,z22=fe#1,z22=fe#1,>z22=fe#1); */ + /* asm 2: fe_sq(>z22=t0,z22=t0,>z22=t0); */ + fe_sq(out t0, ref t0); //for (i = 1; i < 1; ++i) fe_sq(out t0, ref t0); + + /* qhasm: z_5_0 = z9*z22 */ + /* asm 1: fe_mul(>z_5_0=fe#1,z_5_0=t0,z_10_5=fe#2,z_10_5=fe#2,>z_10_5=fe#2); */ + /* asm 2: fe_sq(>z_10_5=t1,z_10_5=t1,>z_10_5=t1); */ + fe_sq(out t1, ref t0); + for (i = 1; i < 5; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z_10_0 = z_10_5*z_5_0 */ + /* asm 1: fe_mul(>z_10_0=fe#1,z_10_0=t0,z_20_10=fe#2,z_20_10=fe#2,>z_20_10=fe#2); */ + /* asm 2: fe_sq(>z_20_10=t1,z_20_10=t1,>z_20_10=t1); */ + fe_sq(out t1, ref t0); + for (i = 1; i < 10; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z_20_0 = z_20_10*z_10_0 */ + /* asm 1: fe_mul(>z_20_0=fe#2,z_20_0=t1,z_40_20=fe#3,z_40_20=fe#3,>z_40_20=fe#3); */ + /* asm 2: fe_sq(>z_40_20=t2,z_40_20=t2,>z_40_20=t2); */ + fe_sq(out t2, ref t1); + for (i = 1; i < 20; ++i) + fe_sq(out t2, ref t2); + + /* qhasm: z_40_0 = z_40_20*z_20_0 */ + /* asm 1: fe_mul(>z_40_0=fe#2,z_40_0=t1,z_50_10=fe#2,z_50_10=fe#2,>z_50_10=fe#2); */ + /* asm 2: fe_sq(>z_50_10=t1,z_50_10=t1,>z_50_10=t1); */ + fe_sq(out t1, ref t1); + for (i = 1; i < 10; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z_50_0 = z_50_10*z_10_0 */ + /* asm 1: fe_mul(>z_50_0=fe#1,z_50_0=t0,z_100_50=fe#2,z_100_50=fe#2,>z_100_50=fe#2); */ + /* asm 2: fe_sq(>z_100_50=t1,z_100_50=t1,>z_100_50=t1); */ + fe_sq(out t1, ref t0); + for (i = 1; i < 50; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z_100_0 = z_100_50*z_50_0 */ + /* asm 1: fe_mul(>z_100_0=fe#2,z_100_0=t1,z_200_100=fe#3,z_200_100=fe#3,>z_200_100=fe#3); */ + /* asm 2: fe_sq(>z_200_100=t2,z_200_100=t2,>z_200_100=t2); */ + fe_sq(out t2, ref t1); + for (i = 1; i < 100; ++i) + fe_sq(out t2, ref t2); + + /* qhasm: z_200_0 = z_200_100*z_100_0 */ + /* asm 1: fe_mul(>z_200_0=fe#2,z_200_0=t1,z_250_50=fe#2,z_250_50=fe#2,>z_250_50=fe#2); */ + /* asm 2: fe_sq(>z_250_50=t1,z_250_50=t1,>z_250_50=t1); */ + fe_sq(out t1, ref t1); + for (i = 1; i < 50; ++i) + fe_sq(out t1, ref t1); + + /* qhasm: z_250_0 = z_250_50*z_50_0 */ + /* asm 1: fe_mul(>z_250_0=fe#1,z_250_0=t0,z_252_2=fe#1,z_252_2=fe#1,>z_252_2=fe#1); */ + /* asm 2: fe_sq(>z_252_2=t0,z_252_2=t0,>z_252_2=t0); */ + fe_sq(out t0, ref t0); + for (i = 1; i < 2; ++i) + fe_sq(out t0, ref t0); + + /* qhasm: z_252_3 = z_252_2*z1 */ + /* asm 1: fe_mul(>z_252_3=fe#12,z_252_3=out,> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry4 = (h4 + (1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry1 = (h1 + (1 << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry5 = (h5 + (1 << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + var carry2 = (h2 + (1 << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry6 = (h6 + (1 << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + var carry3 = (h3 + (1 << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry7 = (h7 + (1 << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + carry4 = (h4 + (1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + + var carry8 = (h8 + (1 << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + var carry9 = (h9 + (1 << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + + carry0 = (h0 + (1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + + h.x0 = (int)h0; + h.x1 = (int)h1; + h.x2 = (int)h2; + h.x3 = (int)h3; + h.x4 = (int)h4; + h.x5 = (int)h5; + h.x6 = (int)h6; + h.x7 = (int)h7; + h.x8 = (int)h8; + h.x9 = (int)h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_sq2.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_sq2.cs new file mode 100644 index 0000000..5150fca --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_sq2.cs @@ -0,0 +1,178 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* +h = 2 * f * f +Can overlap h with f. + +Preconditions: + |f| bounded by 1.65*2^26,1.65*2^25,1.65*2^26,1.65*2^25,etc. + +Postconditions: + |h| bounded by 1.01*2^25,1.01*2^24,1.01*2^25,1.01*2^24,etc. +*/ + + /* + See fe_mul.c for discussion of implementation strategy. + */ + internal static void fe_sq2(out FieldElement h, ref FieldElement f) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + + int f0_2 = 2 * f0; + int f1_2 = 2 * f1; + int f2_2 = 2 * f2; + int f3_2 = 2 * f3; + int f4_2 = 2 * f4; + int f5_2 = 2 * f5; + int f6_2 = 2 * f6; + int f7_2 = 2 * f7; + int f5_38 = 38 * f5; /* 1.959375*2^30 */ + int f6_19 = 19 * f6; /* 1.959375*2^30 */ + int f7_38 = 38 * f7; /* 1.959375*2^30 */ + int f8_19 = 19 * f8; /* 1.959375*2^30 */ + int f9_38 = 38 * f9; /* 1.959375*2^30 */ + + var f0f0 = f0 * (long)f0; + var f0f1_2 = f0_2 * (long)f1; + var f0f2_2 = f0_2 * (long)f2; + var f0f3_2 = f0_2 * (long)f3; + var f0f4_2 = f0_2 * (long)f4; + var f0f5_2 = f0_2 * (long)f5; + var f0f6_2 = f0_2 * (long)f6; + var f0f7_2 = f0_2 * (long)f7; + var f0f8_2 = f0_2 * (long)f8; + var f0f9_2 = f0_2 * (long)f9; + var f1f1_2 = f1_2 * (long)f1; + var f1f2_2 = f1_2 * (long)f2; + var f1f3_4 = f1_2 * (long)f3_2; + var f1f4_2 = f1_2 * (long)f4; + var f1f5_4 = f1_2 * (long)f5_2; + var f1f6_2 = f1_2 * (long)f6; + var f1f7_4 = f1_2 * (long)f7_2; + var f1f8_2 = f1_2 * (long)f8; + var f1f9_76 = f1_2 * (long)f9_38; + var f2f2 = f2 * (long)f2; + var f2f3_2 = f2_2 * (long)f3; + var f2f4_2 = f2_2 * (long)f4; + var f2f5_2 = f2_2 * (long)f5; + var f2f6_2 = f2_2 * (long)f6; + var f2f7_2 = f2_2 * (long)f7; + var f2f8_38 = f2_2 * (long)f8_19; + var f2f9_38 = f2 * (long)f9_38; + var f3f3_2 = f3_2 * (long)f3; + var f3f4_2 = f3_2 * (long)f4; + var f3f5_4 = f3_2 * (long)f5_2; + var f3f6_2 = f3_2 * (long)f6; + var f3f7_76 = f3_2 * (long)f7_38; + var f3f8_38 = f3_2 * (long)f8_19; + var f3f9_76 = f3_2 * (long)f9_38; + var f4f4 = f4 * (long)f4; + var f4f5_2 = f4_2 * (long)f5; + var f4f6_38 = f4_2 * (long)f6_19; + var f4f7_38 = f4 * (long)f7_38; + var f4f8_38 = f4_2 * (long)f8_19; + var f4f9_38 = f4 * (long)f9_38; + var f5f5_38 = f5 * (long)f5_38; + var f5f6_38 = f5_2 * (long)f6_19; + var f5f7_76 = f5_2 * (long)f7_38; + var f5f8_38 = f5_2 * (long)f8_19; + var f5f9_76 = f5_2 * (long)f9_38; + var f6f6_19 = f6 * (long)f6_19; + var f6f7_38 = f6 * (long)f7_38; + var f6f8_38 = f6_2 * (long)f8_19; + var f6f9_38 = f6 * (long)f9_38; + var f7f7_38 = f7 * (long)f7_38; + var f7f8_38 = f7_2 * (long)f8_19; + var f7f9_76 = f7_2 * (long)f9_38; + var f8f8_19 = f8 * (long)f8_19; + var f8f9_38 = f8 * (long)f9_38; + var f9f9_38 = f9 * (long)f9_38; + + var h0 = f0f0 + f1f9_76 + f2f8_38 + f3f7_76 + f4f6_38 + f5f5_38; + var h1 = f0f1_2 + f2f9_38 + f3f8_38 + f4f7_38 + f5f6_38; + var h2 = f0f2_2 + f1f1_2 + f3f9_76 + f4f8_38 + f5f7_76 + f6f6_19; + var h3 = f0f3_2 + f1f2_2 + f4f9_38 + f5f8_38 + f6f7_38; + var h4 = f0f4_2 + f1f3_4 + f2f2 + f5f9_76 + f6f8_38 + f7f7_38; + var h5 = f0f5_2 + f1f4_2 + f2f3_2 + f6f9_38 + f7f8_38; + var h6 = f0f6_2 + f1f5_4 + f2f4_2 + f3f3_2 + f7f9_76 + f8f8_19; + var h7 = f0f7_2 + f1f6_2 + f2f5_2 + f3f4_2 + f8f9_38; + var h8 = f0f8_2 + f1f7_4 + f2f6_2 + f3f5_4 + f4f4 + f9f9_38; + var h9 = f0f9_2 + f1f8_2 + f2f7_2 + f3f6_2 + f4f5_2; + + h0 += h0; + h1 += h1; + h2 += h2; + h3 += h3; + h4 += h4; + h5 += h5; + h6 += h6; + h7 += h7; + h8 += h8; + h9 += h9; + + var carry0 = (h0 + (1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry4 = (h4 + (1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry1 = (h1 + (1 << 24)) >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry5 = (h5 + (1 << 24)) >> 25; + h6 += carry5; + h5 -= carry5 << 25; + var carry2 = (h2 + (1 << 25)) >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry6 = (h6 + (1 << 25)) >> 26; + h7 += carry6; + h6 -= carry6 << 26; + var carry3 = (h3 + (1 << 24)) >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry7 = (h7 + (1 << 24)) >> 25; + h8 += carry7; + h7 -= carry7 << 25; + + carry4 = (h4 + (1 << 25)) >> 26; + h5 += carry4; + h4 -= carry4 << 26; + + var carry8 = (h8 + (1 << 25)) >> 26; + h9 += carry8; + h8 -= carry8 << 26; + var carry9 = (h9 + (1 << 24)) >> 25; + h0 += carry9 * 19; + h9 -= carry9 << 25; + + carry0 = (h0 + (1 << 25)) >> 26; + h1 += carry0; + h0 -= carry0 << 26; + + h.x0 = (int)h0; + h.x1 = (int)h1; + h.x2 = (int)h2; + h.x3 = (int)h3; + h.x4 = (int)h4; + h.x5 = (int)h5; + h.x6 = (int)h6; + h.x7 = (int)h7; + h.x8 = (int)h8; + h.x9 = (int)h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_sub.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_sub.cs new file mode 100644 index 0000000..093b5cc --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_sub.cs @@ -0,0 +1,66 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* + h = f - g + Can overlap h with f or g. + + Preconditions: + |f| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. + |g| bounded by 1.1*2^25,1.1*2^24,1.1*2^25,1.1*2^24,etc. + + Postconditions: + |h| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc. + */ + + internal static void fe_sub(out FieldElement h, ref FieldElement f, ref FieldElement g) + { + int f0 = f.x0; + int f1 = f.x1; + int f2 = f.x2; + int f3 = f.x3; + int f4 = f.x4; + int f5 = f.x5; + int f6 = f.x6; + int f7 = f.x7; + int f8 = f.x8; + int f9 = f.x9; + + int g0 = g.x0; + int g1 = g.x1; + int g2 = g.x2; + int g3 = g.x3; + int g4 = g.x4; + int g5 = g.x5; + int g6 = g.x6; + int g7 = g.x7; + int g8 = g.x8; + int g9 = g.x9; + + int h0 = f0 - g0; + int h1 = f1 - g1; + int h2 = f2 - g2; + int h3 = f3 - g3; + int h4 = f4 - g4; + int h5 = f5 - g5; + int h6 = f6 - g6; + int h7 = f7 - g7; + int h8 = f8 - g8; + int h9 = f9 - g9; + + h.x0 = h0; + h.x1 = h1; + h.x2 = h2; + h.x3 = h3; + h.x4 = h4; + h.x5 = h5; + h.x6 = h6; + h.x7 = h7; + h.x8 = h8; + h.x9 = h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_tobytes.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_tobytes.cs new file mode 100644 index 0000000..b3560bc --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/fe_tobytes.cs @@ -0,0 +1,164 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class FieldOperations + { + /* + Preconditions: + |h| bounded by 1.1*2^26,1.1*2^25,1.1*2^26,1.1*2^25,etc. + + Write p=2^255-19; q=floor(h/p). + Basic claim: q = floor(2^(-255)(h + 19 2^(-25)h9 + 2^(-1))). + + Proof: + Have |h|<=p so |q|<=1 so |19^2 2^(-255) q|<1/4. + Also have |h-2^230 h9|<2^231 so |19 2^(-255)(h-2^230 h9)|<1/4. + + Write y=2^(-1)-19^2 2^(-255)q-19 2^(-255)(h-2^230 h9). + Then 0> 0); + s[offset + 1] = (byte)(h0 >> 8); + s[offset + 2] = (byte)(h0 >> 16); + s[offset + 3] = (byte)((h0 >> 24) | (h1 << 2)); + s[offset + 4] = (byte)(h1 >> 6); + s[offset + 5] = (byte)(h1 >> 14); + s[offset + 6] = (byte)((h1 >> 22) | (h2 << 3)); + s[offset + 7] = (byte)(h2 >> 5); + s[offset + 8] = (byte)(h2 >> 13); + s[offset + 9] = (byte)((h2 >> 21) | (h3 << 5)); + s[offset + 10] = (byte)(h3 >> 3); + s[offset + 11] = (byte)(h3 >> 11); + s[offset + 12] = (byte)((h3 >> 19) | (h4 << 6)); + s[offset + 13] = (byte)(h4 >> 2); + s[offset + 14] = (byte)(h4 >> 10); + s[offset + 15] = (byte)(h4 >> 18); + s[offset + 16] = (byte)(h5 >> 0); + s[offset + 17] = (byte)(h5 >> 8); + s[offset + 18] = (byte)(h5 >> 16); + s[offset + 19] = (byte)((h5 >> 24) | (h6 << 1)); + s[offset + 20] = (byte)(h6 >> 7); + s[offset + 21] = (byte)(h6 >> 15); + s[offset + 22] = (byte)((h6 >> 23) | (h7 << 3)); + s[offset + 23] = (byte)(h7 >> 5); + s[offset + 24] = (byte)(h7 >> 13); + s[offset + 25] = (byte)((h7 >> 21) | (h8 << 4)); + s[offset + 26] = (byte)(h8 >> 4); + s[offset + 27] = (byte)(h8 >> 12); + s[offset + 28] = (byte)((h8 >> 20) | (h9 << 6)); + s[offset + 29] = (byte)(h9 >> 2); + s[offset + 30] = (byte)(h9 >> 10); + s[offset + 31] = (byte)(h9 >> 18); + } + } + + internal static void fe_reduce(out FieldElement hr, ref FieldElement h) + { + int h0 = h.x0; + int h1 = h.x1; + int h2 = h.x2; + int h3 = h.x3; + int h4 = h.x4; + int h5 = h.x5; + int h6 = h.x6; + int h7 = h.x7; + int h8 = h.x8; + int h9 = h.x9; + + int q; + + q = (19 * h9 + (1 << 24)) >> 25; + q = (h0 + q) >> 26; + q = (h1 + q) >> 25; + q = (h2 + q) >> 26; + q = (h3 + q) >> 25; + q = (h4 + q) >> 26; + q = (h5 + q) >> 25; + q = (h6 + q) >> 26; + q = (h7 + q) >> 25; + q = (h8 + q) >> 26; + q = (h9 + q) >> 25; + + /* Goal: Output h-(2^255-19)q, which is between 0 and 2^255-20. */ + h0 += 19 * q; + /* Goal: Output h-2^255 q, which is between 0 and 2^255-20. */ + + var carry0 = h0 >> 26; + h1 += carry0; + h0 -= carry0 << 26; + var carry1 = h1 >> 25; + h2 += carry1; + h1 -= carry1 << 25; + var carry2 = h2 >> 26; + h3 += carry2; + h2 -= carry2 << 26; + var carry3 = h3 >> 25; + h4 += carry3; + h3 -= carry3 << 25; + var carry4 = h4 >> 26; + h5 += carry4; + h4 -= carry4 << 26; + var carry5 = h5 >> 25; + h6 += carry5; + h5 -= carry5 << 25; + var carry6 = h6 >> 26; + h7 += carry6; + h6 -= carry6 << 26; + var carry7 = h7 >> 25; + h8 += carry7; + h7 -= carry7 << 25; + var carry8 = h8 >> 26; + h9 += carry8; + h8 -= carry8 << 26; + var carry9 = h9 >> 25; + h9 -= carry9 << 25; + /* h10 = carry9 */ + + hr.x0 = h0; + hr.x1 = h1; + hr.x2 = h2; + hr.x3 = h3; + hr.x4 = h4; + hr.x5 = h5; + hr.x6 = h6; + hr.x7 = h7; + hr.x8 = h8; + hr.x9 = h9; + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_add.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_add.cs new file mode 100644 index 0000000..6c3f894 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_add.cs @@ -0,0 +1,73 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class GroupOperations + { + /* + r = p + q + */ + + internal static void ge_add(out GroupElementP1P1 r, ref GroupElementP3 p, ref GroupElementCached q) + { + FieldElement t0; + + /* qhasm: YpX1 = Y1+X1 */ + /* asm 1: fe_add(>YpX1=fe#1,YpX1=r.X,YmX1=fe#2,YmX1=r.Y,A=fe#3,A=r.Z,B=fe#2,B=r.Y,C=fe#4,C=r.T,ZZ=fe#1,ZZ=r.X,D=fe#5,D=t0,X3=fe#1,X3=r.X,Y3=fe#2,Y3=r.Y,Z3=fe#3,Z3=r.Z,T3=fe#4,T3=r.T,> 3] >> (i & 7))); + + for (int i = 0; i < 256; ++i) + { + if (r[i] != 0) + { + for (int b = 1; b <= 6 && (i + b) < 256; ++b) + { + if (r[i + b] != 0) + { + if (r[i] + (r[i + b] << b) <= 15) + { + r[i] += (sbyte)(r[i + b] << b); + r[i + b] = 0; + } + else if (r[i] - (r[i + b] << b) >= -15) + { + r[i] -= (sbyte)(r[i + b] << b); + for (int k = i + b; k < 256; ++k) + { + if (r[k] == 0) + { + r[k] = 1; + break; + } + r[k] = 0; + } + } + else + break; + } + } + } + } + } + + /* + r = a * A + b * B + where a = a[0]+256*a[1]+...+256^31 a[31]. + and b = b[0]+256*b[1]+...+256^31 b[31]. + B is the Ed25519 base point (x,4/5) with x positive. + */ + + public static void ge_double_scalarmult_vartime(out GroupElementP2 r, byte[] a, ref GroupElementP3 A, byte[] b) + { + GroupElementPreComp[] Bi = LookupTables.Base2; + // todo: Perhaps remove these allocations? + sbyte[] aslide = new sbyte[256]; + sbyte[] bslide = new sbyte[256]; + GroupElementCached[] Ai = new GroupElementCached[8]; /* A,3A,5A,7A,9A,11A,13A,15A */ + GroupElementP1P1 t; + GroupElementP3 u; + GroupElementP3 A2; + int i; + + slide(aslide, a); + slide(bslide, b); + + ge_p3_to_cached(out Ai[0], ref A); + ge_p3_dbl(out t, ref A); + ge_p1p1_to_p3(out A2, ref t); + ge_add(out t, ref A2, ref Ai[0]); + ge_p1p1_to_p3(out u, ref t); + ge_p3_to_cached(out Ai[1], ref u); + ge_add(out t, ref A2, ref Ai[1]); + ge_p1p1_to_p3(out u, ref t); + ge_p3_to_cached(out Ai[2], ref u); + ge_add(out t, ref A2, ref Ai[2]); + ge_p1p1_to_p3(out u, ref t); + ge_p3_to_cached(out Ai[3], ref u); + ge_add(out t, ref A2, ref Ai[3]); + ge_p1p1_to_p3(out u, ref t); + ge_p3_to_cached(out Ai[4], ref u); + ge_add(out t, ref A2, ref Ai[4]); + ge_p1p1_to_p3(out u, ref t); + ge_p3_to_cached(out Ai[5], ref u); + ge_add(out t, ref A2, ref Ai[5]); + ge_p1p1_to_p3(out u, ref t); + ge_p3_to_cached(out Ai[6], ref u); + ge_add(out t, ref A2, ref Ai[6]); + ge_p1p1_to_p3(out u, ref t); + ge_p3_to_cached(out Ai[7], ref u); + + ge_p2_0(out r); + + for (i = 255; i >= 0; --i) + { + if ((aslide[i] != 0) || (bslide[i] != 0)) + break; + } + + for (; i >= 0; --i) + { + ge_p2_dbl(out t, ref r); + + if (aslide[i] > 0) + { + ge_p1p1_to_p3(out u, ref t); + ge_add(out t, ref u, ref Ai[aslide[i] / 2]); + } + else if (aslide[i] < 0) + { + ge_p1p1_to_p3(out u, ref t); + ge_sub(out t, ref u, ref Ai[(-aslide[i]) / 2]); + } + + if (bslide[i] > 0) + { + ge_p1p1_to_p3(out u, ref t); + ge_madd(out t, ref u, ref Bi[bslide[i] / 2]); + } + else if (bslide[i] < 0) + { + ge_p1p1_to_p3(out u, ref t); + ge_msub(out t, ref u, ref Bi[(-bslide[i]) / 2]); + } + + ge_p1p1_to_p2(out r, ref t); + } + } + + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_frombytes.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_frombytes.cs new file mode 100644 index 0000000..4639d10 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_frombytes.cs @@ -0,0 +1,50 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class GroupOperations + { + public static int ge_frombytes_negate_vartime(out GroupElementP3 h, byte[] data, int offset) + { + FieldElement u, v, v3, vxx, check; + + FieldOperations.fe_frombytes(out h.Y, data, offset); + FieldOperations.fe_1(out h.Z); + FieldOperations.fe_sq(out u, ref h.Y); + FieldOperations.fe_mul(out v, ref u, ref LookupTables.d); + FieldOperations.fe_sub(out u, ref u, ref h.Z); /* u = y^2-1 */ + FieldOperations.fe_add(out v, ref v, ref h.Z); /* v = dy^2+1 */ + + FieldOperations.fe_sq(out v3, ref v); + FieldOperations.fe_mul(out v3, ref v3, ref v); /* v3 = v^3 */ + FieldOperations.fe_sq(out h.X, ref v3); + FieldOperations.fe_mul(out h.X, ref h.X, ref v); + FieldOperations.fe_mul(out h.X, ref h.X, ref u); /* x = uv^7 */ + + FieldOperations.fe_pow22523(out h.X, ref h.X); /* x = (uv^7)^((q-5)/8) */ + FieldOperations.fe_mul(out h.X, ref h.X, ref v3); + FieldOperations.fe_mul(out h.X, ref h.X, ref u); /* x = uv^3(uv^7)^((q-5)/8) */ + + FieldOperations.fe_sq(out vxx, ref h.X); + FieldOperations.fe_mul(out vxx, ref vxx, ref v); + FieldOperations.fe_sub(out check, ref vxx, ref u); /* vx^2-u */ + if (FieldOperations.fe_isnonzero(ref check) != 0) + { + FieldOperations.fe_add(out check, ref vxx, ref u); /* vx^2+u */ + if (FieldOperations.fe_isnonzero(ref check) != 0) + { + h = default(GroupElementP3); + return -1; + } + FieldOperations.fe_mul(out h.X, ref h.X, ref LookupTables.sqrtm1); + } + + if (FieldOperations.fe_isnegative(ref h.X) == (data[offset + 31] >> 7)) + FieldOperations.fe_neg(out h.X, ref h.X); + + FieldOperations.fe_mul(out h.T, ref h.X, ref h.Y); + return 0; + } + + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_madd.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_madd.cs new file mode 100644 index 0000000..1949964 --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_madd.cs @@ -0,0 +1,69 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class GroupOperations + { + /* + r = p + q + */ + public static void ge_madd(out GroupElementP1P1 r, ref GroupElementP3 p, ref GroupElementPreComp q) + { + FieldElement t0; + + /* qhasm: YpX1 = Y1+X1 */ + /* asm 1: fe_add(>YpX1=fe#1,YpX1=r.X,YmX1=fe#2,YmX1=r.Y,A=fe#3,A=r.Z,B=fe#2,B=r.Y,C=fe#4,C=r.T,D=fe#5,D=t0,X3=fe#1,X3=r.X,Y3=fe#2,Y3=r.Y,Z3=fe#3,Z3=r.Z,T3=fe#4,T3=r.T,YpX1=fe#1,YpX1=r.X,YmX1=fe#2,YmX1=r.Y,A=fe#3,A=r.Z,B=fe#2,B=r.Y,C=fe#4,C=r.T,D=fe#5,D=t0,X3=fe#1,X3=r.X,Y3=fe#2,Y3=r.Y,Z3=fe#3,Z3=r.Z,T3=fe#4,T3=r.T,XX=fe#1,XX=r.X,YY=fe#3,YY=r.Z,B=fe#4,B=r.T,A=fe#2,A=r.Y,AA=fe#5,AA=t0,Y3=fe#2,Y3=r.Y,Z3=fe#3,Z3=r.Z,X3=fe#1,X3=r.X,T3=fe#4,T3=r.T,>= 31; /* 1: yes; 0: no */ + return (byte)y; + } + + static byte negative(sbyte b) + { + var x = unchecked((ulong)b); /* 18446744073709551361..18446744073709551615: yes; 0..255: no */ + x >>= 63; /* 1: yes; 0: no */ + return (byte)x; + } + + static void cmov(ref GroupElementPreComp t, ref GroupElementPreComp u, byte b) + { + FieldOperations.fe_cmov(ref t.yplusx, ref u.yplusx, b); + FieldOperations.fe_cmov(ref t.yminusx, ref u.yminusx, b); + FieldOperations.fe_cmov(ref t.xy2d, ref u.xy2d, b); + } + + static void select(out GroupElementPreComp t, int pos, sbyte b) + { + GroupElementPreComp minust; + var bnegative = negative(b); + var babs = (byte)(b - (((-bnegative) & b) << 1)); + + ge_precomp_0(out t); + var table = LookupTables.Base[pos]; + cmov(ref t, ref table[0], equal(babs, 1)); + cmov(ref t, ref table[1], equal(babs, 2)); + cmov(ref t, ref table[2], equal(babs, 3)); + cmov(ref t, ref table[3], equal(babs, 4)); + cmov(ref t, ref table[4], equal(babs, 5)); + cmov(ref t, ref table[5], equal(babs, 6)); + cmov(ref t, ref table[6], equal(babs, 7)); + cmov(ref t, ref table[7], equal(babs, 8)); + minust.yplusx = t.yminusx; + minust.yminusx = t.yplusx; + FieldOperations.fe_neg(out minust.xy2d, ref t.xy2d); + cmov(ref t, ref minust, bnegative); + } + + /* + h = a * B + where a = a[0]+256*a[1]+...+256^31 a[31] + B is the Ed25519 base point (x,4/5) with x positive. + + Preconditions: + a[31] <= 127 + */ + + public static void ge_scalarmult_base(out GroupElementP3 h, byte[] a, int offset) + { + // todo: Perhaps remove this allocation + var e = new sbyte[64]; + sbyte carry; + + GroupElementP1P1 r; + GroupElementP2 s; + GroupElementPreComp t; + + for (int i = 0; i < 32; ++i) + { + e[2 * i + 0] = (sbyte)((a[offset + i] >> 0) & 15); + e[2 * i + 1] = (sbyte)((a[offset + i] >> 4) & 15); + } + /* each e[i] is between 0 and 15 */ + /* e[63] is between 0 and 7 */ + + carry = 0; + for (int i = 0; i < 63; ++i) + { + e[i] += carry; + carry = (sbyte)(e[i] + 8); + carry >>= 4; + e[i] -= (sbyte)(carry << 4); + } + e[63] += carry; + /* each e[i] is between -8 and 8 */ + + ge_p3_0(out h); + for (int i = 1; i < 64; i += 2) + { + select(out t, i / 2, e[i]); + ge_madd(out r, ref h, ref t); + ge_p1p1_to_p3(out h, ref r); + } + + ge_p3_dbl(out r, ref h); + ge_p1p1_to_p2(out s, ref r); + ge_p2_dbl(out r, ref s); + ge_p1p1_to_p2(out s, ref r); + ge_p2_dbl(out r, ref s); + ge_p1p1_to_p2(out s, ref r); + ge_p2_dbl(out r, ref s); + ge_p1p1_to_p3(out h, ref r); + + for (int i = 0; i < 64; i += 2) + { + select(out t, i / 2, e[i]); + ge_madd(out r, ref h, ref t); + ge_p1p1_to_p3(out h, ref r); + } + } + + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_sub.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_sub.cs new file mode 100644 index 0000000..25d909e --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/ge_sub.cs @@ -0,0 +1,74 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class GroupOperations + { + /* + r = p - q + */ + + public static void ge_sub(out GroupElementP1P1 r, ref GroupElementP3 p, ref GroupElementCached q) + { + FieldElement t0; + + /* qhasm: YpX1 = Y1+X1 */ + /* asm 1: fe_add(>YpX1=fe#1,YpX1=r.X,YmX1=fe#2,YmX1=r.Y,A=fe#3,A=r.Z,B=fe#2,B=r.Y,C=fe#4,C=r.T,ZZ=fe#1,ZZ=r.X,D=fe#5,D=t0,X3=fe#1,X3=r.X,Y3=fe#2,Y3=r.Y,Z3=fe#3,Z3=r.Z,T3=fe#4,T3=r.T,> 5); + long a2 = 2097151 & (load_3(a, 5) >> 2); + long a3 = 2097151 & (load_4(a, 7) >> 7); + long a4 = 2097151 & (load_4(a, 10) >> 4); + long a5 = 2097151 & (load_3(a, 13) >> 1); + long a6 = 2097151 & (load_4(a, 15) >> 6); + long a7 = 2097151 & (load_3(a, 18) >> 3); + long a8 = 2097151 & load_3(a, 21); + long a9 = 2097151 & (load_4(a, 23) >> 5); + long a10 = 2097151 & (load_3(a, 26) >> 2); + long a11 = (load_4(a, 28) >> 7); + long b0 = 2097151 & load_3(b, 0); + long b1 = 2097151 & (load_4(b, 2) >> 5); + long b2 = 2097151 & (load_3(b, 5) >> 2); + long b3 = 2097151 & (load_4(b, 7) >> 7); + long b4 = 2097151 & (load_4(b, 10) >> 4); + long b5 = 2097151 & (load_3(b, 13) >> 1); + long b6 = 2097151 & (load_4(b, 15) >> 6); + long b7 = 2097151 & (load_3(b, 18) >> 3); + long b8 = 2097151 & load_3(b, 21); + long b9 = 2097151 & (load_4(b, 23) >> 5); + long b10 = 2097151 & (load_3(b, 26) >> 2); + long b11 = (load_4(b, 28) >> 7); + long c0 = 2097151 & load_3(c, 0); + long c1 = 2097151 & (load_4(c, 2) >> 5); + long c2 = 2097151 & (load_3(c, 5) >> 2); + long c3 = 2097151 & (load_4(c, 7) >> 7); + long c4 = 2097151 & (load_4(c, 10) >> 4); + long c5 = 2097151 & (load_3(c, 13) >> 1); + long c6 = 2097151 & (load_4(c, 15) >> 6); + long c7 = 2097151 & (load_3(c, 18) >> 3); + long c8 = 2097151 & load_3(c, 21); + long c9 = 2097151 & (load_4(c, 23) >> 5); + long c10 = 2097151 & (load_3(c, 26) >> 2); + long c11 = (load_4(c, 28) >> 7); + long s0; + long s1; + long s2; + long s3; + long s4; + long s5; + long s6; + long s7; + long s8; + long s9; + long s10; + long s11; + long s12; + long s13; + long s14; + long s15; + long s16; + long s17; + long s18; + long s19; + long s20; + long s21; + long s22; + long s23; + long carry0; + long carry1; + long carry2; + long carry3; + long carry4; + long carry5; + long carry6; + long carry7; + long carry8; + long carry9; + long carry10; + long carry11; + long carry12; + long carry13; + long carry14; + long carry15; + long carry16; + long carry17; + long carry18; + long carry19; + long carry20; + long carry21; + long carry22; + + s0 = c0 + a0 * b0; + s1 = c1 + a0 * b1 + a1 * b0; + s2 = c2 + a0 * b2 + a1 * b1 + a2 * b0; + s3 = c3 + a0 * b3 + a1 * b2 + a2 * b1 + a3 * b0; + s4 = c4 + a0 * b4 + a1 * b3 + a2 * b2 + a3 * b1 + a4 * b0; + s5 = c5 + a0 * b5 + a1 * b4 + a2 * b3 + a3 * b2 + a4 * b1 + a5 * b0; + s6 = c6 + a0 * b6 + a1 * b5 + a2 * b4 + a3 * b3 + a4 * b2 + a5 * b1 + a6 * b0; + s7 = c7 + a0 * b7 + a1 * b6 + a2 * b5 + a3 * b4 + a4 * b3 + a5 * b2 + a6 * b1 + a7 * b0; + s8 = c8 + a0 * b8 + a1 * b7 + a2 * b6 + a3 * b5 + a4 * b4 + a5 * b3 + a6 * b2 + a7 * b1 + a8 * b0; + s9 = c9 + a0 * b9 + a1 * b8 + a2 * b7 + a3 * b6 + a4 * b5 + a5 * b4 + a6 * b3 + a7 * b2 + a8 * b1 + a9 * b0; + s10 = c10 + a0 * b10 + a1 * b9 + a2 * b8 + a3 * b7 + a4 * b6 + a5 * b5 + a6 * b4 + a7 * b3 + a8 * b2 + a9 * b1 + a10 * b0; + s11 = c11 + a0 * b11 + a1 * b10 + a2 * b9 + a3 * b8 + a4 * b7 + a5 * b6 + a6 * b5 + a7 * b4 + a8 * b3 + a9 * b2 + a10 * b1 + a11 * b0; + s12 = a1 * b11 + a2 * b10 + a3 * b9 + a4 * b8 + a5 * b7 + a6 * b6 + a7 * b5 + a8 * b4 + a9 * b3 + a10 * b2 + a11 * b1; + s13 = a2 * b11 + a3 * b10 + a4 * b9 + a5 * b8 + a6 * b7 + a7 * b6 + a8 * b5 + a9 * b4 + a10 * b3 + a11 * b2; + s14 = a3 * b11 + a4 * b10 + a5 * b9 + a6 * b8 + a7 * b7 + a8 * b6 + a9 * b5 + a10 * b4 + a11 * b3; + s15 = a4 * b11 + a5 * b10 + a6 * b9 + a7 * b8 + a8 * b7 + a9 * b6 + a10 * b5 + a11 * b4; + s16 = a5 * b11 + a6 * b10 + a7 * b9 + a8 * b8 + a9 * b7 + a10 * b6 + a11 * b5; + s17 = a6 * b11 + a7 * b10 + a8 * b9 + a9 * b8 + a10 * b7 + a11 * b6; + s18 = a7 * b11 + a8 * b10 + a9 * b9 + a10 * b8 + a11 * b7; + s19 = a8 * b11 + a9 * b10 + a10 * b9 + a11 * b8; + s20 = a9 * b11 + a10 * b10 + a11 * b9; + s21 = a10 * b11 + a11 * b10; + s22 = a11 * b11; + s23 = 0; + + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + carry18 = (s18 + (1 << 20)) >> 21; + s19 += carry18; + s18 -= carry18 << 21; + carry20 = (s20 + (1 << 20)) >> 21; + s21 += carry20; + s20 -= carry20 << 21; + carry22 = (s22 + (1 << 20)) >> 21; + s23 += carry22; + s22 -= carry22 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + carry17 = (s17 + (1 << 20)) >> 21; + s18 += carry17; + s17 -= carry17 << 21; + carry19 = (s19 + (1 << 20)) >> 21; + s20 += carry19; + s19 -= carry19 << 21; + carry21 = (s21 + (1 << 20)) >> 21; + s22 += carry21; + s21 -= carry21 << 21; + + s11 += s23 * 666643; + s12 += s23 * 470296; + s13 += s23 * 654183; + s14 -= s23 * 997805; + s15 += s23 * 136657; + s16 -= s23 * 683901; + s23 = 0; + + s10 += s22 * 666643; + s11 += s22 * 470296; + s12 += s22 * 654183; + s13 -= s22 * 997805; + s14 += s22 * 136657; + s15 -= s22 * 683901; + s22 = 0; + + s9 += s21 * 666643; + s10 += s21 * 470296; + s11 += s21 * 654183; + s12 -= s21 * 997805; + s13 += s21 * 136657; + s14 -= s21 * 683901; + s21 = 0; + + s8 += s20 * 666643; + s9 += s20 * 470296; + s10 += s20 * 654183; + s11 -= s20 * 997805; + s12 += s20 * 136657; + s13 -= s20 * 683901; + s20 = 0; + + s7 += s19 * 666643; + s8 += s19 * 470296; + s9 += s19 * 654183; + s10 -= s19 * 997805; + s11 += s19 * 136657; + s12 -= s19 * 683901; + s19 = 0; + + s6 += s18 * 666643; + s7 += s18 * 470296; + s8 += s18 * 654183; + s9 -= s18 * 997805; + s10 += s18 * 136657; + s11 -= s18 * 683901; + s18 = 0; + + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + + s5 += s17 * 666643; + s6 += s17 * 470296; + s7 += s17 * 654183; + s8 -= s17 * 997805; + s9 += s17 * 136657; + s10 -= s17 * 683901; + s17 = 0; + + s4 += s16 * 666643; + s5 += s16 * 470296; + s6 += s16 * 654183; + s7 -= s16 * 997805; + s8 += s16 * 136657; + s9 -= s16 * 683901; + s16 = 0; + + s3 += s15 * 666643; + s4 += s15 * 470296; + s5 += s15 * 654183; + s6 -= s15 * 997805; + s7 += s15 * 136657; + s8 -= s15 * 683901; + s15 = 0; + + s2 += s14 * 666643; + s3 += s14 * 470296; + s4 += s14 * 654183; + s5 -= s14 * 997805; + s6 += s14 * 136657; + s7 -= s14 * 683901; + s14 = 0; + + s1 += s13 * 666643; + s2 += s13 * 470296; + s3 += s13 * 654183; + s4 -= s13 * 997805; + s5 += s13 * 136657; + s6 -= s13 * 683901; + s13 = 0; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry11 = s11 >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + unchecked + { + s[0] = (byte)(s0 >> 0); + s[1] = (byte)(s0 >> 8); + s[2] = (byte)((s0 >> 16) | (s1 << 5)); + s[3] = (byte)(s1 >> 3); + s[4] = (byte)(s1 >> 11); + s[5] = (byte)((s1 >> 19) | (s2 << 2)); + s[6] = (byte)(s2 >> 6); + s[7] = (byte)((s2 >> 14) | (s3 << 7)); + s[8] = (byte)(s3 >> 1); + s[9] = (byte)(s3 >> 9); + s[10] = (byte)((s3 >> 17) | (s4 << 4)); + s[11] = (byte)(s4 >> 4); + s[12] = (byte)(s4 >> 12); + s[13] = (byte)((s4 >> 20) | (s5 << 1)); + s[14] = (byte)(s5 >> 7); + s[15] = (byte)((s5 >> 15) | (s6 << 6)); + s[16] = (byte)(s6 >> 2); + s[17] = (byte)(s6 >> 10); + s[18] = (byte)((s6 >> 18) | (s7 << 3)); + s[19] = (byte)(s7 >> 5); + s[20] = (byte)(s7 >> 13); + s[21] = (byte)(s8 >> 0); + s[22] = (byte)(s8 >> 8); + s[23] = (byte)((s8 >> 16) | (s9 << 5)); + s[24] = (byte)(s9 >> 3); + s[25] = (byte)(s9 >> 11); + s[26] = (byte)((s9 >> 19) | (s10 << 2)); + s[27] = (byte)(s10 >> 6); + s[28] = (byte)((s10 >> 14) | (s11 << 7)); + s[29] = (byte)(s11 >> 1); + s[30] = (byte)(s11 >> 9); + s[31] = (byte)(s11 >> 17); + } + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/sc_reduce.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/sc_reduce.cs new file mode 100644 index 0000000..d30863e --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/sc_reduce.cs @@ -0,0 +1,356 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + internal static partial class ScalarOperations + { + /* + Input: + s[0]+256*s[1]+...+256^63*s[63] = s + + Output: + s[0]+256*s[1]+...+256^31*s[31] = s mod l + where l = 2^252 + 27742317777372353535851937790883648493. + Overwrites s in place. + */ + + public static void sc_reduce(byte[] s) + { + long s0 = 2097151 & load_3(s, 0); + long s1 = 2097151 & (load_4(s, 2) >> 5); + long s2 = 2097151 & (load_3(s, 5) >> 2); + long s3 = 2097151 & (load_4(s, 7) >> 7); + long s4 = 2097151 & (load_4(s, 10) >> 4); + long s5 = 2097151 & (load_3(s, 13) >> 1); + long s6 = 2097151 & (load_4(s, 15) >> 6); + long s7 = 2097151 & (load_3(s, 18) >> 3); + long s8 = 2097151 & load_3(s, 21); + long s9 = 2097151 & (load_4(s, 23) >> 5); + long s10 = 2097151 & (load_3(s, 26) >> 2); + long s11 = 2097151 & (load_4(s, 28) >> 7); + long s12 = 2097151 & (load_4(s, 31) >> 4); + long s13 = 2097151 & (load_3(s, 34) >> 1); + long s14 = 2097151 & (load_4(s, 36) >> 6); + long s15 = 2097151 & (load_3(s, 39) >> 3); + long s16 = 2097151 & load_3(s, 42); + long s17 = 2097151 & (load_4(s, 44) >> 5); + long s18 = 2097151 & (load_3(s, 47) >> 2); + long s19 = 2097151 & (load_4(s, 49) >> 7); + long s20 = 2097151 & (load_4(s, 52) >> 4); + long s21 = 2097151 & (load_3(s, 55) >> 1); + long s22 = 2097151 & (load_4(s, 57) >> 6); + long s23 = (load_4(s, 60) >> 3); + + long carry0; + long carry1; + long carry2; + long carry3; + long carry4; + long carry5; + long carry6; + long carry7; + long carry8; + long carry9; + long carry10; + long carry11; + long carry12; + long carry13; + long carry14; + long carry15; + long carry16; + + s11 += s23 * 666643; + s12 += s23 * 470296; + s13 += s23 * 654183; + s14 -= s23 * 997805; + s15 += s23 * 136657; + s16 -= s23 * 683901; + s23 = 0; + + s10 += s22 * 666643; + s11 += s22 * 470296; + s12 += s22 * 654183; + s13 -= s22 * 997805; + s14 += s22 * 136657; + s15 -= s22 * 683901; + s22 = 0; + + s9 += s21 * 666643; + s10 += s21 * 470296; + s11 += s21 * 654183; + s12 -= s21 * 997805; + s13 += s21 * 136657; + s14 -= s21 * 683901; + s21 = 0; + + s8 += s20 * 666643; + s9 += s20 * 470296; + s10 += s20 * 654183; + s11 -= s20 * 997805; + s12 += s20 * 136657; + s13 -= s20 * 683901; + s20 = 0; + + s7 += s19 * 666643; + s8 += s19 * 470296; + s9 += s19 * 654183; + s10 -= s19 * 997805; + s11 += s19 * 136657; + s12 -= s19 * 683901; + s19 = 0; + + s6 += s18 * 666643; + s7 += s18 * 470296; + s8 += s18 * 654183; + s9 -= s18 * 997805; + s10 += s18 * 136657; + s11 -= s18 * 683901; + s18 = 0; + + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry12 = (s12 + (1 << 20)) >> 21; + s13 += carry12; + s12 -= carry12 << 21; + carry14 = (s14 + (1 << 20)) >> 21; + s15 += carry14; + s14 -= carry14 << 21; + carry16 = (s16 + (1 << 20)) >> 21; + s17 += carry16; + s16 -= carry16 << 21; + + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + carry13 = (s13 + (1 << 20)) >> 21; + s14 += carry13; + s13 -= carry13 << 21; + carry15 = (s15 + (1 << 20)) >> 21; + s16 += carry15; + s15 -= carry15 << 21; + + s5 += s17 * 666643; + s6 += s17 * 470296; + s7 += s17 * 654183; + s8 -= s17 * 997805; + s9 += s17 * 136657; + s10 -= s17 * 683901; + s17 = 0; + + s4 += s16 * 666643; + s5 += s16 * 470296; + s6 += s16 * 654183; + s7 -= s16 * 997805; + s8 += s16 * 136657; + s9 -= s16 * 683901; + s16 = 0; + + s3 += s15 * 666643; + s4 += s15 * 470296; + s5 += s15 * 654183; + s6 -= s15 * 997805; + s7 += s15 * 136657; + s8 -= s15 * 683901; + s15 = 0; + + s2 += s14 * 666643; + s3 += s14 * 470296; + s4 += s14 * 654183; + s5 -= s14 * 997805; + s6 += s14 * 136657; + s7 -= s14 * 683901; + s14 = 0; + + s1 += s13 * 666643; + s2 += s13 * 470296; + s3 += s13 * 654183; + s4 -= s13 * 997805; + s5 += s13 * 136657; + s6 -= s13 * 683901; + s13 = 0; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = (s0 + (1 << 20)) >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry2 = (s2 + (1 << 20)) >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry4 = (s4 + (1 << 20)) >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry6 = (s6 + (1 << 20)) >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry8 = (s8 + (1 << 20)) >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry10 = (s10 + (1 << 20)) >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + carry1 = (s1 + (1 << 20)) >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry3 = (s3 + (1 << 20)) >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry5 = (s5 + (1 << 20)) >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry7 = (s7 + (1 << 20)) >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry9 = (s9 + (1 << 20)) >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry11 = (s11 + (1 << 20)) >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + carry11 = s11 >> 21; + s12 += carry11; + s11 -= carry11 << 21; + + s0 += s12 * 666643; + s1 += s12 * 470296; + s2 += s12 * 654183; + s3 -= s12 * 997805; + s4 += s12 * 136657; + s5 -= s12 * 683901; + s12 = 0; + + carry0 = s0 >> 21; + s1 += carry0; + s0 -= carry0 << 21; + carry1 = s1 >> 21; + s2 += carry1; + s1 -= carry1 << 21; + carry2 = s2 >> 21; + s3 += carry2; + s2 -= carry2 << 21; + carry3 = s3 >> 21; + s4 += carry3; + s3 -= carry3 << 21; + carry4 = s4 >> 21; + s5 += carry4; + s4 -= carry4 << 21; + carry5 = s5 >> 21; + s6 += carry5; + s5 -= carry5 << 21; + carry6 = s6 >> 21; + s7 += carry6; + s6 -= carry6 << 21; + carry7 = s7 >> 21; + s8 += carry7; + s7 -= carry7 << 21; + carry8 = s8 >> 21; + s9 += carry8; + s8 -= carry8 << 21; + carry9 = s9 >> 21; + s10 += carry9; + s9 -= carry9 << 21; + carry10 = s10 >> 21; + s11 += carry10; + s10 -= carry10 << 21; + + unchecked + { + s[0] = (byte)(s0 >> 0); + s[1] = (byte)(s0 >> 8); + s[2] = (byte)((s0 >> 16) | (s1 << 5)); + s[3] = (byte)(s1 >> 3); + s[4] = (byte)(s1 >> 11); + s[5] = (byte)((s1 >> 19) | (s2 << 2)); + s[6] = (byte)(s2 >> 6); + s[7] = (byte)((s2 >> 14) | (s3 << 7)); + s[8] = (byte)(s3 >> 1); + s[9] = (byte)(s3 >> 9); + s[10] = (byte)((s3 >> 17) | (s4 << 4)); + s[11] = (byte)(s4 >> 4); + s[12] = (byte)(s4 >> 12); + s[13] = (byte)((s4 >> 20) | (s5 << 1)); + s[14] = (byte)(s5 >> 7); + s[15] = (byte)((s5 >> 15) | (s6 << 6)); + s[16] = (byte)(s6 >> 2); + s[17] = (byte)(s6 >> 10); + s[18] = (byte)((s6 >> 18) | (s7 << 3)); + s[19] = (byte)(s7 >> 5); + s[20] = (byte)(s7 >> 13); + s[21] = (byte)(s8 >> 0); + s[22] = (byte)(s8 >> 8); + s[23] = (byte)((s8 >> 16) | (s9 << 5)); + s[24] = (byte)(s9 >> 3); + s[25] = (byte)(s9 >> 11); + s[26] = (byte)((s9 >> 19) | (s10 << 2)); + s[27] = (byte)(s10 >> 6); + s[28] = (byte)((s10 >> 14) | (s11 << 7)); + s[29] = (byte)(s11 >> 1); + s[30] = (byte)(s11 >> 9); + s[31] = (byte)(s11 >> 17); + } + } + + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/scalarmult.cs b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/scalarmult.cs new file mode 100644 index 0000000..6dc0d7f --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Ed25519Ref10/scalarmult.cs @@ -0,0 +1,153 @@ +using System; + +namespace Discord.Net.ED25519.Ed25519Ref10 +{ + public static class MontgomeryOperations + { + public static void scalarmult( + byte[] q, int qoffset, + byte[] n, int noffset, + byte[] p, int poffset) + { + FieldElement p0, q0; + FieldOperations.fe_frombytes2(out p0, p, poffset); + scalarmult(out q0, n, noffset, ref p0); + FieldOperations.fe_tobytes(q, qoffset, ref q0); + } + + internal static void scalarmult( + out FieldElement q, + byte[] n, int noffset, + ref FieldElement p) + { + byte[] e = new byte[32];//ToDo: remove allocation + FieldElement x1, x2, x3; + FieldElement z2, z3; + FieldElement tmp0, tmp1; + + for (int i = 0; i < 32; ++i) + e[i] = n[noffset + i]; + ScalarOperations.sc_clamp(e, 0); + x1 = p; + FieldOperations.fe_1(out x2); + FieldOperations.fe_0(out z2); + x3 = x1; + FieldOperations.fe_1(out z3); + + uint swap = 0; + for (int pos = 254; pos >= 0; --pos) + { + uint b = (uint)(e[pos / 8] >> (pos & 7)); + b &= 1; + swap ^= b; + FieldOperations.fe_cswap(ref x2, ref x3, swap); + FieldOperations.fe_cswap(ref z2, ref z3, swap); + swap = b; + + /* qhasm: enter ladder */ + + /* qhasm: D = X3-Z3 */ + /* asm 1: fe_sub(>D=fe#5,D=tmp0,B=fe#6,B=tmp1,A=fe#1,A=x2,C=fe#2,C=z2,DA=fe#4,DA=z3,CB=fe#2,CB=z2,BB=fe#5,BB=tmp0,AA=fe#6,AA=tmp1,t0=fe#3,t0=x3,t1=fe#2,t1=z2,X4=fe#1,X4=x2,E=fe#6,E=tmp1,t2=fe#2,t2=z2,t3=fe#4,t3=z3,X5=fe#3,X5=x3,t4=fe#5,t4=tmp0,Z5=fe#4,x1,Z5=z3,x1,Z4=fe#2,Z4=z2, _state; + private readonly byte[] _buffer; + private ulong _totalBytes; + public const int BlockSize = 128; + private static readonly byte[] _padding = new byte[] { 0x80 }; + + /// + /// Allocation and initialization of the new SHA-512 object. + /// + public Sha512() + { + _buffer = new byte[BlockSize];//todo: remove allocation + Init(); + } + + /// + /// Performs an initialization of internal SHA-512 state. + /// + public void Init() + { + Sha512Internal.Sha512Init(out _state); + _totalBytes = 0; + } + + /// + /// Updates internal state with data from the provided array segment. + /// + /// Array segment + public void Update(ArraySegment data) + { + Update(data.Array, data.Offset, data.Count); + } + + /// + /// Updates internal state with data from the provided array. + /// + /// Array of bytes + /// Offset of byte sequence + /// Sequence length + public void Update(byte[] data, int index, int length) + { + + Array16 block; + int bytesInBuffer = (int)_totalBytes & (BlockSize - 1); + _totalBytes += (uint)length; + + if (_totalBytes >= ulong.MaxValue / 8) + throw new InvalidOperationException("Too much data"); + // Fill existing buffer + if (bytesInBuffer != 0) + { + var toCopy = Math.Min(BlockSize - bytesInBuffer, length); + Buffer.BlockCopy(data, index, _buffer, bytesInBuffer, toCopy); + index += toCopy; + length -= toCopy; + bytesInBuffer += toCopy; + if (bytesInBuffer == BlockSize) + { + ByteIntegerConverter.Array16LoadBigEndian64(out block, _buffer, 0); + Sha512Internal.Core(out _state, ref _state, ref block); + CryptoBytes.InternalWipe(_buffer, 0, _buffer.Length); + bytesInBuffer = 0; + } + } + // Hash complete blocks without copying + while (length >= BlockSize) + { + ByteIntegerConverter.Array16LoadBigEndian64(out block, data, index); + Sha512Internal.Core(out _state, ref _state, ref block); + index += BlockSize; + length -= BlockSize; + } + // Copy remainder into buffer + if (length > 0) + { + Buffer.BlockCopy(data, index, _buffer, bytesInBuffer, length); + } + } + + /// + /// Finalizes SHA-512 hashing + /// + /// Output buffer + public void Finalize(ArraySegment output) + { + Preconditions.NotNull(output.Array, nameof(output)); + if (output.Count != 64) + throw new ArgumentException("Output should be 64 in length"); + + Update(_padding, 0, _padding.Length); + Array16 block; + ByteIntegerConverter.Array16LoadBigEndian64(out block, _buffer, 0); + CryptoBytes.InternalWipe(_buffer, 0, _buffer.Length); + int bytesInBuffer = (int)_totalBytes & (BlockSize - 1); + if (bytesInBuffer > BlockSize - 16) + { + Sha512Internal.Core(out _state, ref _state, ref block); + block = default(Array16); + } + block.x15 = (_totalBytes - 1) * 8; + Sha512Internal.Core(out _state, ref _state, ref block); + + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 0, _state.x0); + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 8, _state.x1); + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 16, _state.x2); + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 24, _state.x3); + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 32, _state.x4); + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 40, _state.x5); + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 48, _state.x6); + ByteIntegerConverter.StoreBigEndian64(output.Array, output.Offset + 56, _state.x7); + _state = default(Array8); + } + + /// + /// Finalizes SHA-512 hashing. + /// + /// Hash bytes + public byte[] Finalize() + { + var result = new byte[64]; + Finalize(new ArraySegment(result)); + return result; + } + + /// + /// Calculates SHA-512 hash value for the given bytes array. + /// + /// Data bytes array + /// Hash bytes + public static byte[] Hash(byte[] data) + { + return Hash(data, 0, data.Length); + } + + /// + /// Calculates SHA-512 hash value for the given bytes array. + /// + /// Data bytes array + /// Offset of byte sequence + /// Sequence length + /// Hash bytes + public static byte[] Hash(byte[] data, int index, int length) + { + var hasher = new Sha512(); + hasher.Update(data, index, length); + return hasher.Finalize(); + } + } +} diff --git a/src/Discord.Net.Rest/Net/ED25519/Sha512Internal.cs b/src/Discord.Net.Rest/Net/ED25519/Sha512Internal.cs new file mode 100644 index 0000000..5d9879e --- /dev/null +++ b/src/Discord.Net.Rest/Net/ED25519/Sha512Internal.cs @@ -0,0 +1,447 @@ +using System; +using System.Collections.Generic; + +namespace Discord.Net.ED25519 +{ + internal static class Sha512Internal + { + private static readonly ulong[] K = new ulong[] + { + 0x428a2f98d728ae22,0x7137449123ef65cd,0xb5c0fbcfec4d3b2f,0xe9b5dba58189dbbc, + 0x3956c25bf348b538,0x59f111f1b605d019,0x923f82a4af194f9b,0xab1c5ed5da6d8118, + 0xd807aa98a3030242,0x12835b0145706fbe,0x243185be4ee4b28c,0x550c7dc3d5ffb4e2, + 0x72be5d74f27b896f,0x80deb1fe3b1696b1,0x9bdc06a725c71235,0xc19bf174cf692694, + 0xe49b69c19ef14ad2,0xefbe4786384f25e3,0x0fc19dc68b8cd5b5,0x240ca1cc77ac9c65, + 0x2de92c6f592b0275,0x4a7484aa6ea6e483,0x5cb0a9dcbd41fbd4,0x76f988da831153b5, + 0x983e5152ee66dfab,0xa831c66d2db43210,0xb00327c898fb213f,0xbf597fc7beef0ee4, + 0xc6e00bf33da88fc2,0xd5a79147930aa725,0x06ca6351e003826f,0x142929670a0e6e70, + 0x27b70a8546d22ffc,0x2e1b21385c26c926,0x4d2c6dfc5ac42aed,0x53380d139d95b3df, + 0x650a73548baf63de,0x766a0abb3c77b2a8,0x81c2c92e47edaee6,0x92722c851482353b, + 0xa2bfe8a14cf10364,0xa81a664bbc423001,0xc24b8b70d0f89791,0xc76c51a30654be30, + 0xd192e819d6ef5218,0xd69906245565a910,0xf40e35855771202a,0x106aa07032bbd1b8, + 0x19a4c116b8d2d0c8,0x1e376c085141ab53,0x2748774cdf8eeb99,0x34b0bcb5e19b48a8, + 0x391c0cb3c5c95a63,0x4ed8aa4ae3418acb,0x5b9cca4f7763e373,0x682e6ff3d6b2b8a3, + 0x748f82ee5defb2fc,0x78a5636f43172f60,0x84c87814a1f0ab72,0x8cc702081a6439ec, + 0x90befffa23631e28,0xa4506cebde82bde9,0xbef9a3f7b2c67915,0xc67178f2e372532b, + 0xca273eceea26619c,0xd186b8c721c0c207,0xeada7dd6cde0eb1e,0xf57d4f7fee6ed178, + 0x06f067aa72176fba,0x0a637dc5a2c898a6,0x113f9804bef90dae,0x1b710b35131c471b, + 0x28db77f523047d84,0x32caab7b40c72493,0x3c9ebe0a15c9bebc,0x431d67c49c100d4c, + 0x4cc5d4becb3e42b6,0x597f299cfc657e2a,0x5fcb6fab3ad6faec,0x6c44198c4a475817 + }; + + internal static void Sha512Init(out Array8 state) + { + state.x0 = 0x6a09e667f3bcc908; + state.x1 = 0xbb67ae8584caa73b; + state.x2 = 0x3c6ef372fe94f82b; + state.x3 = 0xa54ff53a5f1d36f1; + state.x4 = 0x510e527fade682d1; + state.x5 = 0x9b05688c2b3e6c1f; + state.x6 = 0x1f83d9abfb41bd6b; + state.x7 = 0x5be0cd19137e2179; + } + + internal static void Core(out Array8 outputState, ref Array8 inputState, ref Array16 input) + { + unchecked + { + var a = inputState.x0; + var b = inputState.x1; + var c = inputState.x2; + var d = inputState.x3; + var e = inputState.x4; + var f = inputState.x5; + var g = inputState.x6; + var h = inputState.x7; + + var w0 = input.x0; + var w1 = input.x1; + var w2 = input.x2; + var w3 = input.x3; + var w4 = input.x4; + var w5 = input.x5; + var w6 = input.x6; + var w7 = input.x7; + var w8 = input.x8; + var w9 = input.x9; + var w10 = input.x10; + var w11 = input.x11; + var w12 = input.x12; + var w13 = input.x13; + var w14 = input.x14; + var w15 = input.x15; + + int t = 0; + while (true) + { + ulong t1, t2; + + {//0 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w0; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//1 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w1; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//2 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w2; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//3 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w3; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//4 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w4; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//5 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w5; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//6 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w6; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//7 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w7; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//8 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w8; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//9 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w9; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//10 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w10; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//11 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w11; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//12 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w12; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//13 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w13; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//14 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w14; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + {//15 + t1 = h + + ((e >> 14) ^ (e << (64 - 14)) ^ (e >> 18) ^ (e << (64 - 18)) ^ (e >> 41) ^ (e << (64 - 41))) + + //Sigma1(e) + ((e & f) ^ (~e & g)) + //Ch(e,f,g) + K[t] + w15; + t2 = ((a >> 28) ^ (a << (64 - 28)) ^ (a >> 34) ^ (a << (64 - 34)) ^ (a >> 39) ^ (a << (64 - 39))) + + //Sigma0(a) + ((a & b) ^ (a & c) ^ (b & c)); //Maj(a,b,c) + h = g; + g = f; + f = e; + e = d + t1; + d = c; + c = b; + b = a; + a = t1 + t2; + t++; + } + if (t == 80) + break; + + w0 += ((w14 >> 19) ^ (w14 << (64 - 19)) ^ (w14 >> 61) ^ (w14 << (64 - 61)) ^ (w14 >> 6)) + + w9 + + ((w1 >> 1) ^ (w1 << (64 - 1)) ^ (w1 >> 8) ^ (w1 << (64 - 8)) ^ (w1 >> 7)); + w1 += ((w15 >> 19) ^ (w15 << (64 - 19)) ^ (w15 >> 61) ^ (w15 << (64 - 61)) ^ (w15 >> 6)) + + w10 + + ((w2 >> 1) ^ (w2 << (64 - 1)) ^ (w2 >> 8) ^ (w2 << (64 - 8)) ^ (w2 >> 7)); + w2 += ((w0 >> 19) ^ (w0 << (64 - 19)) ^ (w0 >> 61) ^ (w0 << (64 - 61)) ^ (w0 >> 6)) + + w11 + + ((w3 >> 1) ^ (w3 << (64 - 1)) ^ (w3 >> 8) ^ (w3 << (64 - 8)) ^ (w3 >> 7)); + w3 += ((w1 >> 19) ^ (w1 << (64 - 19)) ^ (w1 >> 61) ^ (w1 << (64 - 61)) ^ (w1 >> 6)) + + w12 + + ((w4 >> 1) ^ (w4 << (64 - 1)) ^ (w4 >> 8) ^ (w4 << (64 - 8)) ^ (w4 >> 7)); + w4 += ((w2 >> 19) ^ (w2 << (64 - 19)) ^ (w2 >> 61) ^ (w2 << (64 - 61)) ^ (w2 >> 6)) + + w13 + + ((w5 >> 1) ^ (w5 << (64 - 1)) ^ (w5 >> 8) ^ (w5 << (64 - 8)) ^ (w5 >> 7)); + w5 += ((w3 >> 19) ^ (w3 << (64 - 19)) ^ (w3 >> 61) ^ (w3 << (64 - 61)) ^ (w3 >> 6)) + + w14 + + ((w6 >> 1) ^ (w6 << (64 - 1)) ^ (w6 >> 8) ^ (w6 << (64 - 8)) ^ (w6 >> 7)); + w6 += ((w4 >> 19) ^ (w4 << (64 - 19)) ^ (w4 >> 61) ^ (w4 << (64 - 61)) ^ (w4 >> 6)) + + w15 + + ((w7 >> 1) ^ (w7 << (64 - 1)) ^ (w7 >> 8) ^ (w7 << (64 - 8)) ^ (w7 >> 7)); + w7 += ((w5 >> 19) ^ (w5 << (64 - 19)) ^ (w5 >> 61) ^ (w5 << (64 - 61)) ^ (w5 >> 6)) + + w0 + + ((w8 >> 1) ^ (w8 << (64 - 1)) ^ (w8 >> 8) ^ (w8 << (64 - 8)) ^ (w8 >> 7)); + w8 += ((w6 >> 19) ^ (w6 << (64 - 19)) ^ (w6 >> 61) ^ (w6 << (64 - 61)) ^ (w6 >> 6)) + + w1 + + ((w9 >> 1) ^ (w9 << (64 - 1)) ^ (w9 >> 8) ^ (w9 << (64 - 8)) ^ (w9 >> 7)); + w9 += ((w7 >> 19) ^ (w7 << (64 - 19)) ^ (w7 >> 61) ^ (w7 << (64 - 61)) ^ (w7 >> 6)) + + w2 + + ((w10 >> 1) ^ (w10 << (64 - 1)) ^ (w10 >> 8) ^ (w10 << (64 - 8)) ^ (w10 >> 7)); + w10 += ((w8 >> 19) ^ (w8 << (64 - 19)) ^ (w8 >> 61) ^ (w8 << (64 - 61)) ^ (w8 >> 6)) + + w3 + + ((w11 >> 1) ^ (w11 << (64 - 1)) ^ (w11 >> 8) ^ (w11 << (64 - 8)) ^ (w11 >> 7)); + w11 += ((w9 >> 19) ^ (w9 << (64 - 19)) ^ (w9 >> 61) ^ (w9 << (64 - 61)) ^ (w9 >> 6)) + + w4 + + ((w12 >> 1) ^ (w12 << (64 - 1)) ^ (w12 >> 8) ^ (w12 << (64 - 8)) ^ (w12 >> 7)); + w12 += ((w10 >> 19) ^ (w10 << (64 - 19)) ^ (w10 >> 61) ^ (w10 << (64 - 61)) ^ (w10 >> 6)) + + w5 + + ((w13 >> 1) ^ (w13 << (64 - 1)) ^ (w13 >> 8) ^ (w13 << (64 - 8)) ^ (w13 >> 7)); + w13 += ((w11 >> 19) ^ (w11 << (64 - 19)) ^ (w11 >> 61) ^ (w11 << (64 - 61)) ^ (w11 >> 6)) + + w6 + + ((w14 >> 1) ^ (w14 << (64 - 1)) ^ (w14 >> 8) ^ (w14 << (64 - 8)) ^ (w14 >> 7)); + w14 += ((w12 >> 19) ^ (w12 << (64 - 19)) ^ (w12 >> 61) ^ (w12 << (64 - 61)) ^ (w12 >> 6)) + + w7 + + ((w15 >> 1) ^ (w15 << (64 - 1)) ^ (w15 >> 8) ^ (w15 << (64 - 8)) ^ (w15 >> 7)); + w15 += ((w13 >> 19) ^ (w13 << (64 - 19)) ^ (w13 >> 61) ^ (w13 << (64 - 61)) ^ (w13 >> 6)) + + w8 + + ((w0 >> 1) ^ (w0 << (64 - 1)) ^ (w0 >> 8) ^ (w0 << (64 - 8)) ^ (w0 >> 7)); + } + + outputState.x0 = inputState.x0 + a; + outputState.x1 = inputState.x1 + b; + outputState.x2 = inputState.x2 + c; + outputState.x3 = inputState.x3 + d; + outputState.x4 = inputState.x4 + e; + outputState.x5 = inputState.x5 + f; + outputState.x6 = inputState.x6 + g; + outputState.x7 = inputState.x7 + h; + } + } + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/ClientBucket.cs b/src/Discord.Net.Rest/Net/Queue/ClientBucket.cs new file mode 100644 index 0000000..61a8028 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/ClientBucket.cs @@ -0,0 +1,50 @@ +using System.Collections.Immutable; + +namespace Discord.Net.Queue +{ + public enum ClientBucketType + { + Unbucketed = 0, + SendEdit = 1 + } + internal struct ClientBucket + { + private static readonly ImmutableDictionary DefsByType; + private static readonly ImmutableDictionary DefsById; + + static ClientBucket() + { + var buckets = new[] + { + new ClientBucket(ClientBucketType.Unbucketed, BucketId.Create(null, "", null), 10, 10), + new ClientBucket(ClientBucketType.SendEdit, BucketId.Create(null, "", null), 10, 10) + }; + + var builder = ImmutableDictionary.CreateBuilder(); + foreach (var bucket in buckets) + builder.Add(bucket.Type, bucket); + DefsByType = builder.ToImmutable(); + + var builder2 = ImmutableDictionary.CreateBuilder(); + foreach (var bucket in buckets) + builder2.Add(bucket.Id, bucket); + DefsById = builder2.ToImmutable(); + } + + public static ClientBucket Get(ClientBucketType type) => DefsByType[type]; + public static ClientBucket Get(BucketId id) => DefsById[id]; + + public ClientBucketType Type { get; } + public BucketId Id { get; } + public int WindowCount { get; } + public int WindowSeconds { get; } + + public ClientBucket(ClientBucketType type, BucketId id, int count, int seconds) + { + Type = type; + Id = id; + WindowCount = count; + WindowSeconds = seconds; + } + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/GatewayBucket.cs b/src/Discord.Net.Rest/Net/Queue/GatewayBucket.cs new file mode 100644 index 0000000..aa84901 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/GatewayBucket.cs @@ -0,0 +1,53 @@ +using System.Collections.Immutable; + +namespace Discord.Net.Queue +{ + public enum GatewayBucketType + { + Unbucketed = 0, + Identify = 1, + PresenceUpdate = 2, + } + internal struct GatewayBucket + { + private static readonly ImmutableDictionary DefsByType; + private static readonly ImmutableDictionary DefsById; + + static GatewayBucket() + { + var buckets = new[] + { + // Limit is 120/60s, but 3 will be reserved for heartbeats (2 for possible heartbeats in the same timeframe and a possible failure) + new GatewayBucket(GatewayBucketType.Unbucketed, BucketId.Create(null, "", null), 117, 60), + new GatewayBucket(GatewayBucketType.Identify, BucketId.Create(null, "", null), 1, 5), + new GatewayBucket(GatewayBucketType.PresenceUpdate, BucketId.Create(null, "", null), 5, 60), + }; + + var builder = ImmutableDictionary.CreateBuilder(); + foreach (var bucket in buckets) + builder.Add(bucket.Type, bucket); + DefsByType = builder.ToImmutable(); + + var builder2 = ImmutableDictionary.CreateBuilder(); + foreach (var bucket in buckets) + builder2.Add(bucket.Id, bucket); + DefsById = builder2.ToImmutable(); + } + + public static GatewayBucket Get(GatewayBucketType type) => DefsByType[type]; + public static GatewayBucket Get(BucketId id) => DefsById[id]; + + public GatewayBucketType Type { get; } + public BucketId Id { get; } + public int WindowCount { get; set; } + public int WindowSeconds { get; set; } + + public GatewayBucket(GatewayBucketType type, BucketId id, int count, int seconds) + { + Type = type; + Id = id; + WindowCount = count; + WindowSeconds = seconds; + } + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/RequestQueue.cs b/src/Discord.Net.Rest/Net/Queue/RequestQueue.cs new file mode 100644 index 0000000..3da8b3d --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/RequestQueue.cs @@ -0,0 +1,219 @@ +using Newtonsoft.Json.Bson; +using System; +using System.Collections.Concurrent; +#if DEBUG_LIMITS +using System.Diagnostics; +#endif +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Queue +{ + internal class RequestQueue : IDisposable, IAsyncDisposable + { + public event Func RateLimitTriggered; + + private readonly ConcurrentDictionary _buckets; + private readonly SemaphoreSlim _tokenLock; + private readonly CancellationTokenSource _cancelTokenSource; //Dispose token + private CancellationTokenSource _clearToken; + private CancellationToken _parentToken; + private CancellationTokenSource _requestCancelTokenSource; + private CancellationToken _requestCancelToken; //Parent token + Clear token + private DateTimeOffset _waitUntil; + + private Task _cleanupTask; + + public RequestQueue() + { + _tokenLock = new SemaphoreSlim(1, 1); + + _clearToken = new CancellationTokenSource(); + _cancelTokenSource = new CancellationTokenSource(); + _requestCancelToken = CancellationToken.None; + _parentToken = CancellationToken.None; + + _buckets = new ConcurrentDictionary(); + + _cleanupTask = RunCleanup(); + } + + public async Task SetCancelTokenAsync(CancellationToken cancelToken) + { + await _tokenLock.WaitAsync().ConfigureAwait(false); + try + { + _parentToken = cancelToken; + _requestCancelTokenSource?.Dispose(); + _requestCancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, _clearToken.Token); + _requestCancelToken = _requestCancelTokenSource.Token; + } + finally { _tokenLock.Release(); } + } + public async Task ClearAsync() + { + await _tokenLock.WaitAsync().ConfigureAwait(false); + try + { + _clearToken?.Cancel(); + _clearToken?.Dispose(); + _clearToken = new CancellationTokenSource(); + _requestCancelTokenSource?.Dispose(); + _requestCancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_clearToken.Token, _parentToken); + _requestCancelToken = _requestCancelTokenSource.Token; + } + finally { _tokenLock.Release(); } + } + + public async Task SendAsync(RestRequest request) + { + CancellationTokenSource createdTokenSource = null; + if (request.Options.CancelToken.CanBeCanceled) + { + createdTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_requestCancelToken, request.Options.CancelToken); + request.Options.CancelToken = createdTokenSource.Token; + } + else + request.Options.CancelToken = _requestCancelToken; + + var bucket = GetOrCreateBucket(request.Options, request); + var result = await bucket.SendAsync(request).ConfigureAwait(false); + createdTokenSource?.Dispose(); + return result; + } + public async Task SendAsync(WebSocketRequest request) + { + CancellationTokenSource createdTokenSource = null; + if (request.Options.CancelToken.CanBeCanceled) + { + createdTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_requestCancelToken, request.Options.CancelToken); + request.Options.CancelToken = createdTokenSource.Token; + } + else + request.Options.CancelToken = _requestCancelToken; + + var bucket = GetOrCreateBucket(request.Options, request); + await bucket.SendAsync(request).ConfigureAwait(false); + createdTokenSource?.Dispose(); + } + + internal Task EnterGlobalAsync(int id, RestRequest request) + { + int millis = (int)Math.Ceiling((_waitUntil - DateTimeOffset.UtcNow).TotalMilliseconds); + if (millis > 0) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Sleeping {millis} ms (Pre-emptive) [Global]"); +#endif + return Task.Delay(millis); + } + + return Task.CompletedTask; + } + + internal void PauseGlobal(RateLimitInfo info) + { + _waitUntil = DateTimeOffset.UtcNow.AddMilliseconds(info.RetryAfter.Value + (info.Lag?.TotalMilliseconds ?? 0.0)); + } + + internal Task EnterGlobalAsync(int id, WebSocketRequest request) + { + //If this is a global request (unbucketed), it'll be dealt in EnterAsync + var requestBucket = GatewayBucket.Get(request.Options.BucketId); + if (requestBucket.Type == GatewayBucketType.Unbucketed) + return Task.CompletedTask; + + //It's not a global request, so need to remove one from global (per-session) + var globalBucketType = GatewayBucket.Get(GatewayBucketType.Unbucketed); + var options = RequestOptions.CreateOrClone(request.Options); + options.BucketId = globalBucketType.Id; + var globalRequest = new WebSocketRequest(null, null, false, false, options); + var globalBucket = GetOrCreateBucket(options, globalRequest); + return globalBucket.TriggerAsync(id, globalRequest); + } + + private RequestBucket GetOrCreateBucket(RequestOptions options, IRequest request) + { + var bucketId = options.BucketId; + object obj = _buckets.GetOrAdd(bucketId, x => new RequestBucket(this, request, x)); + if (obj is BucketId hashBucket) + { + options.BucketId = hashBucket; + return (RequestBucket)_buckets.GetOrAdd(hashBucket, x => new RequestBucket(this, request, x)); + } + return (RequestBucket)obj; + } + internal Task RaiseRateLimitTriggered(BucketId bucketId, RateLimitInfo? info, string endpoint) + => RateLimitTriggered(bucketId, info, endpoint); + + internal (RequestBucket, BucketId) UpdateBucketHash(BucketId id, string discordHash) + { + if (!id.IsHashBucket) + { + var bucket = BucketId.Create(discordHash, id); + var hashReqQueue = (RequestBucket)_buckets.GetOrAdd(bucket, _buckets[id]); + _buckets.AddOrUpdate(id, bucket, (oldBucket, oldObj) => bucket); + return (hashReqQueue, bucket); + } + return (null, null); + } + + public void ClearGatewayBuckets() + { + foreach (var gwBucket in (GatewayBucketType[])Enum.GetValues(typeof(GatewayBucketType))) + _buckets.TryRemove(GatewayBucket.Get(gwBucket).Id, out _); + } + + private async Task RunCleanup() + { + try + { + while (!_cancelTokenSource.IsCancellationRequested) + { + var now = DateTimeOffset.UtcNow; + foreach (var bucket in _buckets.Where(x => x.Value is RequestBucket).Select(x => (RequestBucket)x.Value)) + { + if ((now - bucket.LastAttemptAt).TotalMinutes > 1.0) + { + if (bucket.Id.IsHashBucket) + foreach (var redirectBucket in _buckets.Where(x => x.Value == bucket.Id).Select(x => (BucketId)x.Value)) + _buckets.TryRemove(redirectBucket, out _); //remove redirections if hash bucket + _buckets.TryRemove(bucket.Id, out _); + } + } + await Task.Delay(60000, _cancelTokenSource.Token).ConfigureAwait(false); //Runs each minute + } + } + catch (TaskCanceledException) { } + catch (ObjectDisposedException) { } + } + + public void Dispose() + { + if (!(_cancelTokenSource is null)) + { + _cancelTokenSource.Cancel(); + _cancelTokenSource.Dispose(); + _cleanupTask.GetAwaiter().GetResult(); + } + _tokenLock?.Dispose(); + _clearToken?.Dispose(); + _requestCancelTokenSource?.Dispose(); + } + + public async ValueTask DisposeAsync() + { + if (!(_cancelTokenSource is null)) + { + _cancelTokenSource.Cancel(); + _cancelTokenSource.Dispose(); + await _cleanupTask.ConfigureAwait(false); + } + _tokenLock?.Dispose(); + _clearToken?.Dispose(); + _requestCancelTokenSource?.Dispose(); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/RequestQueueBucket.cs b/src/Discord.Net.Rest/Net/Queue/RequestQueueBucket.cs new file mode 100644 index 0000000..1fa7494 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/RequestQueueBucket.cs @@ -0,0 +1,503 @@ +using Discord.API; +using Discord.Net.Rest; +using Newtonsoft.Json; +using System; +#if DEBUG_LIMITS +using System.Diagnostics; +#endif +using System.IO; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Queue +{ + internal class RequestBucket + { + private const int MinimumSleepTimeMs = 750; + + private readonly object _lock; + private readonly RequestQueue _queue; + private int _semaphore; + private DateTimeOffset? _resetTick; + private RequestBucket _redirectBucket; + + public BucketId Id { get; private set; } + public int WindowCount { get; private set; } + public DateTimeOffset LastAttemptAt { get; private set; } + + public RequestBucket(RequestQueue queue, IRequest request, BucketId id) + { + _queue = queue; + Id = id; + + _lock = new object(); + + if (request.Options.IsClientBucket) + WindowCount = ClientBucket.Get(request.Options.BucketId).WindowCount; + else if (request.Options.IsGatewayBucket) + WindowCount = GatewayBucket.Get(request.Options.BucketId).WindowCount; + else + WindowCount = 1; //Only allow one request until we get a header back + _semaphore = WindowCount; + _resetTick = null; + LastAttemptAt = DateTimeOffset.UtcNow; + } + + static int nextId = 0; + public async Task SendAsync(RestRequest request) + { + int id = Interlocked.Increment(ref nextId); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Start"); +#endif + LastAttemptAt = DateTimeOffset.UtcNow; + while (true) + { + await _queue.EnterGlobalAsync(id, request).ConfigureAwait(false); + await EnterAsync(id, request).ConfigureAwait(false); + if (_redirectBucket != null) + return await _redirectBucket.SendAsync(request); + +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Sending..."); +#endif + RestResponse response = default(RestResponse); + RateLimitInfo info = default(RateLimitInfo); + try + { + response = await request.SendAsync().ConfigureAwait(false); + info = new RateLimitInfo(response.Headers, request.Endpoint); + + request.Options.ExecuteRatelimitCallback(info); + + if (response.StatusCode < (HttpStatusCode)200 || response.StatusCode >= (HttpStatusCode)300) + { + switch (response.StatusCode) + { + case (HttpStatusCode)429: + if (info.IsGlobal) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] (!) 429 [Global]"); +#endif + _queue.PauseGlobal(info); + } + else + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] (!) 429"); +#endif + } + await _queue.RaiseRateLimitTriggered(Id, info, $"{request.Method} {request.Endpoint}").ConfigureAwait(false); + continue; //Retry + case HttpStatusCode.BadGateway: //502 +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] (!) 502"); +#endif + if ((request.Options.RetryMode & RetryMode.Retry502) == 0) + throw new HttpException(HttpStatusCode.BadGateway, request, null); + + continue; //Retry + default: + API.DiscordError error = null; + if (response.Stream != null) + { + try + { + using var reader = new StreamReader(response.Stream); + using var jsonReader = new JsonTextReader(reader); + + error = Discord.Rest.DiscordRestClient.Serializer.Deserialize(jsonReader); + } + catch { } + } + throw new HttpException( + response.StatusCode, + request, + error?.Code, + error?.Message, + error?.Errors.IsSpecified == true ? + error.Errors.Value.Select(x => new DiscordJsonError(x.Name.GetValueOrDefault("root"), x.Errors.Select(y => new DiscordError(y.Code, y.Message)).ToArray())).ToArray() : + null + ); + } + } + else + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Success"); +#endif + return response.Stream; + } + } + //catch (HttpException) { throw; } //Pass through + catch (TimeoutException) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Timeout"); +#endif + if ((request.Options.RetryMode & RetryMode.RetryTimeouts) == 0) + throw; + + await Task.Delay(500).ConfigureAwait(false); + continue; //Retry + } + /*catch (Exception) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Error"); +#endif + if ((request.Options.RetryMode & RetryMode.RetryErrors) == 0) + throw; + + await Task.Delay(500); + continue; //Retry + }*/ + finally + { + UpdateRateLimit(id, request, info, response.StatusCode == (HttpStatusCode)429, body: response.Stream); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Stop"); +#endif + } + } + } + public async Task SendAsync(WebSocketRequest request) + { + int id = Interlocked.Increment(ref nextId); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Start"); +#endif + LastAttemptAt = DateTimeOffset.UtcNow; + while (true) + { + await _queue.EnterGlobalAsync(id, request).ConfigureAwait(false); + await EnterAsync(id, request).ConfigureAwait(false); + +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Sending..."); +#endif + try + { + await request.SendAsync().ConfigureAwait(false); + return; + } + catch (TimeoutException) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Timeout"); +#endif + if ((request.Options.RetryMode & RetryMode.RetryTimeouts) == 0) + throw; + + await Task.Delay(500).ConfigureAwait(false); + continue; //Retry + } + /*catch (Exception) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Error"); +#endif + if ((request.Options.RetryMode & RetryMode.RetryErrors) == 0) + throw; + + await Task.Delay(500); + continue; //Retry + }*/ + finally + { + UpdateRateLimit(id, request, default(RateLimitInfo), false); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Stop"); +#endif + } + } + } + + internal async Task TriggerAsync(int id, IRequest request) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Trigger Bucket"); +#endif + await EnterAsync(id, request).ConfigureAwait(false); + UpdateRateLimit(id, request, default(RateLimitInfo), false); + } + + private async Task EnterAsync(int id, IRequest request) + { + int windowCount; + DateTimeOffset? resetAt; + bool isRateLimited = false; + + while (true) + { + if (_redirectBucket != null) + break; + + if (DateTimeOffset.UtcNow > request.TimeoutAt || request.Options.CancelToken.IsCancellationRequested) + { + if (!isRateLimited) + throw new TimeoutException(); + else + ThrowRetryLimit(request); + } + + lock (_lock) + { + windowCount = WindowCount; + resetAt = _resetTick; + } + + DateTimeOffset? timeoutAt = request.TimeoutAt; + int semaphore = Interlocked.Decrement(ref _semaphore); + if (windowCount >= 0 && semaphore < 0) + { + if (!isRateLimited) + { + bool ignoreRatelimit = false; + isRateLimited = true; + switch (request) + { + case RestRequest restRequest: + await _queue.RaiseRateLimitTriggered(Id, null, $"{restRequest.Method} {restRequest.Endpoint}").ConfigureAwait(false); + break; + case WebSocketRequest webSocketRequest: + if (webSocketRequest.IgnoreLimit) + { + ignoreRatelimit = true; + break; + } + await _queue.RaiseRateLimitTriggered(Id, null, Id.Endpoint).ConfigureAwait(false); + break; + default: + throw new InvalidOperationException("Unknown request type"); + } + if (ignoreRatelimit) + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Ignoring ratelimit"); +#endif + break; + } + } + + ThrowRetryLimit(request); + + if (resetAt.HasValue && resetAt > DateTimeOffset.UtcNow) + { + if (resetAt > timeoutAt) + ThrowRetryLimit(request); + + int millis = (int)Math.Ceiling((resetAt.Value - DateTimeOffset.UtcNow).TotalMilliseconds); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Sleeping {millis} ms (Pre-emptive)"); +#endif + if (millis > 0) + await Task.Delay(millis, request.Options.CancelToken).ConfigureAwait(false); + } + else + { + if ((timeoutAt.Value - DateTimeOffset.UtcNow).TotalMilliseconds < MinimumSleepTimeMs) + ThrowRetryLimit(request); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Sleeping {MinimumSleepTimeMs}* ms (Pre-emptive)"); +#endif + await Task.Delay(MinimumSleepTimeMs, request.Options.CancelToken).ConfigureAwait(false); + } + continue; + } +#if DEBUG_LIMITS + else + Debug.WriteLine($"[{id}] Entered Semaphore ({semaphore}/{WindowCount} remaining)"); +#endif + break; + } + } + + private void UpdateRateLimit(int id, IRequest request, RateLimitInfo info, bool is429, bool redirected = false, Stream body = null) + { + if (WindowCount == 0) + return; + + lock (_lock) + { + if (redirected) + { + Interlocked.Decrement(ref _semaphore); //we might still hit a real ratelimit if all tickets were already taken, can't do much about it since we didn't know they were the same +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Decrease Semaphore"); +#endif + } + bool hasQueuedReset = _resetTick != null; + + if (info.Bucket != null && !redirected) + { + (RequestBucket, BucketId) hashBucket = _queue.UpdateBucketHash(Id, info.Bucket); + if (!(hashBucket.Item1 is null) && !(hashBucket.Item2 is null)) + { + if (hashBucket.Item1 == this) //this bucket got promoted to a hash queue + { + Id = hashBucket.Item2; +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Promoted to Hash Bucket ({hashBucket.Item2})"); +#endif + } + else + { + _redirectBucket = hashBucket.Item1; //this request should be part of another bucket, this bucket will be disabled, redirect everything + _redirectBucket.UpdateRateLimit(id, request, info, is429, redirected: true); //update the hash bucket ratelimit +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Redirected to {_redirectBucket.Id}"); +#endif + return; + } + } + } + + if (info.Limit.HasValue && WindowCount != info.Limit.Value) + { + WindowCount = info.Limit.Value; +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Updated Limit to {WindowCount}"); +#endif + } + + if (info.Remaining.HasValue && _semaphore != info.Remaining.Value) + { + _semaphore = info.Remaining.Value; +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Updated Semaphore (Remaining) to {_semaphore}"); +#endif + } + + DateTimeOffset? resetTick = null; + + //Using X-RateLimit-Remaining causes a race condition + /*if (info.Remaining.HasValue) + { + Debug.WriteLine($"[{id}] X-RateLimit-Remaining: " + info.Remaining.Value); + _semaphore = info.Remaining.Value; + }*/ + if (is429) + { + // Stop all requests until the QueueReset task is complete + _semaphore = 0; + + // use the payload reset after value + var payload = info.ReadRatelimitPayload(body); + + // fallback on stored ratelimit info when payload is null, https://github.com/discord-net/Discord.Net/issues/2123 + resetTick = DateTimeOffset.UtcNow.Add(TimeSpan.FromSeconds(payload?.RetryAfter ?? info.ResetAfter?.TotalSeconds ?? 0)); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Reset-After: {info.ResetAfter.Value} ({info.ResetAfter?.TotalMilliseconds} ms)"); +#endif + } + else if (info.RetryAfter.HasValue) + { + //RetryAfter is more accurate than Reset, where available + resetTick = DateTimeOffset.UtcNow.AddSeconds(info.RetryAfter.Value); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Retry-After: {info.RetryAfter.Value} ({info.RetryAfter.Value} ms)"); +#endif + } + else if (info.ResetAfter.HasValue && (request.Options.UseSystemClock.HasValue && !request.Options.UseSystemClock.Value)) + { + resetTick = DateTimeOffset.UtcNow.Add(info.ResetAfter.Value); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Reset-After: {info.ResetAfter.Value} ({info.ResetAfter?.TotalMilliseconds} ms)"); +#endif + } + else if (info.Reset.HasValue) + { + resetTick = info.Reset.Value.AddSeconds(info.Lag?.TotalSeconds ?? 1.0); + + /* millisecond precision makes this unnecessary, retaining in case of regression + if (request.Options.IsReactionBucket) + resetTick = DateTimeOffset.Now.AddMilliseconds(250); + */ + + int diff = (int)(resetTick.Value - DateTimeOffset.UtcNow).TotalMilliseconds; +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] X-RateLimit-Reset: {info.Reset.Value.ToUnixTimeSeconds()} ({diff} ms, {info.Lag?.TotalMilliseconds} ms lag)"); +#endif + } + else if (request.Options.IsClientBucket && Id != null) + { + resetTick = DateTimeOffset.UtcNow.AddSeconds(ClientBucket.Get(Id).WindowSeconds); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Client Bucket ({ClientBucket.Get(Id).WindowSeconds * 1000} ms)"); +#endif + } + else if (request.Options.IsGatewayBucket && request.Options.BucketId != null) + { + resetTick = DateTimeOffset.UtcNow.AddSeconds(GatewayBucket.Get(request.Options.BucketId).WindowSeconds); +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Gateway Bucket ({GatewayBucket.Get(request.Options.BucketId).WindowSeconds * 1000} ms)"); +#endif + if (!hasQueuedReset) + { + _resetTick = resetTick; + LastAttemptAt = resetTick.Value; +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Reset in {(int)Math.Ceiling((resetTick - DateTimeOffset.UtcNow).Value.TotalMilliseconds)} ms"); +#endif + var _ = QueueReset(id, (int)Math.Ceiling((_resetTick.Value - DateTimeOffset.UtcNow).TotalMilliseconds), request); + } + return; + } + + if (resetTick == null) + { + WindowCount = -1; //No rate limit info, disable limits on this bucket +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Disabled Semaphore"); +#endif + return; + } + + if (!hasQueuedReset || resetTick > _resetTick) + { + _resetTick = resetTick; + LastAttemptAt = resetTick.Value; //Make sure we don't destroy this until after its been reset +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] Reset in {(int)Math.Ceiling((resetTick - DateTimeOffset.UtcNow).Value.TotalMilliseconds)} ms"); +#endif + + if (!hasQueuedReset) + { + var _ = QueueReset(id, (int)Math.Ceiling((_resetTick.Value - DateTimeOffset.UtcNow).TotalMilliseconds), request); + } + } + } + } + private async Task QueueReset(int id, int millis, IRequest request) + { + while (true) + { + if (millis > 0) + await Task.Delay(millis).ConfigureAwait(false); + lock (_lock) + { + millis = (int)Math.Ceiling((_resetTick.Value - DateTimeOffset.UtcNow).TotalMilliseconds); + if (millis <= 0) //Make sure we haven't gotten a more accurate reset time + { +#if DEBUG_LIMITS + Debug.WriteLine($"[{id}] * Reset *"); +#endif + _semaphore = WindowCount; + _resetTick = null; + return; + } + } + } + } + + private void ThrowRetryLimit(IRequest request) + { + if ((request.Options.RetryMode & RetryMode.RetryRatelimit) == 0) + throw new RateLimitedException(request); + } + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/Requests/JsonRestRequest.cs b/src/Discord.Net.Rest/Net/Queue/Requests/JsonRestRequest.cs new file mode 100644 index 0000000..ff32187 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/Requests/JsonRestRequest.cs @@ -0,0 +1,19 @@ +using Discord.Net.Rest; +using System.Threading.Tasks; + +namespace Discord.Net.Queue +{ + public class JsonRestRequest : RestRequest + { + public string Json { get; } + + public JsonRestRequest(IRestClient client, string method, string endpoint, string json, RequestOptions options) + : base(client, method, endpoint, options) + { + Json = json; + } + + public override Task SendAsync() + => Client.SendAsync(Method, Endpoint, Json, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason); + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/Requests/MultipartRestRequest.cs b/src/Discord.Net.Rest/Net/Queue/Requests/MultipartRestRequest.cs new file mode 100644 index 0000000..b26f343 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/Requests/MultipartRestRequest.cs @@ -0,0 +1,20 @@ +using Discord.Net.Rest; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.Net.Queue +{ + public class MultipartRestRequest : RestRequest + { + public IReadOnlyDictionary MultipartParams { get; } + + public MultipartRestRequest(IRestClient client, string method, string endpoint, IReadOnlyDictionary multipartParams, RequestOptions options) + : base(client, method, endpoint, options) + { + MultipartParams = multipartParams; + } + + public override Task SendAsync() + => Client.SendAsync(Method, Endpoint, MultipartParams, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason); + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs b/src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs new file mode 100644 index 0000000..3240360 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/Requests/RestRequest.cs @@ -0,0 +1,35 @@ +using Discord.Net.Rest; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Threading.Tasks; + +namespace Discord.Net.Queue +{ + public class RestRequest : IRequest + { + public IRestClient Client { get; } + public string Method { get; } + public string Endpoint { get; } + public DateTimeOffset? TimeoutAt { get; } + public TaskCompletionSource Promise { get; } + public RequestOptions Options { get; } + + public RestRequest(IRestClient client, string method, string endpoint, RequestOptions options) + { + Preconditions.NotNull(options, nameof(options)); + + Client = client; + Method = method; + Endpoint = endpoint; + Options = options; + TimeoutAt = options.Timeout.HasValue ? DateTimeOffset.UtcNow.AddMilliseconds(options.Timeout.Value) : (DateTimeOffset?)null; + Promise = new TaskCompletionSource(); + } + + public virtual Task SendAsync() + => Client.SendAsync(Method, Endpoint, Options.CancelToken, Options.HeaderOnly, Options.AuditLogReason, Options.RequestHeaders); + } +} diff --git a/src/Discord.Net.Rest/Net/Queue/Requests/WebSocketRequest.cs b/src/Discord.Net.Rest/Net/Queue/Requests/WebSocketRequest.cs new file mode 100644 index 0000000..ab523ea --- /dev/null +++ b/src/Discord.Net.Rest/Net/Queue/Requests/WebSocketRequest.cs @@ -0,0 +1,36 @@ +using Discord.Net.WebSockets; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Queue +{ + public class WebSocketRequest : IRequest + { + public IWebSocketClient Client { get; } + public byte[] Data { get; } + public bool IsText { get; } + public bool IgnoreLimit { get; } + public DateTimeOffset? TimeoutAt { get; } + public TaskCompletionSource Promise { get; } + public RequestOptions Options { get; } + public CancellationToken CancelToken { get; internal set; } + + public WebSocketRequest(IWebSocketClient client, byte[] data, bool isText, bool ignoreLimit, RequestOptions options) + { + Preconditions.NotNull(options, nameof(options)); + + Client = client; + Data = data; + IsText = isText; + IgnoreLimit = ignoreLimit; + Options = options; + TimeoutAt = options.Timeout.HasValue ? DateTimeOffset.UtcNow.AddMilliseconds(options.Timeout.Value) : (DateTimeOffset?)null; + Promise = new TaskCompletionSource(); + } + + public Task SendAsync() + => Client.SendAsync(Data, 0, Data.Length, IsText); + } +} diff --git a/src/Discord.Net.Rest/Net/RateLimitInfo.cs b/src/Discord.Net.Rest/Net/RateLimitInfo.cs new file mode 100644 index 0000000..42b9766 --- /dev/null +++ b/src/Discord.Net.Rest/Net/RateLimitInfo.cs @@ -0,0 +1,78 @@ +using Discord.API; +using Discord.Net.Rest; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +namespace Discord.Net +{ + /// + /// Represents a REST-Based ratelimit info. + /// + public struct RateLimitInfo : IRateLimitInfo + { + /// + public bool IsGlobal { get; } + + /// + public int? Limit { get; } + + /// + public int? Remaining { get; } + + /// + public int? RetryAfter { get; } + + /// + public DateTimeOffset? Reset { get; } + + /// + public TimeSpan? ResetAfter { get; private set; } + + /// + public string Bucket { get; } + + /// + public TimeSpan? Lag { get; } + + /// + public string Endpoint { get; } + + internal RateLimitInfo(Dictionary headers, string endpoint) + { + Endpoint = endpoint; + + IsGlobal = headers.TryGetValue("X-RateLimit-Global", out string temp) && + bool.TryParse(temp, out var isGlobal) && isGlobal; + Limit = headers.TryGetValue("X-RateLimit-Limit", out temp) && + int.TryParse(temp, NumberStyles.None, CultureInfo.InvariantCulture, out var limit) ? limit : (int?)null; + Remaining = headers.TryGetValue("X-RateLimit-Remaining", out temp) && + int.TryParse(temp, NumberStyles.None, CultureInfo.InvariantCulture, out var remaining) ? remaining : (int?)null; + Reset = headers.TryGetValue("X-RateLimit-Reset", out temp) && + double.TryParse(temp, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var reset) ? DateTimeOffset.FromUnixTimeMilliseconds((long)(reset * 1000)) : (DateTimeOffset?)null; + RetryAfter = headers.TryGetValue("Retry-After", out temp) && + int.TryParse(temp, NumberStyles.None, CultureInfo.InvariantCulture, out var retryAfter) ? retryAfter : (int?)null; + ResetAfter = headers.TryGetValue("X-RateLimit-Reset-After", out temp) && + double.TryParse(temp, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var resetAfter) ? TimeSpan.FromSeconds(resetAfter) : (TimeSpan?)null; + Bucket = headers.TryGetValue("X-RateLimit-Bucket", out temp) ? temp : null; + Lag = headers.TryGetValue("Date", out temp) && + DateTimeOffset.TryParse(temp, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) ? DateTimeOffset.UtcNow - date : (TimeSpan?)null; + } + + internal Ratelimit ReadRatelimitPayload(Stream response) + { + if (response != null && response.Length != 0) + { + using (TextReader text = new StreamReader(response)) + using (JsonReader reader = new JsonTextReader(text)) + { + return Discord.Rest.DiscordRestClient.Serializer.Deserialize(reader); + } + } + + return null; + } + } +} diff --git a/src/Discord.Net.Rest/Program.cs b/src/Discord.Net.Rest/Program.cs deleted file mode 100644 index 3751555..0000000 --- a/src/Discord.Net.Rest/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/src/Discord.Net.Rest/Utils/EmbedBuilderUtils.cs b/src/Discord.Net.Rest/Utils/EmbedBuilderUtils.cs new file mode 100644 index 0000000..511aa0a --- /dev/null +++ b/src/Discord.Net.Rest/Utils/EmbedBuilderUtils.cs @@ -0,0 +1,71 @@ +using Discord.Net.Converters; +using Newtonsoft.Json; + +using System; + +namespace Discord.Rest; + +public static class EmbedBuilderUtils +{ + private static Lazy _settings = new(() => + { + var serializer = new JsonSerializerSettings() + { + ContractResolver = new DiscordContractResolver() + }; + return serializer; + }); + + /// + /// Parses a string into an . + /// + /// The json string to parse. + /// An with populated values from the passed . + /// Thrown if the string passed is not valid json. + public static EmbedBuilder Parse(string json) + { + try + { + var model = JsonConvert.DeserializeObject(json, _settings.Value); + + var embed = model?.ToEntity(); + + if (embed is not null) + return embed.ToEmbedBuilder(); + + return new EmbedBuilder(); + } + catch + { + throw; + } + } + + /// + /// Tries to parse a string into an . + /// + /// The json string to parse. + /// The with populated values. An empty instance if method returns . + /// if was successfully parsed. if not. + public static bool TryParse(string json, out EmbedBuilder builder) + { + builder = new EmbedBuilder(); + try + { + var model = JsonConvert.DeserializeObject(json, _settings.Value); + + var embed = model?.ToEntity(); + + if (embed is not null) + { + builder = embed.ToEmbedBuilder(); + return true; + } + return false; + } + catch + { + return false; + } + } +} diff --git a/src/Discord.Net.Rest/Utils/HexConverter.cs b/src/Discord.Net.Rest/Utils/HexConverter.cs new file mode 100644 index 0000000..ebd959d --- /dev/null +++ b/src/Discord.Net.Rest/Utils/HexConverter.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + internal class HexConverter + { + public static byte[] HexToByteArray(string hex) + { + if (hex.Length % 2 == 1) + throw new Exception("The binary key cannot have an odd number of digits"); + + byte[] arr = new byte[hex.Length >> 1]; + + for (int i = 0; i < hex.Length >> 1; ++i) + { + arr[i] = (byte)((GetHexVal(hex[i << 1]) << 4) + (GetHexVal(hex[(i << 1) + 1]))); + } + + return arr; + } + private static int GetHexVal(char hex) + { + int val = (int)hex; + //For uppercase A-F letters: + //return val - (val < 58 ? 48 : 55); + //For lowercase a-f letters: + //return val - (val < 58 ? 48 : 87); + //Or the two combined, but a bit slower: + return val - (val < 58 ? 48 : (val < 97 ? 55 : 87)); + } + } +} diff --git a/src/Discord.Net.Rest/Utils/TypingNotifier.cs b/src/Discord.Net.Rest/Utils/TypingNotifier.cs new file mode 100644 index 0000000..745dbd3 --- /dev/null +++ b/src/Discord.Net.Rest/Utils/TypingNotifier.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + internal class TypingNotifier : IDisposable + { + private readonly CancellationTokenSource _cancelToken; + private readonly IMessageChannel _channel; + private readonly RequestOptions _options; + + public TypingNotifier(IMessageChannel channel, RequestOptions options) + { + _cancelToken = new CancellationTokenSource(); + _channel = channel; + _options = options; + _ = RunAsync(); + } + + private async Task RunAsync() + { + try + { + var token = _cancelToken.Token; + while (!_cancelToken.IsCancellationRequested) + { + try + { + await _channel.TriggerTypingAsync(_options).ConfigureAwait(false); + } + catch + { + // ignored + } + + await Task.Delay(9500, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + } + + public void Dispose() + { + _cancelToken.Cancel(); + } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/ApplicationCommandCreatedUpdatedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/ApplicationCommandCreatedUpdatedEvent.cs new file mode 100644 index 0000000..96bdc9c --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/ApplicationCommandCreatedUpdatedEvent.cs @@ -0,0 +1,8 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class ApplicationCommandCreatedUpdatedEvent : ApplicationCommand + { + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/AuditLogCreatedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/AuditLogCreatedEvent.cs new file mode 100644 index 0000000..4c208c7 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/AuditLogCreatedEvent.cs @@ -0,0 +1,9 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway; + +internal class AuditLogCreatedEvent : AuditLogEntry +{ + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/AutoModActionExecutedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/AutoModActionExecutedEvent.cs new file mode 100644 index 0000000..44d2834 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/AutoModActionExecutedEvent.cs @@ -0,0 +1,38 @@ +using Newtonsoft.Json; +namespace Discord.API.Gateway; + +internal class AutoModActionExecutedEvent +{ + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("action")] + public Discord.API.AutoModAction Action { get; set; } + + [JsonProperty("rule_id")] + public ulong RuleId { get; set; } + + [JsonProperty("rule_trigger_type")] + public AutoModTriggerType TriggerType { get; set; } + + [JsonProperty("user_id")] + public ulong UserId { get; set; } + + [JsonProperty("channel_id")] + public Optional ChannelId { get; set; } + + [JsonProperty("message_id")] + public Optional MessageId { get; set; } + + [JsonProperty("alert_system_message_id")] + public Optional AlertSystemMessageId { get; set; } + + [JsonProperty("content")] + public string Content { get; set; } + + [JsonProperty("matched_keyword")] + public Optional MatchedKeyword { get; set; } + + [JsonProperty("matched_content")] + public Optional MatchedContent { get; set; } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/ExtendedGuild.cs b/src/Discord.Net.WebSocket/API/Gateway/ExtendedGuild.cs new file mode 100644 index 0000000..04ee38c --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/ExtendedGuild.cs @@ -0,0 +1,35 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Gateway +{ + internal class ExtendedGuild : Guild + { + [JsonProperty("unavailable")] + public bool? Unavailable { get; set; } + + [JsonProperty("member_count")] + public int MemberCount { get; set; } + + [JsonProperty("large")] + public bool Large { get; set; } + + [JsonProperty("presences")] + public Presence[] Presences { get; set; } + + [JsonProperty("members")] + public GuildMember[] Members { get; set; } + + [JsonProperty("channels")] + public Channel[] Channels { get; set; } + + [JsonProperty("joined_at")] + public DateTimeOffset JoinedAt { get; set; } + + [JsonProperty("threads")] + public new Channel[] Threads { get; set; } + + [JsonProperty("guild_scheduled_events")] + public GuildScheduledEvent[] GuildScheduledEvents { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GatewayOpCode.cs b/src/Discord.Net.WebSocket/API/Gateway/GatewayOpCode.cs new file mode 100644 index 0000000..6f8bf48 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GatewayOpCode.cs @@ -0,0 +1,32 @@ +namespace Discord.API.Gateway +{ + internal enum GatewayOpCode : byte + { + /// C←S - Used to send most events. + Dispatch = 0, + /// C↔S - Used to keep the connection alive and measure latency. + Heartbeat = 1, + /// C→S - Used to associate a connection with a token and specify configuration. + Identify = 2, + /// C→S - Used to update client's status and current game id. + PresenceUpdate = 3, + /// C→S - Used to join a particular voice channel. + VoiceStateUpdate = 4, + /// C→S - Used to ensure the guild's voice server is alive. + VoiceServerPing = 5, + /// C→S - Used to resume a connection after a redirect occurs. + Resume = 6, + /// C←S - Used to notify a client that they must reconnect to another gateway. + Reconnect = 7, + /// C→S - Used to request members that were withheld by large_threshold + RequestGuildMembers = 8, + /// C←S - Used to notify the client that their session has expired and cannot be resumed. + InvalidSession = 9, + /// C←S - Used to provide information to the client immediately on connection. + Hello = 10, + /// C←S - Used to reply to a client's heartbeat. + HeartbeatAck = 11, + /// C→S - Used to request presence updates from particular guilds. + GuildSync = 12 + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildBanEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildBanEvent.cs new file mode 100644 index 0000000..a8a72e7 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildBanEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildBanEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("user")] + public User User { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildEmojiUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildEmojiUpdateEvent.cs new file mode 100644 index 0000000..33c10e6 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildEmojiUpdateEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildEmojiUpdateEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("emojis")] + public Emoji[] Emojis { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildJoinRequestDeleteEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildJoinRequestDeleteEvent.cs new file mode 100644 index 0000000..cb6fc5f --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildJoinRequestDeleteEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildJoinRequestDeleteEvent + { + [JsonProperty("user_id")] + public ulong UserId { get; set; } + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildMemberAddEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberAddEvent.cs new file mode 100644 index 0000000..dd42978 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberAddEvent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildMemberAddEvent : GuildMember + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildMemberRemoveEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberRemoveEvent.cs new file mode 100644 index 0000000..ec7df8f --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberRemoveEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildMemberRemoveEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("user")] + public User User { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildMemberUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberUpdateEvent.cs new file mode 100644 index 0000000..0f6fa6f --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberUpdateEvent.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Gateway +{ + internal class GuildMemberUpdateEvent : GuildMember + { + [JsonProperty("joined_at")] + public new DateTimeOffset? JoinedAt { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildMembersChunkEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildMembersChunkEvent.cs new file mode 100644 index 0000000..26114bf --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildMembersChunkEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildMembersChunkEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("members")] + public GuildMember[] Members { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildRoleCreateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildRoleCreateEvent.cs new file mode 100644 index 0000000..3b02164 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildRoleCreateEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildRoleCreateEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("role")] + public Role Role { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildRoleDeleteEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildRoleDeleteEvent.cs new file mode 100644 index 0000000..d9bdb98 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildRoleDeleteEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildRoleDeleteEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("role_id")] + public ulong RoleId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildRoleUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildRoleUpdateEvent.cs new file mode 100644 index 0000000..f898849 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildRoleUpdateEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildRoleUpdateEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("role")] + public Role Role { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildScheduledEventUserAddRemoveEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildScheduledEventUserAddRemoveEvent.cs new file mode 100644 index 0000000..3fc9591 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildScheduledEventUserAddRemoveEvent.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Gateway +{ + internal class GuildScheduledEventUserAddRemoveEvent + { + [JsonProperty("guild_scheduled_event_id")] + public ulong EventId { get; set; } + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("user_id")] + public ulong UserId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildStickerUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildStickerUpdateEvent.cs new file mode 100644 index 0000000..f0ecd3a --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildStickerUpdateEvent.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildStickerUpdateEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("stickers")] + public Sticker[] Stickers { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildSyncEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildSyncEvent.cs new file mode 100644 index 0000000..ba4c1ca --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildSyncEvent.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class GuildSyncEvent + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("large")] + public bool Large { get; set; } + + [JsonProperty("presences")] + public Presence[] Presences { get; set; } + [JsonProperty("members")] + public GuildMember[] Members { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/HelloEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/HelloEvent.cs new file mode 100644 index 0000000..a53a96f --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/HelloEvent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class HelloEvent + { + [JsonProperty("heartbeat_interval")] + public int HeartbeatInterval { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/IdentifyParams.cs b/src/Discord.Net.WebSocket/API/Gateway/IdentifyParams.cs new file mode 100644 index 0000000..96c7cb3 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/IdentifyParams.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API.Gateway +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class IdentifyParams + { + [JsonProperty("token")] + public string Token { get; set; } + [JsonProperty("properties")] + public IDictionary Properties { get; set; } + [JsonProperty("large_threshold")] + public int LargeThreshold { get; set; } + [JsonProperty("shard")] + public Optional ShardingParams { get; set; } + [JsonProperty("presence")] + public Optional Presence { get; set; } + [JsonProperty("intents")] + public Optional Intents { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/IntegrationDeletedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/IntegrationDeletedEvent.cs new file mode 100644 index 0000000..cf6e70c --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/IntegrationDeletedEvent.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class IntegrationDeletedEvent + { + [JsonProperty("id")] + public ulong Id { get; set; } + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("application_id")] + public Optional ApplicationID { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/InviteCreateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/InviteCreateEvent.cs new file mode 100644 index 0000000..013471c --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/InviteCreateEvent.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Gateway +{ + internal class InviteCreateEvent + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("code")] + public string Code { get; set; } + [JsonProperty("created_at")] + public DateTimeOffset CreatedAt { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("inviter")] + public Optional Inviter { get; set; } + [JsonProperty("max_age")] + public int MaxAge { get; set; } + [JsonProperty("max_uses")] + public int MaxUses { get; set; } + [JsonProperty("target_user")] + public Optional TargetUser { get; set; } + [JsonProperty("target_type")] + public Optional TargetUserType { get; set; } + [JsonProperty("temporary")] + public bool Temporary { get; set; } + [JsonProperty("uses")] + public int Uses { get; set; } + + [JsonProperty("target_application")] + public Optional Application { get; set; } + + [JsonProperty("expires_at")] + public Optional ExpiresAt { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/InviteCreatedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/InviteCreatedEvent.cs new file mode 100644 index 0000000..1613cdf --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/InviteCreatedEvent.cs @@ -0,0 +1,32 @@ +using Discord.API; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Gateway +{ + internal class InviteCreatedEvent + { + [JsonProperty("channel_id")] + public ulong ChannelID { get; set; } + [JsonProperty("code")] + public string InviteCode { get; set; } + [JsonProperty("timestamp")] + public Optional RawTimestamp { get; set; } + [JsonProperty("guild_id")] + public ulong? GuildID { get; set; } + [JsonProperty("inviter")] + public Optional Inviter { get; set; } + [JsonProperty("max_age")] + public int RawAge { get; set; } + [JsonProperty("max_uses")] + public int MaxUsers { get; set; } + [JsonProperty("temporary")] + public bool TempInvite { get; set; } + [JsonProperty("uses")] + public int Uses { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/InviteDeleteEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/InviteDeleteEvent.cs new file mode 100644 index 0000000..54bc755 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/InviteDeleteEvent.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class InviteDeleteEvent + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("code")] + public string Code { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/InviteDeletedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/InviteDeletedEvent.cs new file mode 100644 index 0000000..6bdd337 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/InviteDeletedEvent.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + internal class InviteDeletedEvent + { + [JsonProperty("channel_id")] + public ulong ChannelID { get; set; } + [JsonProperty("guild_id")] + public Optional GuildID { get; set; } + [JsonProperty("code")] + public string Code { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/MessageDeleteBulkEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/MessageDeleteBulkEvent.cs new file mode 100644 index 0000000..c503e63 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/MessageDeleteBulkEvent.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API.Gateway +{ + internal class MessageDeleteBulkEvent + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("ids")] + public ulong[] Ids { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/PartialApplication.cs b/src/Discord.Net.WebSocket/API/Gateway/PartialApplication.cs new file mode 100644 index 0000000..ae645e4 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/PartialApplication.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Gateway +{ + internal class PartialApplication + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("flags")] + public ApplicationFlags Flags { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/Reaction.cs b/src/Discord.Net.WebSocket/API/Gateway/Reaction.cs new file mode 100644 index 0000000..051da45 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/Reaction.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway; + +internal class Reaction +{ + [JsonProperty("user_id")] + public ulong UserId { get; set; } + + [JsonProperty("message_id")] + public ulong MessageId { get; set; } + + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("emoji")] + public Emoji Emoji { get; set; } + + [JsonProperty("member")] + public Optional Member { get; set; } + + [JsonProperty("burst")] + public bool IsBurst { get; set; } + + [JsonProperty("burst_colors")] + public Optional BurstColors { get; set; } + + [JsonProperty("type")] + public ReactionType Type { get; set; } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs new file mode 100644 index 0000000..fb6670a --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/ReadyEvent.cs @@ -0,0 +1,41 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class ReadyEvent + { + public class ReadState + { + [JsonProperty("id")] + public string ChannelId { get; set; } + [JsonProperty("mention_count")] + public int MentionCount { get; set; } + [JsonProperty("last_message_id")] + public string LastMessageId { get; set; } + } + + [JsonProperty("v")] + public int Version { get; set; } + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("session_id")] + public string SessionId { get; set; } + [JsonProperty("resume_gateway_url")] + public string ResumeGatewayUrl { get; set; } + [JsonProperty("read_state")] + public ReadState[] ReadStates { get; set; } + [JsonProperty("guilds")] + public ExtendedGuild[] Guilds { get; set; } + [JsonProperty("private_channels")] + public Channel[] PrivateChannels { get; set; } + [JsonProperty("relationships")] + public Relationship[] Relationships { get; set; } + [JsonProperty("application")] + public PartialApplication Application { get; set; } + + //Ignored + /*[JsonProperty("user_settings")] + [JsonProperty("user_guild_settings")] + [JsonProperty("tutorial")]*/ + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/RecipientEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/RecipientEvent.cs new file mode 100644 index 0000000..778b570 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/RecipientEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class RecipientEvent + { + [JsonProperty("user")] + public User User { get; set; } + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsEvent.cs new file mode 100644 index 0000000..582dbd6 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class RemoveAllReactionsEvent + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("message_id")] + public ulong MessageId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsForEmoteEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsForEmoteEvent.cs new file mode 100644 index 0000000..7f804d3 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/RemoveAllReactionsForEmoteEvent.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class RemoveAllReactionsForEmoteEvent + { + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("message_id")] + public ulong MessageId { get; set; } + [JsonProperty("emoji")] + public Emoji Emoji { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/RequestMembersParams.cs b/src/Discord.Net.WebSocket/API/Gateway/RequestMembersParams.cs new file mode 100644 index 0000000..f7a63e3 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/RequestMembersParams.cs @@ -0,0 +1,17 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API.Gateway +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class RequestMembersParams + { + [JsonProperty("query")] + public string Query { get; set; } + [JsonProperty("limit")] + public int Limit { get; set; } + + [JsonProperty("guild_id")] + public IEnumerable GuildIds { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/ResumeParams.cs b/src/Discord.Net.WebSocket/API/Gateway/ResumeParams.cs new file mode 100644 index 0000000..826e8fa --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/ResumeParams.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class ResumeParams + { + [JsonProperty("token")] + public string Token { get; set; } + [JsonProperty("session_id")] + public string SessionId { get; set; } + [JsonProperty("seq")] + public int Sequence { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/ResumedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/ResumedEvent.cs new file mode 100644 index 0000000..1225cd3 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/ResumedEvent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class ResumedEvent + { + [JsonProperty("heartbeat_interval")] + public int HeartbeatInterval { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/StatusUpdateParams.cs b/src/Discord.Net.WebSocket/API/Gateway/StatusUpdateParams.cs new file mode 100644 index 0000000..cbde225 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/StatusUpdateParams.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class PresenceUpdateParams + + { + [JsonProperty("status")] + public UserStatus Status { get; set; } + [JsonProperty("since", NullValueHandling = NullValueHandling.Include), Int53] + public long? IdleSince { get; set; } + [JsonProperty("afk")] + public bool IsAFK { get; set; } + [JsonProperty("activities")] + public object[] Activities { get; set; } // TODO, change to interface later + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/ThreadListSyncEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/ThreadListSyncEvent.cs new file mode 100644 index 0000000..5084f6c --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/ThreadListSyncEvent.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class ThreadListSyncEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("channel_ids")] + public Optional ChannelIds { get; set; } + + [JsonProperty("threads")] + public Channel[] Threads { get; set; } + + [JsonProperty("members")] + public ThreadMember[] Members { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/ThreadMembersUpdate.cs b/src/Discord.Net.WebSocket/API/Gateway/ThreadMembersUpdate.cs new file mode 100644 index 0000000..83d2c0e --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/ThreadMembersUpdate.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class ThreadMembersUpdated + { + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("member_count")] + public int MemberCount { get; set; } + + [JsonProperty("added_members")] + public Optional AddedMembers { get; set; } + + [JsonProperty("removed_member_ids")] + public Optional RemovedMemberIds { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/TypingStartEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/TypingStartEvent.cs new file mode 100644 index 0000000..c2ced17 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/TypingStartEvent.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class TypingStartEvent + { + [JsonProperty("user_id")] + public ulong UserId { get; set; } + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + [JsonProperty("member")] + public Optional Member { get; set; } + [JsonProperty("timestamp")] + public int Timestamp { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/VoiceChannelStatusUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/VoiceChannelStatusUpdateEvent.cs new file mode 100644 index 0000000..757e50b --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/VoiceChannelStatusUpdateEvent.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway; + +internal class VoiceChannelStatusUpdateEvent +{ + [JsonProperty("id")] + public ulong Id { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("status")] + public string Status { get; set; } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/VoiceServerUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/VoiceServerUpdateEvent.cs new file mode 100644 index 0000000..bd3db17 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/VoiceServerUpdateEvent.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class VoiceServerUpdateEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("endpoint")] + public string Endpoint { get; set; } + [JsonProperty("token")] + public string Token { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/VoiceStateUpdateParams.cs b/src/Discord.Net.WebSocket/API/Gateway/VoiceStateUpdateParams.cs new file mode 100644 index 0000000..ad21b14 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/VoiceStateUpdateParams.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + [JsonObject(MemberSerialization = MemberSerialization.OptIn)] + internal class VoiceStateUpdateParams + { + [JsonProperty("self_mute")] + public bool SelfMute { get; set; } + [JsonProperty("self_deaf")] + public bool SelfDeaf { get; set; } + + [JsonProperty("guild_id")] + public ulong? GuildId { get; set; } + [JsonProperty("channel_id")] + public ulong? ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/WebhookUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/WebhookUpdateEvent.cs new file mode 100644 index 0000000..c1e6d53 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/WebhookUpdateEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class WebhookUpdateEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/WebhooksUpdatedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/WebhooksUpdatedEvent.cs new file mode 100644 index 0000000..5555dc8 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/WebhooksUpdatedEvent.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway +{ + internal class WebhooksUpdatedEvent + { + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/SocketFrame.cs b/src/Discord.Net.WebSocket/API/SocketFrame.cs new file mode 100644 index 0000000..11c82ec --- /dev/null +++ b/src/Discord.Net.WebSocket/API/SocketFrame.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API +{ + internal class SocketFrame + { + [JsonProperty("op")] + public int Operation { get; set; } + [JsonProperty("t", NullValueHandling = NullValueHandling.Ignore)] + public string Type { get; set; } + [JsonProperty("s", NullValueHandling = NullValueHandling.Ignore)] + public int? Sequence { get; set; } + [JsonProperty("d")] + public object Payload { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/ClientDisconnectEvent.cs b/src/Discord.Net.WebSocket/API/Voice/ClientDisconnectEvent.cs new file mode 100644 index 0000000..d7c1bb2 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/ClientDisconnectEvent.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.API.Voice; +internal class ClientDisconnectEvent +{ + [JsonProperty("user_id")] + public ulong UserId { get; set; } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/HelloEvent.cs b/src/Discord.Net.WebSocket/API/Voice/HelloEvent.cs new file mode 100644 index 0000000..8fdb080 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/HelloEvent.cs @@ -0,0 +1,10 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + internal class HelloEvent + { + [JsonProperty("heartbeat_interval")] + public int HeartbeatInterval { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/IdentifyParams.cs b/src/Discord.Net.WebSocket/API/Voice/IdentifyParams.cs new file mode 100644 index 0000000..508b70d --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/IdentifyParams.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + internal class IdentifyParams + { + [JsonProperty("server_id")] + public ulong GuildId { get; set; } + [JsonProperty("user_id")] + public ulong UserId { get; set; } + [JsonProperty("session_id")] + public string SessionId { get; set; } + [JsonProperty("token")] + public string Token { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/ReadyEvent.cs b/src/Discord.Net.WebSocket/API/Voice/ReadyEvent.cs new file mode 100644 index 0000000..fb91057 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/ReadyEvent.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; +using System; + +namespace Discord.API.Voice +{ + internal class ReadyEvent + { + [JsonProperty("ssrc")] + public uint SSRC { get; set; } + [JsonProperty("ip")] + public string Ip { get; set; } + [JsonProperty("port")] + public ushort Port { get; set; } + [JsonProperty("modes")] + public string[] Modes { get; set; } + [JsonProperty("heartbeat_interval")] + [Obsolete("This field is erroneous and should not be used", true)] + public int HeartbeatInterval { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/ResumeParams.cs b/src/Discord.Net.WebSocket/API/Voice/ResumeParams.cs new file mode 100644 index 0000000..5d3730a --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/ResumeParams.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + public class ResumeParams + { + [JsonProperty("server_id")] + public ulong ServerId { get; set; } + [JsonProperty("session_id")] + public string SessionId { get; set; } + [JsonProperty("token")] + public string Token { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/SelectProtocolParams.cs b/src/Discord.Net.WebSocket/API/Voice/SelectProtocolParams.cs new file mode 100644 index 0000000..2e9bd15 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/SelectProtocolParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + internal class SelectProtocolParams + { + [JsonProperty("protocol")] + public string Protocol { get; set; } + [JsonProperty("data")] + public UdpProtocolInfo Data { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/SessionDescriptionEvent.cs b/src/Discord.Net.WebSocket/API/Voice/SessionDescriptionEvent.cs new file mode 100644 index 0000000..043b9fe --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/SessionDescriptionEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + internal class SessionDescriptionEvent + { + [JsonProperty("secret_key")] + public byte[] SecretKey { get; set; } + [JsonProperty("mode")] + public string Mode { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/SpeakingEvent.cs b/src/Discord.Net.WebSocket/API/Voice/SpeakingEvent.cs new file mode 100644 index 0000000..c1746e9 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/SpeakingEvent.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + internal class SpeakingEvent + { + [JsonProperty("user_id")] + public ulong UserId { get; set; } + [JsonProperty("ssrc")] + public uint Ssrc { get; set; } + [JsonProperty("speaking")] + public bool Speaking { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/SpeakingParams.cs b/src/Discord.Net.WebSocket/API/Voice/SpeakingParams.cs new file mode 100644 index 0000000..e03bfc7 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/SpeakingParams.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + internal class SpeakingParams + { + [JsonProperty("speaking")] + public bool IsSpeaking { get; set; } + [JsonProperty("delay")] + public int Delay { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/UdpProtocolInfo.cs b/src/Discord.Net.WebSocket/API/Voice/UdpProtocolInfo.cs new file mode 100644 index 0000000..5e69a03 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/UdpProtocolInfo.cs @@ -0,0 +1,14 @@ +using Newtonsoft.Json; + +namespace Discord.API.Voice +{ + internal class UdpProtocolInfo + { + [JsonProperty("address")] + public string Address { get; set; } + [JsonProperty("port")] + public int Port { get; set; } + [JsonProperty("mode")] + public string Mode { get; set; } + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/VoiceCloseCode.cs b/src/Discord.Net.WebSocket/API/Voice/VoiceCloseCode.cs new file mode 100644 index 0000000..498d595 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/VoiceCloseCode.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + /// + /// Represents generic op codes for voice disconnect. + /// + public enum VoiceCloseCode + { + /// + /// You sent an invalid opcode. + /// + UnknownOpcode = 4001, + /// + /// You sent an invalid payload in your identifying to the Gateway. + /// + DecodeFailure = 4002, + /// + /// You sent a payload before identifying with the Gateway. + /// + NotAuthenticated = 4003, + /// + /// The token you sent in your identify payload is incorrect. + /// + AuthenticationFailed = 4004, + /// + /// You sent more than one identify payload. Stahp. + /// + AlreadyAuthenticated = 4005, + /// + /// Your session is no longer valid. + /// + SessionNolongerValid = 4006, + /// + /// Your session has timed out. + /// + SessionTimeout = 4009, + /// + /// We can't find the server you're trying to connect to. + /// + ServerNotFound = 4011, + /// + /// We didn't recognize the protocol you sent. + /// + UnknownProtocol = 4012, + /// + /// Channel was deleted, you were kicked, voice server changed, or the main gateway session was dropped. Should not reconnect. + /// + Disconnected = 4014, + /// + /// The server crashed. Our bad! Try resuming. + /// + VoiceServerCrashed = 4015, + /// + /// We didn't recognize your encryption. + /// + UnknownEncryptionMode = 4016, + } +} diff --git a/src/Discord.Net.WebSocket/API/Voice/VoiceOpCode.cs b/src/Discord.Net.WebSocket/API/Voice/VoiceOpCode.cs new file mode 100644 index 0000000..9400650 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Voice/VoiceOpCode.cs @@ -0,0 +1,28 @@ +namespace Discord.API.Voice +{ + internal enum VoiceOpCode : byte + { + /// C→S - Used to associate a connection with a token. + Identify = 0, + /// C→S - Used to specify configuration. + SelectProtocol = 1, + /// C←S - Used to notify that the voice connection was successful and informs the client of available protocols. + Ready = 2, + /// C→S - Used to keep the connection alive and measure latency. + Heartbeat = 3, + /// C←S - Used to provide an encryption key to the client. + SessionDescription = 4, + /// C↔S - Used to inform that a certain user is speaking. + Speaking = 5, + /// C←S - Used to reply to a client's heartbeat. + HeartbeatAck = 6, + /// C→S - Used to resume a connection. + Resume = 7, + /// C←S - Used to inform the client the heartbeat interval. + Hello = 8, + /// C←S - Used to acknowledge a resumed connection. + Resumed = 9, + /// C←S - Used to notify that a client has disconnected. + ClientDisconnect = 13, + } +} diff --git a/src/Discord.Net.WebSocket/AssemblyInfo.cs b/src/Discord.Net.WebSocket/AssemblyInfo.cs new file mode 100644 index 0000000..442ec7d --- /dev/null +++ b/src/Discord.Net.WebSocket/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Discord.Net.Relay")] +[assembly: InternalsVisibleTo("Discord.Net.Tests")] +[assembly: InternalsVisibleTo("Discord.Net.Tests.Unit")] diff --git a/src/Discord.Net.WebSocket/Audio/AudioClient.Events.cs b/src/Discord.Net.WebSocket/Audio/AudioClient.Events.cs new file mode 100644 index 0000000..ea8d2d2 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/AudioClient.Events.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + internal partial class AudioClient + { + public event Func Connected + { + add { _connectedEvent.Add(value); } + remove { _connectedEvent.Remove(value); } + } + private readonly AsyncEvent> _connectedEvent = new AsyncEvent>(); + public event Func Disconnected + { + add { _disconnectedEvent.Add(value); } + remove { _disconnectedEvent.Remove(value); } + } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + public event Func LatencyUpdated + { + add { _latencyUpdatedEvent.Add(value); } + remove { _latencyUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _latencyUpdatedEvent = new AsyncEvent>(); + public event Func UdpLatencyUpdated + { + add { _udpLatencyUpdatedEvent.Add(value); } + remove { _udpLatencyUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _udpLatencyUpdatedEvent = new AsyncEvent>(); + public event Func StreamCreated + { + add { _streamCreatedEvent.Add(value); } + remove { _streamCreatedEvent.Remove(value); } + } + private readonly AsyncEvent> _streamCreatedEvent = new AsyncEvent>(); + public event Func StreamDestroyed + { + add { _streamDestroyedEvent.Add(value); } + remove { _streamDestroyedEvent.Remove(value); } + } + private readonly AsyncEvent> _streamDestroyedEvent = new AsyncEvent>(); + public event Func SpeakingUpdated + { + add { _speakingUpdatedEvent.Add(value); } + remove { _speakingUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _speakingUpdatedEvent = new AsyncEvent>(); + public event Func ClientDisconnected + { + add { _clientDisconnectedEvent.Add(value); } + remove { _clientDisconnectedEvent.Remove(value); } + } + private readonly AsyncEvent> _clientDisconnectedEvent = new AsyncEvent>(); + } +} diff --git a/src/Discord.Net.WebSocket/Audio/AudioClient.cs b/src/Discord.Net.WebSocket/Audio/AudioClient.cs new file mode 100644 index 0000000..018547c --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/AudioClient.cs @@ -0,0 +1,643 @@ +using Discord.API.Voice; +using Discord.Audio.Streams; +using Discord.Logging; +using Discord.Net; +using Discord.Net.Converters; +using Discord.WebSocket; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + internal partial class AudioClient : IAudioClient + { + private static readonly int ConnectionTimeoutMs = 30000; // 30 seconds + private static readonly int KeepAliveIntervalMs = 5000; // 5 seconds + + private static readonly int[] BlacklistedResumeCodes = new int[] + { + 4001, 4002, 4003, 4004, 4005, 4006, 4009, 4012, 1014, 4016 + }; + + private struct StreamPair + { + public AudioInStream Reader; + public AudioOutStream Writer; + + public StreamPair(AudioInStream reader, AudioOutStream writer) + { + Reader = reader; + Writer = writer; + } + } + + private readonly Logger _audioLogger; + private readonly JsonSerializer _serializer; + private readonly ConnectionManager _connection; + private readonly SemaphoreSlim _stateLock; + private readonly ConcurrentQueue _heartbeatTimes; + private readonly ConcurrentQueue> _keepaliveTimes; + private readonly ConcurrentDictionary _ssrcMap; + private readonly ConcurrentDictionary _streams; + + private Task _heartbeatTask, _keepaliveTask; + private int _heartbeatInterval; + private long _lastMessageTime; + private string _url, _sessionId, _token; + private ulong _userId; + private uint _ssrc; + private bool _isSpeaking; + private StopReason _stopReason; + private bool _resuming; + + public SocketGuild Guild { get; } + public DiscordVoiceAPIClient ApiClient { get; private set; } + public int Latency { get; private set; } + public int UdpLatency { get; private set; } + public ulong ChannelId { get; internal set; } + internal byte[] SecretKey { get; private set; } + internal bool IsFinished { get; private set; } + + private DiscordSocketClient Discord => Guild.Discord; + public ConnectionState ConnectionState => _connection.State; + + /// Creates a new REST/WebSocket discord client. + internal AudioClient(SocketGuild guild, int clientId, ulong channelId) + { + Guild = guild; + ChannelId = channelId; + _audioLogger = Discord.LogManager.CreateLogger($"Audio #{clientId}"); + + ApiClient = new DiscordVoiceAPIClient(guild.Id, Discord.WebSocketProvider, Discord.UdpSocketProvider); + ApiClient.SentGatewayMessage += async opCode => await _audioLogger.DebugAsync($"Sent {opCode}").ConfigureAwait(false); + ApiClient.SentDiscovery += async () => await _audioLogger.DebugAsync("Sent Discovery").ConfigureAwait(false); + //ApiClient.SentData += async bytes => await _audioLogger.DebugAsync($"Sent {bytes} Bytes").ConfigureAwait(false); + ApiClient.ReceivedEvent += ProcessMessageAsync; + ApiClient.ReceivedPacket += ProcessPacketAsync; + + _stateLock = new SemaphoreSlim(1, 1); + _connection = new ConnectionManager(_stateLock, _audioLogger, ConnectionTimeoutMs, + OnConnectingAsync, OnDisconnectingAsync, x => ApiClient.Disconnected += x); + _connection.Connected += () => _connectedEvent.InvokeAsync(); + _connection.Disconnected += (exception, _) => _disconnectedEvent.InvokeAsync(exception); + _heartbeatTimes = new ConcurrentQueue(); + _keepaliveTimes = new ConcurrentQueue>(); + _ssrcMap = new ConcurrentDictionary(); + _streams = new ConcurrentDictionary(); + + _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + _serializer.Error += (s, e) => + { + _audioLogger.WarningAsync(e.ErrorContext.Error).GetAwaiter().GetResult(); + e.ErrorContext.Handled = true; + }; + + LatencyUpdated += async (old, val) => await _audioLogger.DebugAsync($"Latency = {val} ms").ConfigureAwait(false); + UdpLatencyUpdated += async (old, val) => await _audioLogger.DebugAsync($"UDP Latency = {val} ms").ConfigureAwait(false); + } + + internal Task StartAsync(string url, ulong userId, string sessionId, string token) + { + _url = url; + _userId = userId; + _sessionId = sessionId; + _token = token; + return _connection.StartAsync(); + } + + public IReadOnlyDictionary GetStreams() + { + return _streams.ToDictionary(pair => pair.Key, pair => pair.Value.Reader); + } + + public Task StopAsync() + => StopAsync(StopReason.Normal); + + internal Task StopAsync(StopReason stopReason) + { + _stopReason = stopReason; + return _connection.StopAsync(); + } + + private async Task OnConnectingAsync() + { + await _audioLogger.DebugAsync($"Connecting ApiClient. Voice server: wss://{_url}").ConfigureAwait(false); + await ApiClient.ConnectAsync($"wss://{_url}?v={DiscordConfig.VoiceAPIVersion}").ConfigureAwait(false); + await _audioLogger.DebugAsync($"Listening on port {ApiClient.UdpPort}").ConfigureAwait(false); + + if (!_resuming) + { + await _audioLogger.DebugAsync("Sending Identity").ConfigureAwait(false); + await ApiClient.SendIdentityAsync(_userId, _sessionId, _token).ConfigureAwait(false); + } + else + { + await _audioLogger.DebugAsync("Sending Resume").ConfigureAwait(false); + await ApiClient.SendResume(_token, _sessionId).ConfigureAwait(false); + } + + //Wait for READY + await _connection.WaitAsync().ConfigureAwait(false); + } + private async Task OnDisconnectingAsync(Exception ex) + { + await _audioLogger.DebugAsync("Disconnecting ApiClient").ConfigureAwait(false); + await ApiClient.DisconnectAsync().ConfigureAwait(false); + + if (_stopReason == StopReason.Unknown && ex.InnerException is WebSocketException exception) + { + await _audioLogger.WarningAsync( + $"Audio connection terminated with unknown reason. Code: {exception.ErrorCode} - {exception.Message}", + exception); + + if (_resuming) + { + await _audioLogger.WarningAsync("Resume failed"); + + _resuming = false; + + await FinishDisconnect(ex, true); + return; + } + + if (BlacklistedResumeCodes.Contains(exception.ErrorCode)) + { + await FinishDisconnect(ex, true); + return; + } + + await ClearHeartBeaters(); + + _resuming = true; + return; + } + + await FinishDisconnect(ex, _stopReason != StopReason.Moved); + + if (_stopReason == StopReason.Normal) + { + await _audioLogger.DebugAsync("Sending Voice State").ConfigureAwait(false); + await Discord.ApiClient.SendVoiceStateUpdateAsync(Guild.Id, null, false, false).ConfigureAwait(false); + } + + _stopReason = StopReason.Unknown; + } + + private async Task FinishDisconnect(Exception ex, bool wontTryReconnect) + { + await _audioLogger.DebugAsync("Finishing audio connection").ConfigureAwait(false); + + await ClearHeartBeaters().ConfigureAwait(false); + + if (wontTryReconnect) + { + await _connection.StopAsync().ConfigureAwait(false); + + await ClearInputStreamsAsync().ConfigureAwait(false); + + IsFinished = true; + } + } + + private async Task ClearHeartBeaters() + { + //Wait for tasks to complete + await _audioLogger.DebugAsync("Waiting for heartbeater").ConfigureAwait(false); + + if (_heartbeatTask != null) + await _heartbeatTask.ConfigureAwait(false); + _heartbeatTask = null; + + if (_keepaliveTask != null) + await _keepaliveTask.ConfigureAwait(false); + _keepaliveTask = null; + + while (_heartbeatTimes.TryDequeue(out _)) + { } + _lastMessageTime = 0; + + while (_keepaliveTimes.TryDequeue(out _)) + { } + } + + #region Streams + public AudioOutStream CreateOpusStream(int bufferMillis) + { + var outputStream = new OutputStream(ApiClient); //Ignores header + var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); //Passes header + var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header, passes + return new BufferedWriteStream(rtpWriter, this, bufferMillis, _connection.CancelToken, _audioLogger); //Generates header + } + public AudioOutStream CreateDirectOpusStream() + { + var outputStream = new OutputStream(ApiClient); //Ignores header + var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); //Passes header + return new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header (external input), passes + } + public AudioOutStream CreatePCMStream(AudioApplication application, int? bitrate, int bufferMillis, int packetLoss) + { + var outputStream = new OutputStream(ApiClient); //Ignores header + var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); //Passes header + var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header, passes + var bufferedStream = new BufferedWriteStream(rtpWriter, this, bufferMillis, _connection.CancelToken, _audioLogger); //Ignores header, generates header + return new OpusEncodeStream(bufferedStream, bitrate ?? (96 * 1024), application, packetLoss); //Generates header + } + public AudioOutStream CreateDirectPCMStream(AudioApplication application, int? bitrate, int packetLoss) + { + var outputStream = new OutputStream(ApiClient); //Ignores header + var sodiumEncrypter = new SodiumEncryptStream(outputStream, this); //Passes header + var rtpWriter = new RTPWriteStream(sodiumEncrypter, _ssrc); //Consumes header, passes + return new OpusEncodeStream(rtpWriter, bitrate ?? (96 * 1024), application, packetLoss); //Generates header + } + + internal async Task CreateInputStreamAsync(ulong userId) + { + //Assume Thread-safe + if (!_streams.ContainsKey(userId)) + { + var readerStream = new InputStream(); //Consumes header + var opusDecoder = new OpusDecodeStream(readerStream); //Passes header + //var jitterBuffer = new JitterBuffer(opusDecoder, _audioLogger); + var rtpReader = new RTPReadStream(opusDecoder); //Generates header + var decryptStream = new SodiumDecryptStream(rtpReader, this); //No header + _streams.TryAdd(userId, new StreamPair(readerStream, decryptStream)); + await _streamCreatedEvent.InvokeAsync(userId, readerStream); + } + } + internal AudioInStream GetInputStream(ulong id) + { + if (_streams.TryGetValue(id, out StreamPair streamPair)) + return streamPair.Reader; + + return null; + } + internal async Task RemoveInputStreamAsync(ulong userId) + { + if (_streams.TryRemove(userId, out StreamPair pair)) + { + await _streamDestroyedEvent.InvokeAsync(userId).ConfigureAwait(false); + pair.Reader.Dispose(); + } + } + internal async Task ClearInputStreamsAsync() + { + foreach (var pair in _streams) + { + await _streamDestroyedEvent.InvokeAsync(pair.Key).ConfigureAwait(false); + pair.Value.Reader.Dispose(); + } + _ssrcMap.Clear(); + _streams.Clear(); + } + #endregion + + private async Task ProcessMessageAsync(VoiceOpCode opCode, object payload) + { + _lastMessageTime = Environment.TickCount; + + try + { + switch (opCode) + { + case VoiceOpCode.Ready: + { + await _audioLogger.DebugAsync("Received Ready").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + _ssrc = data.SSRC; + + if (!data.Modes.Contains(DiscordVoiceAPIClient.Mode)) + throw new InvalidOperationException($"Discord does not support {DiscordVoiceAPIClient.Mode}"); + + ApiClient.SetUdpEndpoint(data.Ip, data.Port); + await ApiClient.SendDiscoveryAsync(_ssrc).ConfigureAwait(false); + + _heartbeatTask = RunHeartbeatAsync(_heartbeatInterval, _connection.CancelToken); + } + break; + case VoiceOpCode.SessionDescription: + { + await _audioLogger.DebugAsync("Received SessionDescription").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + if (data.Mode != DiscordVoiceAPIClient.Mode) + throw new InvalidOperationException($"Discord selected an unexpected mode: {data.Mode}"); + + SecretKey = data.SecretKey; + _isSpeaking = false; + await ApiClient.SendSetSpeaking(_isSpeaking).ConfigureAwait(false); + _keepaliveTask = RunKeepaliveAsync(_connection.CancelToken); + + _ = _connection.CompleteAsync(); + } + break; + case VoiceOpCode.HeartbeatAck: + { + await _audioLogger.DebugAsync("Received HeartbeatAck").ConfigureAwait(false); + + if (_heartbeatTimes.TryDequeue(out long time)) + { + int latency = (int)(Environment.TickCount - time); + int before = Latency; + Latency = latency; + + await _latencyUpdatedEvent.InvokeAsync(before, latency).ConfigureAwait(false); + } + } + break; + case VoiceOpCode.Hello: + { + await _audioLogger.DebugAsync("Received Hello").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + _heartbeatInterval = data.HeartbeatInterval; + } + break; + case VoiceOpCode.Speaking: + { + await _audioLogger.DebugAsync("Received Speaking").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + _ssrcMap[data.Ssrc] = data.UserId; + + await _speakingUpdatedEvent.InvokeAsync(data.UserId, data.Speaking); + } + break; + case VoiceOpCode.ClientDisconnect: + { + await _audioLogger.DebugAsync("Received ClientDisconnect").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + await _clientDisconnectedEvent.InvokeAsync(data.UserId); + } + break; + case VoiceOpCode.Resumed: + { + await _audioLogger.DebugAsync($"Voice connection resumed: wss://{_url}"); + _resuming = false; + + _heartbeatTask = RunHeartbeatAsync(_heartbeatInterval, _connection.CancelToken); + _keepaliveTask = RunKeepaliveAsync(_connection.CancelToken); + + _ = _connection.CompleteAsync(); + } + break; + default: + await _audioLogger.WarningAsync($"Unknown OpCode ({opCode})").ConfigureAwait(false); + break; + } + } + catch (Exception ex) + { + await _audioLogger.ErrorAsync($"Error handling {opCode}", ex).ConfigureAwait(false); + } + } + private async Task ProcessPacketAsync(byte[] packet) + { + try + { + if (_connection.State == ConnectionState.Connecting) + { + if (packet.Length != 74) + { + await _audioLogger.DebugAsync("Malformed Packet").ConfigureAwait(false); + return; + } + string ip; + try + { + ip = Encoding.UTF8.GetString(packet, 8, 74 - 10).TrimEnd('\0'); + } + catch (Exception ex) + { + await _audioLogger.DebugAsync("Malformed Packet", ex).ConfigureAwait(false); + return; + } + + await _audioLogger.DebugAsync("Received Discovery").ConfigureAwait(false); + await ApiClient.SendSelectProtocol(ip).ConfigureAwait(false); + } + else if (_connection.State == ConnectionState.Connected) + { + if (packet.Length == 8) + { + await _audioLogger.DebugAsync("Received Keepalive").ConfigureAwait(false); + + ulong value = + ((ulong)packet[0] >> 0) | + ((ulong)packet[1] >> 8) | + ((ulong)packet[2] >> 16) | + ((ulong)packet[3] >> 24) | + ((ulong)packet[4] >> 32) | + ((ulong)packet[5] >> 40) | + ((ulong)packet[6] >> 48) | + ((ulong)packet[7] >> 56); + + while (_keepaliveTimes.TryDequeue(out var pair)) + { + if (pair.Key == value) + { + int latency = Environment.TickCount - pair.Value; + int before = UdpLatency; + UdpLatency = latency; + + await _udpLatencyUpdatedEvent.InvokeAsync(before, latency).ConfigureAwait(false); + break; + } + } + } + else + { + if (!RTPReadStream.TryReadSsrc(packet, 0, out uint ssrc)) + { + await _audioLogger.DebugAsync("Malformed Frame").ConfigureAwait(false); + } + else if (!_ssrcMap.TryGetValue(ssrc, out ulong userId)) + { + await _audioLogger.DebugAsync($"Unknown SSRC {ssrc}").ConfigureAwait(false); + } + else if (!_streams.TryGetValue(userId, out StreamPair pair)) + { + await _audioLogger.DebugAsync($"Unknown User {userId}").ConfigureAwait(false); + } + else + { + try + { + await pair.Writer.WriteAsync(packet, 0, packet.Length).ConfigureAwait(false); + } + catch (Exception ex) + { + await _audioLogger.DebugAsync("Malformed Frame", ex).ConfigureAwait(false); + } + } + //await _audioLogger.DebugAsync($"Received {packet.Length} bytes from user {userId}").ConfigureAwait(false); + } + } + } + catch (Exception ex) + { + await _audioLogger.WarningAsync("Failed to process UDP packet", ex).ConfigureAwait(false); + } + } + + private async Task RunHeartbeatAsync(int intervalMillis, CancellationToken cancelToken) + { + int delayInterval = (int)(intervalMillis * DiscordConfig.HeartbeatIntervalFactor); + + // TODO: Clean this up when Discord's session patch is live + try + { + await _audioLogger.DebugAsync("Heartbeat Started").ConfigureAwait(false); + while (!cancelToken.IsCancellationRequested) + { + int now = Environment.TickCount; + + //Did server respond to our last heartbeat? + if (_heartbeatTimes.Count != 0 && (now - _lastMessageTime) > intervalMillis && + ConnectionState == ConnectionState.Connected) + { + _connection.Error(new Exception("Server missed last heartbeat")); + return; + } + + _heartbeatTimes.Enqueue(now); + try + { + await ApiClient.SendHeartbeatAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + await _audioLogger.WarningAsync("Failed to send heartbeat", ex).ConfigureAwait(false); + } + + int delay = Math.Max(0, delayInterval - Latency); + await Task.Delay(delay, cancelToken).ConfigureAwait(false); + } + await _audioLogger.DebugAsync("Heartbeat Stopped").ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await _audioLogger.DebugAsync("Heartbeat Stopped").ConfigureAwait(false); + } + catch (Exception ex) + { + await _audioLogger.ErrorAsync("Heartbeat Errored", ex).ConfigureAwait(false); + } + } + private async Task RunKeepaliveAsync(CancellationToken cancelToken) + { + try + { + await _audioLogger.DebugAsync("Keepalive Started").ConfigureAwait(false); + while (!cancelToken.IsCancellationRequested) + { + int now = Environment.TickCount; + + try + { + ulong value = await ApiClient.SendKeepaliveAsync().ConfigureAwait(false); + if (_keepaliveTimes.Count < 12) //No reply for 60 Seconds + _keepaliveTimes.Enqueue(new KeyValuePair(value, now)); + } + catch (Exception ex) + { + await _audioLogger.WarningAsync("Failed to send keepalive", ex).ConfigureAwait(false); + } + + await Task.Delay(KeepAliveIntervalMs, cancelToken).ConfigureAwait(false); + } + await _audioLogger.DebugAsync("Keepalive Stopped").ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await _audioLogger.DebugAsync("Keepalive Stopped").ConfigureAwait(false); + } + catch (Exception ex) + { + await _audioLogger.ErrorAsync("Keepalive Errored", ex).ConfigureAwait(false); + } + } + + public async Task SetSpeakingAsync(bool value) + { + if (_isSpeaking != value) + { + _isSpeaking = value; + await ApiClient.SendSetSpeaking(value).ConfigureAwait(false); + } + } + + /// + /// Waits until all post-disconnect actions are done. + /// + /// Maximum time to wait. + /// + /// A that represents an asynchronous process of waiting. + /// + internal async Task WaitForDisconnectAsync(TimeSpan timeout) + { + if (ConnectionState == ConnectionState.Disconnected) + return; + + var completion = new TaskCompletionSource(); + + var cts = new CancellationTokenSource(); + + var _ = Task.Delay(timeout, cts.Token).ContinueWith(_ => + { + completion.TrySetException(new TimeoutException("Exceeded maximum time to wait")); + cts.Dispose(); + }, cts.Token); + + _connection.Disconnected += HandleDisconnectSubscription; + + await completion.Task.ConfigureAwait(false); + + Task HandleDisconnectSubscription(Exception exception, bool reconnect) + { + try + { + cts.Cancel(); + completion.TrySetResult(exception); + } + finally + { + _connection.Disconnected -= HandleDisconnectSubscription; + cts.Dispose(); + } + + return Task.CompletedTask; + } + } + + internal void Dispose(bool disposing) + { + if (disposing) + { + StopAsync().GetAwaiter().GetResult(); + ApiClient.Dispose(); + _stateLock?.Dispose(); + } + } + /// + public void Dispose() => Dispose(true); + + internal enum StopReason + { + Unknown = 0, + Normal, + Disconnected, + Moved + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Opus/OpusApplication.cs b/src/Discord.Net.WebSocket/Audio/Opus/OpusApplication.cs new file mode 100644 index 0000000..68f9d41 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Opus/OpusApplication.cs @@ -0,0 +1,9 @@ +namespace Discord.Audio +{ + internal enum OpusApplication : int + { + Voice = 2048, + MusicOrMixed = 2049, + LowLatency = 2051 + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Opus/OpusConverter.cs b/src/Discord.Net.WebSocket/Audio/Opus/OpusConverter.cs new file mode 100644 index 0000000..c8a164f --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Opus/OpusConverter.cs @@ -0,0 +1,47 @@ +using System; + +namespace Discord.Audio +{ + internal abstract class OpusConverter : IDisposable + { + protected IntPtr _ptr; + + public const int SamplingRate = 48000; + public const int Channels = 2; + public const int FrameMillis = 20; + + public const int SampleBytes = sizeof(short) * Channels; + + public const int FrameSamplesPerChannel = SamplingRate / 1000 * FrameMillis; + public const int FrameSamples = FrameSamplesPerChannel * Channels; + public const int FrameBytes = FrameSamplesPerChannel * SampleBytes; + + protected bool _isDisposed = false; + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + _isDisposed = true; + } + ~OpusConverter() + { + Dispose(false); + } + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected static void CheckError(int result) + { + if (result < 0) + throw new Exception($"Opus Error: {(OpusError)result}"); + } + protected static void CheckError(OpusError error) + { + if ((int)error < 0) + throw new Exception($"Opus Error: {error}"); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Opus/OpusCtl.cs b/src/Discord.Net.WebSocket/Audio/Opus/OpusCtl.cs new file mode 100644 index 0000000..0eadb74 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Opus/OpusCtl.cs @@ -0,0 +1,11 @@ +namespace Discord.Audio +{ + //https://github.com/gcp/opus/blob/master/include/opus_defines.h + internal enum OpusCtl : int + { + SetBitrate = 4002, + SetBandwidth = 4008, + SetPacketLossPercent = 4014, + SetSignal = 4024 + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Opus/OpusDecoder.cs b/src/Discord.Net.WebSocket/Audio/Opus/OpusDecoder.cs new file mode 100644 index 0000000..9423003 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Opus/OpusDecoder.cs @@ -0,0 +1,43 @@ +using System; +using System.Runtime.InteropServices; + +namespace Discord.Audio +{ + internal unsafe class OpusDecoder : OpusConverter + { + [DllImport("opus", EntryPoint = "opus_decoder_create", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr CreateDecoder(int Fs, int channels, out OpusError error); + [DllImport("opus", EntryPoint = "opus_decoder_destroy", CallingConvention = CallingConvention.Cdecl)] + private static extern void DestroyDecoder(IntPtr decoder); + [DllImport("opus", EntryPoint = "opus_decode", CallingConvention = CallingConvention.Cdecl)] + private static extern int Decode(IntPtr st, byte* data, int len, byte* pcm, int max_frame_size, int decode_fec); + [DllImport("opus", EntryPoint = "opus_decoder_ctl", CallingConvention = CallingConvention.Cdecl)] + private static extern int DecoderCtl(IntPtr st, OpusCtl request, int value); + + public OpusDecoder() + { + _ptr = CreateDecoder(SamplingRate, Channels, out var error); + CheckError(error); + } + + public unsafe int DecodeFrame(byte[] input, int inputOffset, int inputCount, byte[] output, int outputOffset, bool decodeFEC) + { + int result = 0; + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + result = Decode(_ptr, inPtr + inputOffset, inputCount, outPtr + outputOffset, FrameSamplesPerChannel, decodeFEC ? 1 : 0); + CheckError(result); + return result * SampleBytes; + } + + protected override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (_ptr != IntPtr.Zero) + DestroyDecoder(_ptr); + base.Dispose(disposing); + } + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Opus/OpusEncoder.cs b/src/Discord.Net.WebSocket/Audio/Opus/OpusEncoder.cs new file mode 100644 index 0000000..8f05925 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Opus/OpusEncoder.cs @@ -0,0 +1,75 @@ +using System; +using System.Runtime.InteropServices; + +namespace Discord.Audio +{ + internal unsafe class OpusEncoder : OpusConverter + { + [DllImport("opus", EntryPoint = "opus_encoder_create", CallingConvention = CallingConvention.Cdecl)] + private static extern IntPtr CreateEncoder(int Fs, int channels, int application, out OpusError error); + [DllImport("opus", EntryPoint = "opus_encoder_destroy", CallingConvention = CallingConvention.Cdecl)] + private static extern void DestroyEncoder(IntPtr encoder); + [DllImport("opus", EntryPoint = "opus_encode", CallingConvention = CallingConvention.Cdecl)] + private static extern int Encode(IntPtr st, byte* pcm, int frame_size, byte* data, int max_data_bytes); + [DllImport("opus", EntryPoint = "opus_encoder_ctl", CallingConvention = CallingConvention.Cdecl)] + private static extern OpusError EncoderCtl(IntPtr st, OpusCtl request, int value); + + public AudioApplication Application { get; } + public int BitRate { get; } + + public OpusEncoder(int bitrate, AudioApplication application, int packetLoss) + { + if (bitrate < 1 || bitrate > DiscordVoiceAPIClient.MaxBitrate) + throw new ArgumentOutOfRangeException(nameof(bitrate)); + + Application = application; + BitRate = bitrate; + + OpusApplication opusApplication; + OpusSignal opusSignal; + switch (application) + { + case AudioApplication.Mixed: + opusApplication = OpusApplication.MusicOrMixed; + opusSignal = OpusSignal.Auto; + break; + case AudioApplication.Music: + opusApplication = OpusApplication.MusicOrMixed; + opusSignal = OpusSignal.Music; + break; + case AudioApplication.Voice: + opusApplication = OpusApplication.Voice; + opusSignal = OpusSignal.Voice; + break; + default: + throw new ArgumentOutOfRangeException(nameof(application)); + } + + _ptr = CreateEncoder(SamplingRate, Channels, (int)opusApplication, out var error); + CheckError(error); + CheckError(EncoderCtl(_ptr, OpusCtl.SetSignal, (int)opusSignal)); + CheckError(EncoderCtl(_ptr, OpusCtl.SetPacketLossPercent, packetLoss)); //% + CheckError(EncoderCtl(_ptr, OpusCtl.SetBitrate, bitrate)); + } + + public unsafe int EncodeFrame(byte[] input, int inputOffset, byte[] output, int outputOffset) + { + int result = 0; + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + result = Encode(_ptr, inPtr + inputOffset, FrameSamplesPerChannel, outPtr + outputOffset, output.Length - outputOffset); + CheckError(result); + return result; + } + + protected override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (_ptr != IntPtr.Zero) + DestroyEncoder(_ptr); + base.Dispose(disposing); + } + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Opus/OpusError.cs b/src/Discord.Net.WebSocket/Audio/Opus/OpusError.cs new file mode 100644 index 0000000..20ff26d --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Opus/OpusError.cs @@ -0,0 +1,14 @@ +namespace Discord.Audio +{ + internal enum OpusError : int + { + OK = 0, + BadArg = -1, + BufferToSmall = -2, + InternalError = -3, + InvalidPacket = -4, + Unimplemented = -5, + InvalidState = -6, + AllocFail = -7 + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Opus/OpusSignal.cs b/src/Discord.Net.WebSocket/Audio/Opus/OpusSignal.cs new file mode 100644 index 0000000..3f95183 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Opus/OpusSignal.cs @@ -0,0 +1,9 @@ +namespace Discord.Audio +{ + internal enum OpusSignal : int + { + Auto = -1000, + Voice = 3001, + Music = 3002, + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Sodium/SecretBox.cs b/src/Discord.Net.WebSocket/Audio/Sodium/SecretBox.cs new file mode 100644 index 0000000..5ace823 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Sodium/SecretBox.cs @@ -0,0 +1,36 @@ +using System; +using System.Runtime.InteropServices; + +namespace Discord.Audio +{ + public unsafe static class SecretBox + { + [DllImport("libsodium", EntryPoint = "crypto_secretbox_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxEasy(byte* output, byte* input, long inputLength, byte[] nonce, byte[] secret); + [DllImport("libsodium", EntryPoint = "crypto_secretbox_open_easy", CallingConvention = CallingConvention.Cdecl)] + private static extern int SecretBoxOpenEasy(byte* output, byte* input, long inputLength, byte[] nonce, byte[] secret); + + public static int Encrypt(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, byte[] nonce, byte[] secret) + { + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + { + int error = SecretBoxEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength, nonce, secret); + if (error != 0) + throw new Exception($"Sodium Error: {error}"); + return inputLength + 16; + } + } + public static int Decrypt(byte[] input, int inputOffset, int inputLength, byte[] output, int outputOffset, byte[] nonce, byte[] secret) + { + fixed (byte* inPtr = input) + fixed (byte* outPtr = output) + { + int error = SecretBoxOpenEasy(outPtr + outputOffset, inPtr + inputOffset, inputLength, nonce, secret); + if (error != 0) + throw new Exception($"Sodium Error: {error}"); + return inputLength - 16; + } + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/BufferedWriteStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/BufferedWriteStream.cs new file mode 100644 index 0000000..25afde7 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/BufferedWriteStream.cs @@ -0,0 +1,193 @@ +using Discord.Logging; +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Wraps another stream with a timed buffer. + public class BufferedWriteStream : AudioOutStream + { + private const int MaxSilenceFrames = 10; + + private struct Frame + { + public Frame(byte[] buffer, int bytes) + { + Buffer = buffer; + Bytes = bytes; + } + + public readonly byte[] Buffer; + public readonly int Bytes; + } + + private static readonly byte[] _silenceFrame = new byte[0]; + + private readonly AudioClient _client; + private readonly AudioStream _next; + private readonly CancellationTokenSource _disposeTokenSource, _cancelTokenSource; + private readonly CancellationToken _cancelToken; + private readonly Task _task; + private readonly ConcurrentQueue _queuedFrames; + private readonly ConcurrentQueue _bufferPool; + private readonly SemaphoreSlim _queueLock; + private readonly Logger _logger; + private readonly int _ticksPerFrame, _queueLength; + private bool _isPreloaded; + private int _silenceFrames; + + public BufferedWriteStream(AudioStream next, IAudioClient client, int bufferMillis, CancellationToken cancelToken, int maxFrameSize = 1500) + : this(next, client as AudioClient, bufferMillis, cancelToken, null, maxFrameSize) { } + internal BufferedWriteStream(AudioStream next, AudioClient client, int bufferMillis, CancellationToken cancelToken, Logger logger, int maxFrameSize = 1500) + { + //maxFrameSize = 1275 was too limiting at 128kbps,2ch,60ms + _next = next; + _client = client; + _ticksPerFrame = OpusEncoder.FrameMillis; + _logger = logger; + _queueLength = (bufferMillis + (_ticksPerFrame - 1)) / _ticksPerFrame; //Round up + + _disposeTokenSource = new CancellationTokenSource(); + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_disposeTokenSource.Token, cancelToken); + _cancelToken = _cancelTokenSource.Token; + _queuedFrames = new ConcurrentQueue(); + _bufferPool = new ConcurrentQueue(); + for (int i = 0; i < _queueLength; i++) + _bufferPool.Enqueue(new byte[maxFrameSize]); + _queueLock = new SemaphoreSlim(_queueLength, _queueLength); + _silenceFrames = MaxSilenceFrames; + + _task = Run(); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _disposeTokenSource?.Cancel(); + _disposeTokenSource?.Dispose(); + _cancelTokenSource?.Cancel(); + _cancelTokenSource?.Dispose(); + _queueLock?.Dispose(); + _next.Dispose(); + } + base.Dispose(disposing); + } + + private Task Run() + { + return Task.Run(async () => + { + try + { + while (!_isPreloaded && !_cancelToken.IsCancellationRequested) + await Task.Delay(1).ConfigureAwait(false); + + long nextTick = Environment.TickCount; + ushort seq = 0; + uint timestamp = 0; + while (!_cancelToken.IsCancellationRequested) + { + long tick = Environment.TickCount; + long dist = nextTick - tick; + if (dist <= 0) + { + if (_queuedFrames.TryDequeue(out Frame frame)) + { + await _client.SetSpeakingAsync(true).ConfigureAwait(false); + _next.WriteHeader(seq, timestamp, false); + await _next.WriteAsync(frame.Buffer, 0, frame.Bytes).ConfigureAwait(false); + _bufferPool.Enqueue(frame.Buffer); + _queueLock.Release(); + nextTick += _ticksPerFrame; + seq++; + timestamp += OpusEncoder.FrameSamplesPerChannel; + _silenceFrames = 0; +#if DEBUG + var _ = _logger?.DebugAsync($"Sent {frame.Bytes} bytes ({_queuedFrames.Count} frames buffered)"); +#endif + } + else + { + while ((nextTick - tick) <= 0) + { + if (_silenceFrames++ < MaxSilenceFrames) + { + _next.WriteHeader(seq, timestamp, false); + await _next.WriteAsync(_silenceFrame, 0, _silenceFrame.Length).ConfigureAwait(false); + } + else + await _client.SetSpeakingAsync(false).ConfigureAwait(false); + nextTick += _ticksPerFrame; + seq++; + timestamp += OpusEncoder.FrameSamplesPerChannel; + } +#if DEBUG + var _ = _logger?.DebugAsync("Buffer under run"); +#endif + } + } + else + await Task.Delay((int)(dist)/*, _cancelToken*/).ConfigureAwait(false); + } + } + catch (OperationCanceledException) { } + }); + } + + public override void WriteHeader(ushort seq, uint timestamp, bool missed) { } //Ignore, we use our own timing + public override async Task WriteAsync(byte[] data, int offset, int count, CancellationToken cancelToken) + { + CancellationTokenSource writeCancelToken = null; + if (cancelToken.CanBeCanceled) + { + writeCancelToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, _cancelToken); + cancelToken = writeCancelToken.Token; + } + else + cancelToken = _cancelToken; + + await _queueLock.WaitAsync(-1, cancelToken).ConfigureAwait(false); + if (!_bufferPool.TryDequeue(out byte[] buffer)) + { +#if DEBUG + var _ = _logger?.DebugAsync("Buffer overflow"); //Should never happen because of the queueLock +#endif +#pragma warning disable IDISP016 + writeCancelToken?.Dispose(); +#pragma warning restore IDISP016 + return; + } + Buffer.BlockCopy(data, offset, buffer, 0, count); + _queuedFrames.Enqueue(new Frame(buffer, count)); + if (!_isPreloaded && _queuedFrames.Count == _queueLength) + { +#if DEBUG + var _ = _logger?.DebugAsync("Preloaded"); +#endif + _isPreloaded = true; + } + writeCancelToken?.Dispose(); + } + + public override async Task FlushAsync(CancellationToken cancelToken) + { + while (true) + { + cancelToken.ThrowIfCancellationRequested(); + if (_queuedFrames.Count == 0) + return; + await Task.Delay(250, cancelToken).ConfigureAwait(false); + } + } + public override Task ClearAsync(CancellationToken cancelToken) + { + do + cancelToken.ThrowIfCancellationRequested(); + while (_queuedFrames.TryDequeue(out _)); + return Task.Delay(0); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/InputStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/InputStream.cs new file mode 100644 index 0000000..6233c47 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/InputStream.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Reads the payload from an RTP frame + public class InputStream : AudioInStream + { + private const int MaxFrames = 100; //1-2 Seconds + + private ConcurrentQueue _frames; + private SemaphoreSlim _signal; + private ushort _nextSeq; + private uint _nextTimestamp; + private bool _nextMissed; + private bool _hasHeader; + private bool _isDisposed; + + public override bool CanRead => !_isDisposed; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override int AvailableFrames => _signal.CurrentCount; + + public InputStream() + { + _frames = new ConcurrentQueue(); + _signal = new SemaphoreSlim(0, MaxFrames); + } + + public override bool TryReadFrame(CancellationToken cancelToken, out RTPFrame frame) + { + cancelToken.ThrowIfCancellationRequested(); + + if (_signal.Wait(0)) + { + _frames.TryDequeue(out frame); + return true; + } + frame = default(RTPFrame); + return false; + } + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + + var frame = await ReadFrameAsync(cancelToken).ConfigureAwait(false); + if (count < frame.Payload.Length) + throw new InvalidOperationException("Buffer is too small."); + Buffer.BlockCopy(frame.Payload, 0, buffer, offset, frame.Payload.Length); + return frame.Payload.Length; + } + public override async Task ReadFrameAsync(CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + + await _signal.WaitAsync(cancelToken).ConfigureAwait(false); + _frames.TryDequeue(out RTPFrame frame); + return frame; + } + + public override void WriteHeader(ushort seq, uint timestamp, bool missed) + { + if (_hasHeader) + throw new InvalidOperationException("Header received with no payload"); + _hasHeader = true; + _nextSeq = seq; + _nextTimestamp = timestamp; + _nextMissed = missed; + } + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + + if (_signal.CurrentCount >= MaxFrames) //1-2 seconds + { + _hasHeader = false; + return Task.Delay(0); //Buffer overloaded + } + if (!_hasHeader) + throw new InvalidOperationException("Received payload without an RTP header"); + _hasHeader = false; + byte[] payload = new byte[count]; + Buffer.BlockCopy(buffer, offset, payload, 0, count); + + _frames.Enqueue(new RTPFrame( + sequence: _nextSeq, + timestamp: _nextTimestamp, + missed: _nextMissed, + payload: payload + )); + _signal.Release(); + return Task.Delay(0); + } + + protected override void Dispose(bool isDisposing) + { + if (!_isDisposed) + { + if (isDisposing) + { + _signal?.Dispose(); + } + + _isDisposed = true; + } + + base.Dispose(isDisposing); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/JitterBuffer.cs b/src/Discord.Net.WebSocket/Audio/Streams/JitterBuffer.cs new file mode 100644 index 0000000..5d7bb09 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/JitterBuffer.cs @@ -0,0 +1,246 @@ +/*using Discord.Logging; +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Wraps another stream with a timed buffer and packet loss detection. + public class JitterBuffer : AudioOutStream + { + private struct Frame + { + public Frame(byte[] buffer, int bytes, ushort sequence, uint timestamp) + { + Buffer = buffer; + Bytes = bytes; + Sequence = sequence; + Timestamp = timestamp; + } + + public readonly byte[] Buffer; + public readonly int Bytes; + public readonly ushort Sequence; + public readonly uint Timestamp; + } + + private static readonly byte[] _silenceFrame = new byte[0]; + + private readonly AudioStream _next; + private readonly CancellationTokenSource _cancelTokenSource; + private readonly CancellationToken _cancelToken; + private readonly Task _task; + private readonly ConcurrentQueue _queuedFrames; + private readonly ConcurrentQueue _bufferPool; + private readonly SemaphoreSlim _queueLock; + private readonly Logger _logger; + private readonly int _ticksPerFrame, _queueLength; + private bool _isPreloaded, _hasHeader; + + private ushort _seq, _nextSeq; + private uint _timestamp, _nextTimestamp; + private bool _isFirst; + + public JitterBuffer(AudioStream next, int bufferMillis = 60, int maxFrameSize = 1500) + : this(next, null, bufferMillis, maxFrameSize) { } + internal JitterBuffer(AudioStream next, Logger logger, int bufferMillis = 60, int maxFrameSize = 1500) + { + //maxFrameSize = 1275 was too limiting at 128kbps,2ch,60ms + _next = next; + _ticksPerFrame = OpusEncoder.FrameMillis; + _logger = logger; + _queueLength = (bufferMillis + (_ticksPerFrame - 1)) / _ticksPerFrame; //Round up + + _cancelTokenSource = new CancellationTokenSource(); + _cancelToken = _cancelTokenSource.Token; + _queuedFrames = new ConcurrentQueue(); + _bufferPool = new ConcurrentQueue(); + for (int i = 0; i < _queueLength; i++) + _bufferPool.Enqueue(new byte[maxFrameSize]); + _queueLock = new SemaphoreSlim(_queueLength, _queueLength); + + _isFirst = true; + _task = Run(); + } + protected override void Dispose(bool disposing) + { + if (disposing) + _cancelTokenSource.Cancel(); + base.Dispose(disposing); + } + + private Task Run() + { + return Task.Run(async () => + { + try + { + long nextTick = Environment.TickCount; + int silenceFrames = 0; + while (!_cancelToken.IsCancellationRequested) + { + long tick = Environment.TickCount; + long dist = nextTick - tick; + if (dist > 0) + { + await Task.Delay((int)dist).ConfigureAwait(false); + continue; + } + nextTick += _ticksPerFrame; + if (!_isPreloaded) + { + await Task.Delay(_ticksPerFrame).ConfigureAwait(false); + continue; + } + + if (_queuedFrames.TryPeek(out Frame frame)) + { + silenceFrames = 0; + uint distance = (uint)(frame.Timestamp - _timestamp); + bool restartSeq = _isFirst; + if (!_isFirst) + { + if (distance > uint.MaxValue - (OpusEncoder.FrameSamplesPerChannel * 50 * 5)) //Negative distances wraps + { + _queuedFrames.TryDequeue(out frame); + _bufferPool.Enqueue(frame.Buffer); + _queueLock.Release(); +#if DEBUG + var _ = _logger?.DebugAsync($"Dropped frame {_timestamp} ({_queuedFrames.Count} frames buffered)"); +#endif + continue; //This is a missed packet less than five seconds old, ignore it + } + } + + if (distance == 0 || restartSeq) + { + //This is the frame we expected + _seq = frame.Sequence; + _timestamp = frame.Timestamp; + _isFirst = false; + silenceFrames = 0; + + _next.WriteHeader(_seq++, _timestamp, false); + await _next.WriteAsync(frame.Buffer, 0, frame.Bytes).ConfigureAwait(false); + _queuedFrames.TryDequeue(out frame); + _bufferPool.Enqueue(frame.Buffer); + _queueLock.Release(); +#if DEBUG + var _ = _logger?.DebugAsync($"Read frame {_timestamp} ({_queuedFrames.Count} frames buffered)"); +#endif + } + else if (distance == OpusEncoder.FrameSamplesPerChannel) + { + //Missed this frame, but the next queued one might have FEC info + _next.WriteHeader(_seq++, _timestamp, true); + await _next.WriteAsync(frame.Buffer, 0, frame.Bytes).ConfigureAwait(false); +#if DEBUG + var _ = _logger?.DebugAsync($"Recreated Frame {_timestamp} (Next is {frame.Timestamp}) ({_queuedFrames.Count} frames buffered)"); +#endif + } + else + { + //Missed this frame and we have no FEC data to work with + _next.WriteHeader(_seq++, _timestamp, true); + await _next.WriteAsync(null, 0, 0).ConfigureAwait(false); +#if DEBUG + var _ = _logger?.DebugAsync($"Missed Frame {_timestamp} (Next is {frame.Timestamp}) ({_queuedFrames.Count} frames buffered)"); +#endif + } + } + else if (!_isFirst) + { + //Missed this frame and we have no FEC data to work with + _next.WriteHeader(_seq++, _timestamp, true); + await _next.WriteAsync(null, 0, 0).ConfigureAwait(false); + if (silenceFrames < 5) + silenceFrames++; + else + { + _isFirst = true; + _isPreloaded = false; + } +#if DEBUG + var _ = _logger?.DebugAsync($"Missed Frame {_timestamp} ({_queuedFrames.Count} frames buffered)"); +#endif + } + _timestamp += OpusEncoder.FrameSamplesPerChannel; + } + } + catch (OperationCanceledException) { } + }); + } + + public override void WriteHeader(ushort seq, uint timestamp, bool missed) + { + if (_hasHeader) + throw new InvalidOperationException("Header received with no payload"); + _nextSeq = seq; + _nextTimestamp = timestamp; + _hasHeader = true; + } + public override async Task WriteAsync(byte[] data, int offset, int count, CancellationToken cancelToken) + { + if (cancelToken.CanBeCanceled) + cancelToken = CancellationTokenSource.CreateLinkedTokenSource(cancelToken, _cancelToken).Token; + else + cancelToken = _cancelToken; + + if (!_hasHeader) + throw new InvalidOperationException("Received payload without an RTP header"); + _hasHeader = false; + + uint distance = (uint)(_nextTimestamp - _timestamp); + if (!_isFirst && (distance == 0 || distance > OpusEncoder.FrameSamplesPerChannel * 50 * 5)) //Negative distances wraps + { +#if DEBUG + var _ = _logger?.DebugAsync($"Frame {_nextTimestamp} was {distance} samples off. Ignoring."); +#endif + return; //This is an old frame, ignore + } + + if (!await _queueLock.WaitAsync(0).ConfigureAwait(false)) + { +#if DEBUG + var _ = _logger?.DebugAsync($"Buffer overflow"); +#endif + return; + } + _bufferPool.TryDequeue(out byte[] buffer); + + Buffer.BlockCopy(data, offset, buffer, 0, count); +#if DEBUG + { + var _ = _logger?.DebugAsync($"Queued Frame {_nextTimestamp}."); + } +#endif + _queuedFrames.Enqueue(new Frame(buffer, count, _nextSeq, _nextTimestamp)); + if (!_isPreloaded && _queuedFrames.Count >= _queueLength) + { +#if DEBUG + var _ = _logger?.DebugAsync($"Preloaded"); +#endif + _isPreloaded = true; + } + } + + public override async Task FlushAsync(CancellationToken cancelToken) + { + while (true) + { + cancelToken.ThrowIfCancellationRequested(); + if (_queuedFrames.Count == 0) + return; + await Task.Delay(250, cancelToken).ConfigureAwait(false); + } + } + public override Task ClearAsync(CancellationToken cancelToken) + { + do + cancelToken.ThrowIfCancellationRequested(); + while (_queuedFrames.TryDequeue(out Frame ignored)); + return Task.Delay(0); + } + } +}*/ diff --git a/src/Discord.Net.WebSocket/Audio/Streams/OpusDecodeStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/OpusDecodeStream.cs new file mode 100644 index 0000000..e900c10 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/OpusDecodeStream.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Converts Opus to PCM + public class OpusDecodeStream : AudioOutStream + { + public const int SampleRate = OpusEncodeStream.SampleRate; + + private readonly AudioStream _next; + private readonly OpusDecoder _decoder; + private readonly byte[] _buffer; + private bool _nextMissed; + private bool _hasHeader; + + public OpusDecodeStream(AudioStream next) + { + _next = next; + _buffer = new byte[OpusConverter.FrameBytes]; + _decoder = new OpusDecoder(); + } + + /// Header received with no payload. + public override void WriteHeader(ushort seq, uint timestamp, bool missed) + { + if (_hasHeader) + throw new InvalidOperationException("Header received with no payload."); + _hasHeader = true; + + _nextMissed = missed; + _next.WriteHeader(seq, timestamp, missed); + } + + /// Received payload without an RTP header. + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + if (!_hasHeader) + throw new InvalidOperationException("Received payload without an RTP header."); + _hasHeader = false; + + count = !_nextMissed || count > 0 + ? _decoder.DecodeFrame(buffer, offset, count, _buffer, 0, false) + : _decoder.DecodeFrame(null, 0, 0, _buffer, 0, false); + + return _next.WriteAsync(_buffer, 0, count, cancelToken); + } + + public override Task FlushAsync(CancellationToken cancelToken) + => _next.FlushAsync(cancelToken); + + public override Task ClearAsync(CancellationToken cancelToken) + => _next.ClearAsync(cancelToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _decoder.Dispose(); + _next.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/OpusEncodeStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/OpusEncodeStream.cs new file mode 100644 index 0000000..259ece6 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/OpusEncodeStream.cs @@ -0,0 +1,122 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Converts PCM to Opus + public class OpusEncodeStream : AudioOutStream + { + public const int SampleRate = 48000; + + private readonly AudioStream _next; + private readonly OpusEncoder _encoder; + private readonly byte[] _buffer; + private int _partialFramePos; + private ushort _seq; + private uint _timestamp; + + public OpusEncodeStream(AudioStream next, int bitrate, AudioApplication application, int packetLoss) + { + _next = next; + _encoder = new OpusEncoder(bitrate, application, packetLoss); + _buffer = new byte[OpusConverter.FrameBytes]; + } + + /// + /// Sends silent frames to avoid interpolation errors after breaks in data transmission. + /// + /// A task representing the asynchronous operation of sending a silent frame. + public async Task WriteSilentFramesAsync() + { + // https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation + + byte[] frameBytes = new byte[OpusConverter.FrameBytes]; + + // Magic silence numbers. + frameBytes[0] = 0xF8; + frameBytes[1] = 0xFF; + frameBytes[2] = 0xFE; + + // The rest of the array is already zeroes, so no need to fill the rest. + + const int frameCount = 5; + for (int i = 0; i < frameCount; i += 1) + { + await WriteAsync(frameBytes, 0, frameBytes.Length).ConfigureAwait(false); + } + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + //Assume thread-safe + while (count > 0) + { + if (_partialFramePos == 0 && count >= OpusConverter.FrameBytes) + { + //We have enough data and no partial frames. Pass the buffer directly to the encoder + int encFrameSize = _encoder.EncodeFrame(buffer, offset, _buffer, 0); + _next.WriteHeader(_seq, _timestamp, false); + await _next.WriteAsync(_buffer, 0, encFrameSize, cancelToken).ConfigureAwait(false); + + offset += OpusConverter.FrameBytes; + count -= OpusConverter.FrameBytes; + _seq++; + _timestamp += OpusConverter.FrameSamplesPerChannel; + } + else if (_partialFramePos + count >= OpusConverter.FrameBytes) + { + //We have enough data to complete a previous partial frame. + int partialSize = OpusConverter.FrameBytes - _partialFramePos; + Buffer.BlockCopy(buffer, offset, _buffer, _partialFramePos, partialSize); + int encFrameSize = _encoder.EncodeFrame(_buffer, 0, _buffer, 0); + _next.WriteHeader(_seq, _timestamp, false); + await _next.WriteAsync(_buffer, 0, encFrameSize, cancelToken).ConfigureAwait(false); + + offset += partialSize; + count -= partialSize; + _partialFramePos = 0; + _seq++; + _timestamp += OpusConverter.FrameSamplesPerChannel; + } + else + { + //Not enough data to build a complete frame, store this part for later + Buffer.BlockCopy(buffer, offset, _buffer, _partialFramePos, count); + _partialFramePos += count; + break; + } + } + } + + /* //Opus throws memory errors on bad frames + public override async Task FlushAsync(CancellationToken cancelToken) + { + try + { + int encFrameSize = _encoder.EncodeFrame(_partialFrameBuffer, 0, _partialFramePos, _buffer, 0); + base.Write(_buffer, 0, encFrameSize); + } + catch (Exception) { } //Incomplete frame + _partialFramePos = 0; + await base.FlushAsync(cancelToken).ConfigureAwait(false); + }*/ + + public override Task FlushAsync(CancellationToken cancelToken) + => _next.FlushAsync(cancelToken); + + public override Task ClearAsync(CancellationToken cancelToken) + => _next.ClearAsync(cancelToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _encoder.Dispose(); + _next.Dispose(); + } + base.Dispose(disposing); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/OutputStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/OutputStream.cs new file mode 100644 index 0000000..91fe631 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/OutputStream.cs @@ -0,0 +1,24 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Wraps an IAudioClient, sending voice data on write. + public class OutputStream : AudioOutStream + { + private readonly DiscordVoiceAPIClient _client; + public OutputStream(IAudioClient client) + : this((client as AudioClient).ApiClient) { } + internal OutputStream(DiscordVoiceAPIClient client) + { + _client = client; + } + + public override void WriteHeader(ushort seq, uint timestamp, bool missed) { } //Ignore + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + return _client.SendAsync(buffer, offset, count); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/RTPReadStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/RTPReadStream.cs new file mode 100644 index 0000000..2331746 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/RTPReadStream.cs @@ -0,0 +1,87 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Reads the payload from an RTP frame + public class RTPReadStream : AudioOutStream + { + private readonly AudioStream _next; + private readonly byte[] _buffer, _nonce; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + + public RTPReadStream(AudioStream next, int bufferSize = 4000) + { + _next = next; + _buffer = new byte[bufferSize]; + _nonce = new byte[24]; + } + + /// The token has had cancellation requested. + /// The associated has been disposed. + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + + int headerSize = GetHeaderSize(buffer, offset); + + ushort seq = (ushort)((buffer[offset + 2] << 8) | + (buffer[offset + 3] << 0)); + + uint timestamp = (uint)((buffer[offset + 4] << 24) | + (buffer[offset + 5] << 16) | + (buffer[offset + 6] << 8) | + (buffer[offset + 7] << 0)); + + _next.WriteHeader(seq, timestamp, false); + return _next.WriteAsync(buffer, offset + headerSize, count - headerSize, cancelToken); + } + + public static bool TryReadSsrc(byte[] buffer, int offset, out uint ssrc) + { + ssrc = 0; + if (buffer.Length - offset < 12) + return false; + + int version = (buffer[offset + 0] & 0b1100_0000) >> 6; + if (version != 2) + return false; + int type = (buffer[offset + 1] & 0b01111_1111); + if (type != 120) //Dynamic Discord type + return false; + + ssrc = (uint)((buffer[offset + 8] << 24) | + (buffer[offset + 9] << 16) | + (buffer[offset + 10] << 8) | + (buffer[offset + 11] << 0)); + return true; + } + + public static int GetHeaderSize(byte[] buffer, int offset) + { + byte headerByte = buffer[offset]; + bool extension = (headerByte & 0b0001_0000) != 0; + int csics = (headerByte & 0b0000_1111) >> 4; + + if (!extension) + return 12 + csics * 4; + + int extensionOffset = offset + 12 + (csics * 4); + int extensionLength = + (buffer[extensionOffset + 2] << 8) | + (buffer[extensionOffset + 3]); + return extensionOffset + 4 + (extensionLength * 4); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _next.Dispose(); + base.Dispose(disposing); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/RTPWriteStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/RTPWriteStream.cs new file mode 100644 index 0000000..6bf4e73 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/RTPWriteStream.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// Wraps data in an RTP frame + public class RTPWriteStream : AudioOutStream + { + private readonly AudioStream _next; + private readonly byte[] _header; + protected readonly byte[] _buffer; + private uint _ssrc; + private ushort _nextSeq; + private uint _nextTimestamp; + private bool _hasHeader; + + public RTPWriteStream(AudioStream next, uint ssrc, int bufferSize = 4000) + { + _next = next; + _ssrc = ssrc; + _buffer = new byte[bufferSize]; + _header = new byte[24]; + _header[0] = 0x80; + _header[1] = 0x78; + _header[8] = (byte)(_ssrc >> 24); + _header[9] = (byte)(_ssrc >> 16); + _header[10] = (byte)(_ssrc >> 8); + _header[11] = (byte)(_ssrc >> 0); + } + + public override void WriteHeader(ushort seq, uint timestamp, bool missed) + { + if (_hasHeader) + throw new InvalidOperationException("Header received with no payload"); + + _hasHeader = true; + _nextSeq = seq; + _nextTimestamp = timestamp; + } + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + if (!_hasHeader) + throw new InvalidOperationException("Received payload without an RTP header"); + _hasHeader = false; + + unchecked + { + _header[2] = (byte)(_nextSeq >> 8); + _header[3] = (byte)(_nextSeq >> 0); + _header[4] = (byte)(_nextTimestamp >> 24); + _header[5] = (byte)(_nextTimestamp >> 16); + _header[6] = (byte)(_nextTimestamp >> 8); + _header[7] = (byte)(_nextTimestamp >> 0); + } + Buffer.BlockCopy(_header, 0, _buffer, 0, 12); //Copy RTP header from to the buffer + Buffer.BlockCopy(buffer, offset, _buffer, 12, count); + + _next.WriteHeader(_nextSeq, _nextTimestamp, false); + return _next.WriteAsync(_buffer, 0, count + 12); + } + + public override Task FlushAsync(CancellationToken cancelToken) + => _next.FlushAsync(cancelToken); + + public override Task ClearAsync(CancellationToken cancelToken) + => _next.ClearAsync(cancelToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + _next.Dispose(); + base.Dispose(disposing); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/SodiumDecryptStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/SodiumDecryptStream.cs new file mode 100644 index 0000000..f343f0c --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/SodiumDecryptStream.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// + /// Decrypts an RTP frame using libsodium. + /// + public class SodiumDecryptStream : AudioOutStream + { + private readonly AudioClient _client; + private readonly AudioStream _next; + private readonly byte[] _nonce; + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + + public SodiumDecryptStream(AudioStream next, IAudioClient client) + { + _next = next; + _client = (AudioClient)client; + _nonce = new byte[24]; + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + + if (_client.SecretKey == null) + return Task.CompletedTask; + + Buffer.BlockCopy(buffer, 0, _nonce, 0, 12); //Copy RTP header to nonce + count = SecretBox.Decrypt(buffer, offset + 12, count - 12, buffer, offset + 12, _nonce, _client.SecretKey); + return _next.WriteAsync(buffer, 0, count + 12, cancelToken); + } + + public override Task FlushAsync(CancellationToken cancelToken) + => _next.FlushAsync(cancelToken); + + public override Task ClearAsync(CancellationToken cancelToken) + => _next.ClearAsync(cancelToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + _next.Dispose(); + base.Dispose(disposing); + } + } +} diff --git a/src/Discord.Net.WebSocket/Audio/Streams/SodiumEncryptStream.cs b/src/Discord.Net.WebSocket/Audio/Streams/SodiumEncryptStream.cs new file mode 100644 index 0000000..30799e8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Audio/Streams/SodiumEncryptStream.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio.Streams +{ + /// + /// Encrypts an RTP frame using libsodium. + /// + public class SodiumEncryptStream : AudioOutStream + { + private readonly AudioClient _client; + private readonly AudioStream _next; + private readonly byte[] _nonce; + private bool _hasHeader; + private ushort _nextSeq; + private uint _nextTimestamp; + + public SodiumEncryptStream(AudioStream next, IAudioClient client) + { + _next = next; + _client = (AudioClient)client; + _nonce = new byte[24]; + } + + /// Header received with no payload. + public override void WriteHeader(ushort seq, uint timestamp, bool missed) + { + if (_hasHeader) + throw new InvalidOperationException("Header received with no payload."); + + _nextSeq = seq; + _nextTimestamp = timestamp; + _hasHeader = true; + } + /// Received payload without an RTP header. + /// The token has had cancellation requested. + /// The associated has been disposed. + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancelToken) + { + cancelToken.ThrowIfCancellationRequested(); + if (!_hasHeader) + throw new InvalidOperationException("Received payload without an RTP header."); + _hasHeader = false; + + if (_client.SecretKey == null) + return; + + Buffer.BlockCopy(buffer, offset, _nonce, 0, 12); //Copy nonce from RTP header + count = SecretBox.Encrypt(buffer, offset + 12, count - 12, buffer, 12, _nonce, _client.SecretKey); + _next.WriteHeader(_nextSeq, _nextTimestamp, false); + await _next.WriteAsync(buffer, 0, count + 12, cancelToken).ConfigureAwait(false); + } + + public override Task FlushAsync(CancellationToken cancelToken) + => _next.FlushAsync(cancelToken); + + public override Task ClearAsync(CancellationToken cancelToken) + => _next.ClearAsync(cancelToken); + + protected override void Dispose(bool disposing) + { + if (disposing) + _next.Dispose(); + base.Dispose(disposing); + } + } +} diff --git a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs new file mode 100644 index 0000000..2a7f7ae --- /dev/null +++ b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs @@ -0,0 +1,1012 @@ +using Discord.Rest; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + public partial class BaseSocketClient + { + #region Channels + /// Fired when a channel is created. + /// + /// + /// This event is fired when a generic channel has been created. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The newly created channel is passed into the event handler parameter. The given channel type may + /// include, but not limited to, Private Channels (DM, Group), Guild Channels (Text, Voice, Category); + /// see the derived classes of for more details. + /// + /// + /// + /// + /// + public event Func ChannelCreated + { + add { _channelCreatedEvent.Add(value); } + remove { _channelCreatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _channelCreatedEvent = new AsyncEvent>(); + /// Fired when a channel is destroyed. + /// + /// + /// This event is fired when a generic channel has been destroyed. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The destroyed channel is passed into the event handler parameter. The given channel type may + /// include, but not limited to, Private Channels (DM, Group), Guild Channels (Text, Voice, Category); + /// see the derived classes of for more details. + /// + /// + /// + /// + /// + public event Func ChannelDestroyed + { + add { _channelDestroyedEvent.Add(value); } + remove { _channelDestroyedEvent.Remove(value); } + } + internal readonly AsyncEvent> _channelDestroyedEvent = new AsyncEvent>(); + /// Fired when a channel is updated. + /// + /// + /// This event is fired when a generic channel has been updated. The event handler must return a + /// and accept 2 as its parameters. + /// + /// + /// The original (prior to update) channel is passed into the first , while + /// the updated channel is passed into the second. The given channel type may include, but not limited + /// to, Private Channels (DM, Group), Guild Channels (Text, Voice, Category); see the derived classes of + /// for more details. + /// + /// + /// + /// + /// + public event Func ChannelUpdated + { + add { _channelUpdatedEvent.Add(value); } + remove { _channelUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _channelUpdatedEvent = new AsyncEvent>(); + + /// + /// Fired when status of a voice channel is updated. + /// + /// + /// The previous state of the channel is passed into the first parameter; the updated channel is passed into the second one. + /// + public event Func, string, string, Task> VoiceChannelStatusUpdated + { + add { _voiceChannelStatusUpdated.Add(value); } + remove { _voiceChannelStatusUpdated.Remove(value); } + } + + internal readonly AsyncEvent, string, string, Task>> _voiceChannelStatusUpdated = new(); + + + #endregion + + #region Messages + /// Fired when a message is received. + /// + /// + /// This event is fired when a message is received. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The message that is sent to the client is passed into the event handler parameter as + /// . This message may be a system message (i.e. + /// ) or a user message (i.e. . See the + /// derived classes of for more details. + /// + /// + /// + /// The example below checks if the newly received message contains the target user. + /// + /// + public event Func MessageReceived + { + add { _messageReceivedEvent.Add(value); } + remove { _messageReceivedEvent.Remove(value); } + } + internal readonly AsyncEvent> _messageReceivedEvent = new AsyncEvent>(); + /// Fired when a message is deleted. + /// + /// + /// This event is fired when a message is deleted. The event handler must return a + /// and accept a and + /// as its parameters. + /// + /// + /// + /// It is not possible to retrieve the message via + /// ; the message cannot be retrieved by Discord + /// after the message has been deleted. + /// + /// If caching is enabled via , the + /// entity will contain the deleted message; otherwise, in event + /// that the message cannot be retrieved, the snowflake ID of the message is preserved in the + /// . + /// + /// + /// The source channel of the removed message will be passed into the + /// parameter. + /// + /// + /// + /// + /// + + public event Func, Cacheable, Task> MessageDeleted + { + add { _messageDeletedEvent.Add(value); } + remove { _messageDeletedEvent.Remove(value); } + } + internal readonly AsyncEvent, Cacheable, Task>> _messageDeletedEvent = new AsyncEvent, Cacheable, Task>>(); + /// Fired when multiple messages are bulk deleted. + /// + /// + /// The event will not be fired for individual messages contained in this event. + /// + /// + /// This event is fired when multiple messages are bulk deleted. The event handler must return a + /// and accept an and + /// as its parameters. + /// + /// + /// + /// It is not possible to retrieve the message via + /// ; the message cannot be retrieved by Discord + /// after the message has been deleted. + /// + /// If caching is enabled via , the + /// entity will contain the deleted message; otherwise, in event + /// that the message cannot be retrieved, the snowflake ID of the message is preserved in the + /// . + /// + /// + /// The source channel of the removed message will be passed into the + /// parameter. + /// + /// + public event Func>, Cacheable, Task> MessagesBulkDeleted + { + add { _messagesBulkDeletedEvent.Add(value); } + remove { _messagesBulkDeletedEvent.Remove(value); } + } + internal readonly AsyncEvent>, Cacheable, Task>> _messagesBulkDeletedEvent = new AsyncEvent>, Cacheable, Task>>(); + /// Fired when a message is updated. + /// + /// + /// This event is fired when a message is updated. The event handler must return a + /// and accept a , , + /// and as its parameters. + /// + /// + /// If caching is enabled via , the + /// entity will contain the original message; otherwise, in event + /// that the message cannot be retrieved, the snowflake ID of the message is preserved in the + /// . + /// + /// + /// The updated message will be passed into the parameter. + /// + /// + /// The source channel of the updated message will be passed into the + /// parameter. + /// + /// + public event Func, SocketMessage, ISocketMessageChannel, Task> MessageUpdated + { + add { _messageUpdatedEvent.Add(value); } + remove { _messageUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent, SocketMessage, ISocketMessageChannel, Task>> _messageUpdatedEvent = new AsyncEvent, SocketMessage, ISocketMessageChannel, Task>>(); + /// Fired when a reaction is added to a message. + /// + /// + /// This event is fired when a reaction is added to a user message. The event handler must return a + /// and accept a , an + /// , and a as its parameter. + /// + /// + /// If caching is enabled via , the + /// entity will contain the original message; otherwise, in event + /// that the message cannot be retrieved, the snowflake ID of the message is preserved in the + /// . + /// + /// + /// The source channel of the reaction addition will be passed into the + /// parameter. + /// + /// + /// The reaction that was added will be passed into the parameter. + /// + /// + /// When fetching the reaction from this event, a user may not be provided under + /// . Please see the documentation of the property for more + /// information. + /// + /// + /// + /// + /// + public event Func, Cacheable, SocketReaction, Task> ReactionAdded + { + add { _reactionAddedEvent.Add(value); } + remove { _reactionAddedEvent.Remove(value); } + } + internal readonly AsyncEvent, Cacheable, SocketReaction, Task>> _reactionAddedEvent = new AsyncEvent, Cacheable, SocketReaction, Task>>(); + /// Fired when a reaction is removed from a message. + public event Func, Cacheable, SocketReaction, Task> ReactionRemoved + { + add { _reactionRemovedEvent.Add(value); } + remove { _reactionRemovedEvent.Remove(value); } + } + internal readonly AsyncEvent, Cacheable, SocketReaction, Task>> _reactionRemovedEvent = new AsyncEvent, Cacheable, SocketReaction, Task>>(); + /// Fired when all reactions to a message are cleared. + public event Func, Cacheable, Task> ReactionsCleared + { + add { _reactionsClearedEvent.Add(value); } + remove { _reactionsClearedEvent.Remove(value); } + } + internal readonly AsyncEvent, Cacheable, Task>> _reactionsClearedEvent = new AsyncEvent, Cacheable, Task>>(); + /// + /// Fired when all reactions to a message with a specific emote are removed. + /// + /// + /// + /// This event is fired when all reactions to a message with a specific emote are removed. + /// The event handler must return a and accept a and + /// a as its parameters. + /// + /// + /// The channel where this message was sent will be passed into the parameter. + /// + /// + /// The emoji that all reactions had and were removed will be passed into the parameter. + /// + /// + public event Func, Cacheable, IEmote, Task> ReactionsRemovedForEmote + { + add { _reactionsRemovedForEmoteEvent.Add(value); } + remove { _reactionsRemovedForEmoteEvent.Remove(value); } + } + internal readonly AsyncEvent, Cacheable, IEmote, Task>> _reactionsRemovedForEmoteEvent = new AsyncEvent, Cacheable, IEmote, Task>>(); + #endregion + + #region Roles + /// Fired when a role is created. + public event Func RoleCreated + { + add { _roleCreatedEvent.Add(value); } + remove { _roleCreatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _roleCreatedEvent = new AsyncEvent>(); + /// Fired when a role is deleted. + public event Func RoleDeleted + { + add { _roleDeletedEvent.Add(value); } + remove { _roleDeletedEvent.Remove(value); } + } + internal readonly AsyncEvent> _roleDeletedEvent = new AsyncEvent>(); + /// Fired when a role is updated. + public event Func RoleUpdated + { + add { _roleUpdatedEvent.Add(value); } + remove { _roleUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _roleUpdatedEvent = new AsyncEvent>(); + #endregion + + #region Guilds + /// Fired when the connected account joins a guild. + public event Func JoinedGuild + { + add { _joinedGuildEvent.Add(value); } + remove { _joinedGuildEvent.Remove(value); } + } + internal readonly AsyncEvent> _joinedGuildEvent = new AsyncEvent>(); + /// Fired when the connected account leaves a guild. + public event Func LeftGuild + { + add { _leftGuildEvent.Add(value); } + remove { _leftGuildEvent.Remove(value); } + } + internal readonly AsyncEvent> _leftGuildEvent = new AsyncEvent>(); + /// Fired when a guild becomes available. + public event Func GuildAvailable + { + add { _guildAvailableEvent.Add(value); } + remove { _guildAvailableEvent.Remove(value); } + } + internal readonly AsyncEvent> _guildAvailableEvent = new AsyncEvent>(); + /// Fired when a guild becomes unavailable. + public event Func GuildUnavailable + { + add { _guildUnavailableEvent.Add(value); } + remove { _guildUnavailableEvent.Remove(value); } + } + internal readonly AsyncEvent> _guildUnavailableEvent = new AsyncEvent>(); + /// Fired when offline guild members are downloaded. + public event Func GuildMembersDownloaded + { + add { _guildMembersDownloadedEvent.Add(value); } + remove { _guildMembersDownloadedEvent.Remove(value); } + } + internal readonly AsyncEvent> _guildMembersDownloadedEvent = new AsyncEvent>(); + /// Fired when a guild is updated. + public event Func GuildUpdated + { + add { _guildUpdatedEvent.Add(value); } + remove { _guildUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _guildUpdatedEvent = new AsyncEvent>(); + /// Fired when a user leaves without agreeing to the member screening + public event Func, SocketGuild, Task> GuildJoinRequestDeleted + { + add { _guildJoinRequestDeletedEvent.Add(value); } + remove { _guildJoinRequestDeletedEvent.Remove(value); } + } + internal readonly AsyncEvent, SocketGuild, Task>> _guildJoinRequestDeletedEvent = new AsyncEvent, SocketGuild, Task>>(); + #endregion + + #region Guild Events + + /// + /// Fired when a guild event is created. + /// + public event Func GuildScheduledEventCreated + { + add { _guildScheduledEventCreated.Add(value); } + remove { _guildScheduledEventCreated.Remove(value); } + } + internal readonly AsyncEvent> _guildScheduledEventCreated = new AsyncEvent>(); + + /// + /// Fired when a guild event is updated. + /// + public event Func, SocketGuildEvent, Task> GuildScheduledEventUpdated + { + add { _guildScheduledEventUpdated.Add(value); } + remove { _guildScheduledEventUpdated.Remove(value); } + } + internal readonly AsyncEvent, SocketGuildEvent, Task>> _guildScheduledEventUpdated = new AsyncEvent, SocketGuildEvent, Task>>(); + + + /// + /// Fired when a guild event is cancelled. + /// + public event Func GuildScheduledEventCancelled + { + add { _guildScheduledEventCancelled.Add(value); } + remove { _guildScheduledEventCancelled.Remove(value); } + } + internal readonly AsyncEvent> _guildScheduledEventCancelled = new AsyncEvent>(); + + /// + /// Fired when a guild event is completed. + /// + public event Func GuildScheduledEventCompleted + { + add { _guildScheduledEventCompleted.Add(value); } + remove { _guildScheduledEventCompleted.Remove(value); } + } + internal readonly AsyncEvent> _guildScheduledEventCompleted = new AsyncEvent>(); + + /// + /// Fired when a guild event is started. + /// + public event Func GuildScheduledEventStarted + { + add { _guildScheduledEventStarted.Add(value); } + remove { _guildScheduledEventStarted.Remove(value); } + } + internal readonly AsyncEvent> _guildScheduledEventStarted = new AsyncEvent>(); + + public event Func, SocketGuildEvent, Task> GuildScheduledEventUserAdd + { + add { _guildScheduledEventUserAdd.Add(value); } + remove { _guildScheduledEventUserAdd.Remove(value); } + } + internal readonly AsyncEvent, SocketGuildEvent, Task>> _guildScheduledEventUserAdd = new AsyncEvent, SocketGuildEvent, Task>>(); + + public event Func, SocketGuildEvent, Task> GuildScheduledEventUserRemove + { + add { _guildScheduledEventUserRemove.Add(value); } + remove { _guildScheduledEventUserRemove.Remove(value); } + } + internal readonly AsyncEvent, SocketGuildEvent, Task>> _guildScheduledEventUserRemove = new AsyncEvent, SocketGuildEvent, Task>>(); + + + #endregion + + #region Integrations + /// Fired when an integration is created. + public event Func IntegrationCreated + { + add { _integrationCreated.Add(value); } + remove { _integrationCreated.Remove(value); } + } + internal readonly AsyncEvent> _integrationCreated = new AsyncEvent>(); + + /// Fired when an integration is updated. + public event Func IntegrationUpdated + { + add { _integrationUpdated.Add(value); } + remove { _integrationUpdated.Remove(value); } + } + internal readonly AsyncEvent> _integrationUpdated = new AsyncEvent>(); + + /// Fired when an integration is deleted. + public event Func, Task> IntegrationDeleted + { + add { _integrationDeleted.Add(value); } + remove { _integrationDeleted.Remove(value); } + } + internal readonly AsyncEvent, Task>> _integrationDeleted = new AsyncEvent, Task>>(); + #endregion + + #region Users + /// Fired when a user joins a guild. + public event Func UserJoined + { + add { _userJoinedEvent.Add(value); } + remove { _userJoinedEvent.Remove(value); } + } + internal readonly AsyncEvent> _userJoinedEvent = new AsyncEvent>(); + /// Fired when a user leaves a guild. + public event Func UserLeft + { + add { _userLeftEvent.Add(value); } + remove { _userLeftEvent.Remove(value); } + } + internal readonly AsyncEvent> _userLeftEvent = new AsyncEvent>(); + /// Fired when a user is banned from a guild. + public event Func UserBanned + { + add { _userBannedEvent.Add(value); } + remove { _userBannedEvent.Remove(value); } + } + internal readonly AsyncEvent> _userBannedEvent = new AsyncEvent>(); + /// Fired when a user is unbanned from a guild. + public event Func UserUnbanned + { + add { _userUnbannedEvent.Add(value); } + remove { _userUnbannedEvent.Remove(value); } + } + internal readonly AsyncEvent> _userUnbannedEvent = new AsyncEvent>(); + /// Fired when a user is updated. + public event Func UserUpdated + { + add { _userUpdatedEvent.Add(value); } + remove { _userUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _userUpdatedEvent = new AsyncEvent>(); + /// Fired when a guild member is updated. + public event Func, SocketGuildUser, Task> GuildMemberUpdated + { + add { _guildMemberUpdatedEvent.Add(value); } + remove { _guildMemberUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent, SocketGuildUser, Task>> _guildMemberUpdatedEvent = new AsyncEvent, SocketGuildUser, Task>>(); + /// Fired when a user joins, leaves, or moves voice channels. + public event Func UserVoiceStateUpdated + { + add { _userVoiceStateUpdatedEvent.Add(value); } + remove { _userVoiceStateUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _userVoiceStateUpdatedEvent = new AsyncEvent>(); + /// Fired when the bot connects to a Discord voice server. + public event Func VoiceServerUpdated + { + add { _voiceServerUpdatedEvent.Add(value); } + remove { _voiceServerUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _voiceServerUpdatedEvent = new AsyncEvent>(); + /// Fired when the connected account is updated. + public event Func CurrentUserUpdated + { + add { _selfUpdatedEvent.Add(value); } + remove { _selfUpdatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _selfUpdatedEvent = new AsyncEvent>(); + /// Fired when a user starts typing. + public event Func, Cacheable, Task> UserIsTyping + { + add { _userIsTypingEvent.Add(value); } + remove { _userIsTypingEvent.Remove(value); } + } + internal readonly AsyncEvent, Cacheable, Task>> _userIsTypingEvent = new AsyncEvent, Cacheable, Task>>(); + /// Fired when a user joins a group channel. + public event Func RecipientAdded + { + add { _recipientAddedEvent.Add(value); } + remove { _recipientAddedEvent.Remove(value); } + } + internal readonly AsyncEvent> _recipientAddedEvent = new AsyncEvent>(); + /// Fired when a user is removed from a group channel. + public event Func RecipientRemoved + { + add { _recipientRemovedEvent.Add(value); } + remove { _recipientRemovedEvent.Remove(value); } + } + internal readonly AsyncEvent> _recipientRemovedEvent = new AsyncEvent>(); + #endregion + + #region Presence + + /// Fired when a users presence is updated. + public event Func PresenceUpdated + { + add { _presenceUpdated.Add(value); } + remove { _presenceUpdated.Remove(value); } + } + internal readonly AsyncEvent> _presenceUpdated = new AsyncEvent>(); + + #endregion + + #region Invites + /// + /// Fired when an invite is created. + /// + /// + /// + /// This event is fired when an invite is created. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The invite created will be passed into the parameter. + /// + /// + public event Func InviteCreated + { + add { _inviteCreatedEvent.Add(value); } + remove { _inviteCreatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _inviteCreatedEvent = new AsyncEvent>(); + /// + /// Fired when an invite is deleted. + /// + /// + /// + /// This event is fired when an invite is deleted. The event handler must return + /// a and accept a and + /// as its parameter. + /// + /// + /// The channel where this invite was created will be passed into the parameter. + /// + /// + /// The code of the deleted invite will be passed into the parameter. + /// + /// + public event Func InviteDeleted + { + add { _inviteDeletedEvent.Add(value); } + remove { _inviteDeletedEvent.Remove(value); } + } + internal readonly AsyncEvent> _inviteDeletedEvent = new AsyncEvent>(); + #endregion + + #region Interactions + /// + /// Fired when an Interaction is created. This event covers all types of interactions including but not limited to: buttons, select menus, slash commands, autocompletes. + /// + /// + /// + /// This event is fired when an interaction is created. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The interaction created will be passed into the parameter. + /// + /// + public event Func InteractionCreated + { + add { _interactionCreatedEvent.Add(value); } + remove { _interactionCreatedEvent.Remove(value); } + } + internal readonly AsyncEvent> _interactionCreatedEvent = new AsyncEvent>(); + + /// + /// Fired when a button is clicked and its interaction is received. + /// + public event Func ButtonExecuted + { + add => _buttonExecuted.Add(value); + remove => _buttonExecuted.Remove(value); + } + internal readonly AsyncEvent> _buttonExecuted = new AsyncEvent>(); + + /// + /// Fired when a select menu is used and its interaction is received. + /// + public event Func SelectMenuExecuted + { + add => _selectMenuExecuted.Add(value); + remove => _selectMenuExecuted.Remove(value); + } + internal readonly AsyncEvent> _selectMenuExecuted = new AsyncEvent>(); + /// + /// Fired when a slash command is used and its interaction is received. + /// + public event Func SlashCommandExecuted + { + add => _slashCommandExecuted.Add(value); + remove => _slashCommandExecuted.Remove(value); + } + internal readonly AsyncEvent> _slashCommandExecuted = new AsyncEvent>(); + + /// + /// Fired when a user command is used and its interaction is received. + /// + public event Func UserCommandExecuted + { + add => _userCommandExecuted.Add(value); + remove => _userCommandExecuted.Remove(value); + } + internal readonly AsyncEvent> _userCommandExecuted = new AsyncEvent>(); + + /// + /// Fired when a message command is used and its interaction is received. + /// + public event Func MessageCommandExecuted + { + add => _messageCommandExecuted.Add(value); + remove => _messageCommandExecuted.Remove(value); + } + internal readonly AsyncEvent> _messageCommandExecuted = new AsyncEvent>(); + /// + /// Fired when an autocomplete is used and its interaction is received. + /// + public event Func AutocompleteExecuted + { + add => _autocompleteExecuted.Add(value); + remove => _autocompleteExecuted.Remove(value); + } + internal readonly AsyncEvent> _autocompleteExecuted = new AsyncEvent>(); + /// + /// Fired when a modal is submitted. + /// + public event Func ModalSubmitted + { + add => _modalSubmitted.Add(value); + remove => _modalSubmitted.Remove(value); + } + internal readonly AsyncEvent> _modalSubmitted = new AsyncEvent>(); + + /// + /// Fired when a guild application command is created. + /// + /// + /// + /// This event is fired when an application command is created. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The command that was deleted will be passed into the parameter. + /// + /// + /// This event is an undocumented discord event and may break at any time, its not recommended to rely on this event + /// + /// + public event Func ApplicationCommandCreated + { + add { _applicationCommandCreated.Add(value); } + remove { _applicationCommandCreated.Remove(value); } + } + internal readonly AsyncEvent> _applicationCommandCreated = new AsyncEvent>(); + + /// + /// Fired when a guild application command is updated. + /// + /// + /// + /// This event is fired when an application command is updated. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The command that was deleted will be passed into the parameter. + /// + /// + /// This event is an undocumented discord event and may break at any time, its not recommended to rely on this event + /// + /// + public event Func ApplicationCommandUpdated + { + add { _applicationCommandUpdated.Add(value); } + remove { _applicationCommandUpdated.Remove(value); } + } + internal readonly AsyncEvent> _applicationCommandUpdated = new AsyncEvent>(); + + /// + /// Fired when a guild application command is deleted. + /// + /// + /// + /// This event is fired when an application command is deleted. The event handler must return a + /// and accept a as its parameter. + /// + /// + /// The command that was deleted will be passed into the parameter. + /// + /// + /// This event is an undocumented discord event and may break at any time, its not recommended to rely on this event + /// + /// + public event Func ApplicationCommandDeleted + { + add { _applicationCommandDeleted.Add(value); } + remove { _applicationCommandDeleted.Remove(value); } + } + internal readonly AsyncEvent> _applicationCommandDeleted = new AsyncEvent>(); + + /// + /// Fired when a thread is created within a guild, or when the current user is added to a thread. + /// + public event Func ThreadCreated + { + add { _threadCreated.Add(value); } + remove { _threadCreated.Remove(value); } + } + internal readonly AsyncEvent> _threadCreated = new AsyncEvent>(); + + /// + /// Fired when a thread is updated within a guild. + /// + public event Func, SocketThreadChannel, Task> ThreadUpdated + { + add { _threadUpdated.Add(value); } + remove { _threadUpdated.Remove(value); } + } + + internal readonly AsyncEvent, SocketThreadChannel, Task>> _threadUpdated = new(); + + /// + /// Fired when a thread is deleted. + /// + public event Func, Task> ThreadDeleted + { + add { _threadDeleted.Add(value); } + remove { _threadDeleted.Remove(value); } + } + internal readonly AsyncEvent, Task>> _threadDeleted = new AsyncEvent, Task>>(); + + /// + /// Fired when a user joins a thread + /// + public event Func ThreadMemberJoined + { + add { _threadMemberJoined.Add(value); } + remove { _threadMemberJoined.Remove(value); } + } + internal readonly AsyncEvent> _threadMemberJoined = new AsyncEvent>(); + + /// + /// Fired when a user leaves a thread + /// + public event Func ThreadMemberLeft + { + add { _threadMemberLeft.Add(value); } + remove { _threadMemberLeft.Remove(value); } + } + internal readonly AsyncEvent> _threadMemberLeft = new AsyncEvent>(); + + /// + /// Fired when a stage is started. + /// + public event Func StageStarted + { + add { _stageStarted.Add(value); } + remove { _stageStarted.Remove(value); } + } + internal readonly AsyncEvent> _stageStarted = new AsyncEvent>(); + + /// + /// Fired when a stage ends. + /// + public event Func StageEnded + { + add { _stageEnded.Add(value); } + remove { _stageEnded.Remove(value); } + } + internal readonly AsyncEvent> _stageEnded = new AsyncEvent>(); + + /// + /// Fired when a stage is updated. + /// + public event Func StageUpdated + { + add { _stageUpdated.Add(value); } + remove { _stageUpdated.Remove(value); } + } + internal readonly AsyncEvent> _stageUpdated = new AsyncEvent>(); + + /// + /// Fired when a user requests to speak within a stage channel. + /// + public event Func RequestToSpeak + { + add { _requestToSpeak.Add(value); } + remove { _requestToSpeak.Remove(value); } + } + internal readonly AsyncEvent> _requestToSpeak = new AsyncEvent>(); + + /// + /// Fired when a speaker is added in a stage channel. + /// + public event Func SpeakerAdded + { + add { _speakerAdded.Add(value); } + remove { _speakerAdded.Remove(value); } + } + internal readonly AsyncEvent> _speakerAdded = new AsyncEvent>(); + + /// + /// Fired when a speaker is removed from a stage channel. + /// + public event Func SpeakerRemoved + { + add { _speakerRemoved.Add(value); } + remove { _speakerRemoved.Remove(value); } + } + internal readonly AsyncEvent> _speakerRemoved = new AsyncEvent>(); + + /// + /// Fired when a sticker in a guild is created. + /// + public event Func GuildStickerCreated + { + add { _guildStickerCreated.Add(value); } + remove { _guildStickerCreated.Remove(value); } + } + internal readonly AsyncEvent> _guildStickerCreated = new AsyncEvent>(); + + /// + /// Fired when a sticker in a guild is updated. + /// + public event Func GuildStickerUpdated + { + add { _guildStickerUpdated.Add(value); } + remove { _guildStickerUpdated.Remove(value); } + } + internal readonly AsyncEvent> _guildStickerUpdated = new AsyncEvent>(); + + /// + /// Fired when a sticker in a guild is deleted. + /// + public event Func GuildStickerDeleted + { + add { _guildStickerDeleted.Add(value); } + remove { _guildStickerDeleted.Remove(value); } + } + internal readonly AsyncEvent> _guildStickerDeleted = new AsyncEvent>(); + #endregion + + #region Webhooks + + /// + /// Fired when a webhook is modified, moved, or deleted. If the webhook was + /// moved the channel represents the destination channel, not the source. + /// + public event Func WebhooksUpdated + { + add { _webhooksUpdated.Add(value); } + remove { _webhooksUpdated.Remove(value); } + } + internal readonly AsyncEvent> _webhooksUpdated = new AsyncEvent>(); + + #endregion + + #region Audit Logs + + /// + /// Fired when a guild audit log entry is created. + /// + public event Func AuditLogCreated + { + add { _auditLogCreated.Add(value); } + remove { _auditLogCreated.Remove(value); } + } + + internal readonly AsyncEvent> _auditLogCreated = new(); + + #endregion + + #region AutoModeration + + /// + /// Fired when an auto moderation rule is created. + /// + public event Func AutoModRuleCreated + { + add => _autoModRuleCreated.Add(value); + remove => _autoModRuleCreated.Remove(value); + } + internal readonly AsyncEvent> _autoModRuleCreated = new(); + + /// + /// Fired when an auto moderation rule is modified. + /// + public event Func, SocketAutoModRule, Task> AutoModRuleUpdated + { + add => _autoModRuleUpdated.Add(value); + remove => _autoModRuleUpdated.Remove(value); + } + internal readonly AsyncEvent, SocketAutoModRule, Task>> _autoModRuleUpdated = new(); + + /// + /// Fired when an auto moderation rule is deleted. + /// + public event Func AutoModRuleDeleted + { + add => _autoModRuleDeleted.Add(value); + remove => _autoModRuleDeleted.Remove(value); + } + internal readonly AsyncEvent> _autoModRuleDeleted = new(); + + /// + /// Fired when an auto moderation rule is triggered by a user. + /// + public event Func AutoModActionExecuted + { + add => _autoModActionExecuted.Add(value); + remove => _autoModActionExecuted.Remove(value); + } + internal readonly AsyncEvent> _autoModActionExecuted = new(); + + #endregion + + #region App Subscriptions + + /// + /// Fired when a user subscribes to a SKU. + /// + public event Func EntitlementCreated + { + add { _entitlementCreated.Add(value); } + remove { _entitlementCreated.Remove(value); } + } + + internal readonly AsyncEvent> _entitlementCreated = new(); + + + /// + /// Fired when a subscription to a SKU is updated. + /// + public event Func, SocketEntitlement, Task> EntitlementUpdated + { + add { _entitlementUpdated.Add(value); } + remove { _entitlementUpdated.Remove(value); } + } + + internal readonly AsyncEvent, SocketEntitlement, Task>> _entitlementUpdated = new(); + + + /// + /// Fired when a user's entitlement is deleted. + /// + public event Func, Task> EntitlementDeleted + { + add { _entitlementDeleted.Add(value); } + remove { _entitlementDeleted.Remove(value); } + } + + internal readonly AsyncEvent, Task>> _entitlementDeleted = new(); + + + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/BaseSocketClient.cs b/src/Discord.Net.WebSocket/BaseSocketClient.cs new file mode 100644 index 0000000..03c0417 --- /dev/null +++ b/src/Discord.Net.WebSocket/BaseSocketClient.cs @@ -0,0 +1,358 @@ +using Discord.API; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + /// + /// Represents the base of a WebSocket-based Discord client. + /// + public abstract partial class BaseSocketClient : BaseDiscordClient, IDiscordClient, IRestClientProvider + { + #region BaseSocketClient + protected readonly DiscordSocketConfig BaseConfig; + + /// + /// Gets the estimated round-trip latency, in milliseconds, to the gateway server. + /// + /// + /// An that represents the round-trip latency to the WebSocket server. Please + /// note that this value does not represent a "true" latency for operations such as sending a message. + /// + public abstract int Latency { get; protected set; } + /// + /// Gets the status for the logged-in user. + /// + /// + /// A status object that represents the user's online presence status. + /// + public abstract UserStatus Status { get; protected set; } + /// + /// Gets the activity for the logged-in user. + /// + /// + /// An activity object that represents the user's current activity. + /// + public abstract IActivity Activity { get; protected set; } + + /// + /// Provides access to a REST-only client with a shared state from this client. + /// + public abstract DiscordSocketRestClient Rest { get; } + + internal new DiscordSocketApiClient ApiClient => base.ApiClient as DiscordSocketApiClient; + + /// + /// Gets a collection of default stickers. + /// + public abstract IReadOnlyCollection> DefaultStickerPacks { get; } + /// + /// Gets the current logged-in user. + /// + public virtual new SocketSelfUser CurrentUser { get => base.CurrentUser as SocketSelfUser; protected set => base.CurrentUser = value; } + /// + /// Gets a collection of guilds that the user is currently in. + /// + /// + /// A read-only collection of guilds that the current user is in. + /// + public abstract IReadOnlyCollection Guilds { get; } + /// + /// Gets a collection of private channels opened in this session. + /// + /// + /// This method will retrieve all private channels (including direct-message, group channel and such) that + /// are currently opened in this session. + /// + /// This method will not return previously opened private channels outside of the current session! If + /// you have just started the client, this may return an empty collection. + /// + /// + /// + /// A read-only collection of private channels that the user currently partakes in. + /// + public abstract IReadOnlyCollection PrivateChannels { get; } + + internal BaseSocketClient(DiscordSocketConfig config, DiscordRestApiClient client) + : base(config, client) => BaseConfig = config; + private static DiscordSocketApiClient CreateApiClient(DiscordSocketConfig config) + => new DiscordSocketApiClient(config.RestClientProvider, config.WebSocketProvider, DiscordRestConfig.UserAgent, config.GatewayHost, + useSystemClock: config.UseSystemClock, defaultRatelimitCallback: config.DefaultRatelimitCallback); + + /// + /// Gets a Discord application information for the logged-in user. + /// + /// + /// This method reflects your application information you submitted when creating a Discord application via + /// the Developer Portal. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the application + /// information. + /// + public abstract Task GetApplicationInfoAsync(RequestOptions options = null); + /// + /// Gets a generic user. + /// + /// The user snowflake ID. + /// + /// This method gets the user present in the WebSocket cache with the given condition. + /// + /// Sometimes a user may return due to Discord not sending offline users in large guilds + /// (i.e. guild with 100+ members) actively. To download users on startup and to see more information + /// about this subject, see . + /// + /// + /// This method does not attempt to fetch users that the logged-in user does not have access to (i.e. + /// users who don't share mutual guild(s) with the current user). If you wish to get a user that you do + /// not have access to, consider using the REST implementation of + /// . + /// + /// + /// + /// A generic WebSocket-based user; when the user cannot be found. + /// + public abstract SocketUser GetUser(ulong id); + + /// + /// Gets a user. + /// + /// + /// This method gets the user present in the WebSocket cache with the given condition. + /// + /// Sometimes a user may return due to Discord not sending offline users in large guilds + /// (i.e. guild with 100+ members) actively. To download users on startup and to see more information + /// about this subject, see . + /// + /// + /// This method does not attempt to fetch users that the logged-in user does not have access to (i.e. + /// users who don't share mutual guild(s) with the current user). If you wish to get a user that you do + /// not have access to, consider using the REST implementation of + /// . + /// + /// + /// The name of the user. + /// The discriminator value of the user. + /// + /// A generic WebSocket-based user; when the user cannot be found. + /// + public abstract SocketUser GetUser(string username, string discriminator = null); + /// + /// Gets a channel. + /// + /// The snowflake identifier of the channel (e.g. `381889909113225237`). + /// + /// A generic WebSocket-based channel object (voice, text, category, etc.) associated with the identifier; + /// when the channel cannot be found. + /// + public abstract SocketChannel GetChannel(ulong id); + /// + /// Gets a guild. + /// + /// The guild snowflake identifier. + /// + /// A WebSocket-based guild associated with the snowflake identifier; when the guild cannot be + /// found. + /// + public abstract SocketGuild GetGuild(ulong id); + /// + /// Gets all voice regions. + /// + /// The options to be used when sending the request. + /// + /// A task that contains a read-only collection of REST-based voice regions. + /// + public abstract ValueTask> GetVoiceRegionsAsync(RequestOptions options = null); + /// + /// Gets a voice region. + /// + /// The identifier of the voice region (e.g. eu-central ). + /// The options to be used when sending the request. + /// + /// A task that contains a REST-based voice region associated with the identifier; if the + /// voice region is not found. + /// + public abstract ValueTask GetVoiceRegionAsync(string id, RequestOptions options = null); + /// + public abstract Task StartAsync(); + /// + public abstract Task StopAsync(); + /// + /// Sets the current status of the user (e.g. Online, Do not Disturb). + /// + /// The new status to be set. + /// + /// A task that represents the asynchronous set operation. + /// + public abstract Task SetStatusAsync(UserStatus status); + /// + /// Sets the game of the user. + /// + /// The name of the game. + /// If streaming, the URL of the stream. Must be a valid Twitch URL. + /// The type of the game. + /// + /// + /// Bot accounts cannot set as their activity + /// type and it will have no effect. + /// + /// + /// + /// A task that represents the asynchronous set operation. + /// + public abstract Task SetGameAsync(string name, string streamUrl = null, ActivityType type = ActivityType.Playing); + /// + /// Sets the of the logged-in user. + /// + /// + /// This method sets the of the user. + /// + /// Discord will only accept setting of name and the type of activity. + /// + /// + /// Bot accounts cannot set as their activity + /// type and it will have no effect. + /// + /// + /// Rich Presence cannot be set via this method or client. Rich Presence is strictly limited to RPC + /// clients only. + /// + /// + /// The activity to be set. + /// + /// A task that represents the asynchronous set operation. + /// + public abstract Task SetActivityAsync(IActivity activity); + + /// + /// Sets the custom status of the logged-in user. + /// + /// The string that will be displayed as status. + /// + /// A task that represents the asynchronous set operation. + /// + public abstract Task SetCustomStatusAsync(string status); + + /// + /// Attempts to download users into the user cache for the selected guilds. + /// + /// The guilds to download the members from. + /// + /// A task that represents the asynchronous download operation. + /// + public abstract Task DownloadUsersAsync(IEnumerable guilds); + + /// + /// Creates a guild for the logged-in user who is in less than 10 active guilds. + /// + /// + /// This method creates a new guild on behalf of the logged-in user. + /// + /// Due to Discord's limitation, this method will only work for users that are in less than 10 guilds. + /// + /// + /// The name of the new guild. + /// The voice region to create the guild with. + /// The icon of the guild. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created guild. + /// + public Task CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon = null, RequestOptions options = null) + => ClientHelper.CreateGuildAsync(this, name, region, jpegIcon, options ?? RequestOptions.Default); + /// + /// Gets the connections that the user has set up. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of connections. + /// + public Task> GetConnectionsAsync(RequestOptions options = null) + => ClientHelper.GetConnectionsAsync(this, options ?? RequestOptions.Default); + /// + /// Gets an invite. + /// + /// The invitation identifier. + /// The options to be used when sending the request. + /// The id of the guild scheduled event to include with the invite. + /// + /// A task that represents the asynchronous get operation. The task result contains the invite information. + /// + public Task GetInviteAsync(string inviteId, RequestOptions options = null, ulong? scheduledEventId = null) + => ClientHelper.GetInviteAsync(this, inviteId, options ?? RequestOptions.Default, scheduledEventId); + /// + /// Gets a sticker. + /// + /// Whether or not to allow downloading from the api. + /// The id of the sticker to get. + /// The options to be used when sending the request. + /// + /// A if found, otherwise . + /// + public abstract Task GetStickerAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null); + #endregion + + #region IDiscordClient + /// + async Task IDiscordClient.GetApplicationInfoAsync(RequestOptions options) + => await GetApplicationInfoAsync(options).ConfigureAwait(false); + + /// + Task IDiscordClient.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetChannel(id)); + /// + Task> IDiscordClient.GetPrivateChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(PrivateChannels); + + /// + async Task> IDiscordClient.GetConnectionsAsync(RequestOptions options) + => await GetConnectionsAsync(options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetInviteAsync(string inviteId, RequestOptions options) + => await GetInviteAsync(inviteId, options).ConfigureAwait(false); + + /// + Task IDiscordClient.GetGuildAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetGuild(id)); + /// + Task> IDiscordClient.GetGuildsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(Guilds); + + /// + async Task IDiscordClient.CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon, RequestOptions options) + => await CreateGuildAsync(name, region, jpegIcon, options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + var user = GetUser(id); + if (user is not null || mode == CacheMode.CacheOnly) + return user; + + return await Rest.GetUserAsync(id, options).ConfigureAwait(false); + } + + /// + Task IDiscordClient.GetUserAsync(string username, string discriminator, RequestOptions options) + => Task.FromResult(GetUser(username, discriminator)); + + /// + async Task IDiscordClient.GetVoiceRegionAsync(string id, RequestOptions options) + { + return await GetVoiceRegionAsync(id).ConfigureAwait(false); + } + /// + async Task> IDiscordClient.GetVoiceRegionsAsync(RequestOptions options) + { + return await GetVoiceRegionsAsync().ConfigureAwait(false); + } + #endregion + + DiscordRestClient IRestClientProvider.RestClient => Rest; + } +} diff --git a/src/Discord.Net.WebSocket/ClientState.cs b/src/Discord.Net.WebSocket/ClientState.cs new file mode 100644 index 0000000..745d558 --- /dev/null +++ b/src/Discord.Net.WebSocket/ClientState.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace Discord.WebSocket +{ + internal class ClientState + { + private const double AverageChannelsPerGuild = 10.22; //Source: Googie2149 + private const double AverageUsersPerGuild = 47.78; //Source: Googie2149 + private const double CollectionMultiplier = 1.05; //Add 5% buffer to handle growth + + private readonly ConcurrentDictionary _channels; + private readonly ConcurrentDictionary _dmChannels; + private readonly ConcurrentDictionary _guilds; + private readonly ConcurrentDictionary _users; + private readonly ConcurrentHashSet _groupChannels; + private readonly ConcurrentDictionary _commands; + private readonly ConcurrentDictionary _entitlements; + + internal IReadOnlyCollection Channels => _channels.ToReadOnlyCollection(); + internal IReadOnlyCollection DMChannels => _dmChannels.ToReadOnlyCollection(); + internal IReadOnlyCollection GroupChannels => _groupChannels.Select(x => GetChannel(x) as SocketGroupChannel).ToReadOnlyCollection(_groupChannels); + internal IReadOnlyCollection Guilds => _guilds.ToReadOnlyCollection(); + internal IReadOnlyCollection Users => _users.ToReadOnlyCollection(); + internal IReadOnlyCollection Commands => _commands.ToReadOnlyCollection(); + internal IReadOnlyCollection Entitlements => _entitlements.ToReadOnlyCollection(); + + internal IReadOnlyCollection PrivateChannels => + _dmChannels.Select(x => x.Value as ISocketPrivateChannel).Concat( + _groupChannels.Select(x => GetChannel(x) as ISocketPrivateChannel)) + .ToReadOnlyCollection(() => _dmChannels.Count + _groupChannels.Count); + + public ClientState(int guildCount, int dmChannelCount) + { + double estimatedChannelCount = guildCount * AverageChannelsPerGuild + dmChannelCount; + double estimatedUsersCount = guildCount * AverageUsersPerGuild; + _channels = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(estimatedChannelCount * CollectionMultiplier)); + _dmChannels = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(dmChannelCount * CollectionMultiplier)); + _guilds = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(guildCount * CollectionMultiplier)); + _users = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(estimatedUsersCount * CollectionMultiplier)); + _groupChannels = new ConcurrentHashSet(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(10 * CollectionMultiplier)); + _commands = new ConcurrentDictionary(); + _entitlements = new(); + } + + internal SocketChannel GetChannel(ulong id) + { + if (_channels.TryGetValue(id, out SocketChannel channel)) + return channel; + return null; + } + internal SocketDMChannel GetDMChannel(ulong userId) + { + if (_dmChannels.TryGetValue(userId, out SocketDMChannel channel)) + return channel; + return null; + } + internal void AddChannel(SocketChannel channel) + { + _channels[channel.Id] = channel; + + switch (channel) + { + case SocketDMChannel dmChannel: + _dmChannels[dmChannel.Recipient.Id] = dmChannel; + break; + case SocketGroupChannel groupChannel: + _groupChannels.TryAdd(groupChannel.Id); + break; + } + } + internal SocketChannel RemoveChannel(ulong id) + { + if (_channels.TryRemove(id, out SocketChannel channel)) + { + switch (channel) + { + case SocketDMChannel dmChannel: + _dmChannels.TryRemove(dmChannel.Recipient.Id, out _); + break; + case SocketGroupChannel _: + _groupChannels.TryRemove(id); + break; + } + return channel; + } + return null; + } + internal void PurgeAllChannels() + { + foreach (var guild in _guilds.Values) + guild.PurgeChannelCache(this); + + PurgeDMChannels(); + } + internal void PurgeDMChannels() + { + foreach (var channel in _dmChannels.Values) + _channels.TryRemove(channel.Id, out _); + + _dmChannels.Clear(); + } + + internal SocketGuild GetGuild(ulong id) + { + if (_guilds.TryGetValue(id, out SocketGuild guild)) + return guild; + return null; + } + internal void AddGuild(SocketGuild guild) + { + _guilds[guild.Id] = guild; + } + internal SocketGuild RemoveGuild(ulong id) + { + if (_guilds.TryRemove(id, out SocketGuild guild)) + { + guild.PurgeChannelCache(this); + guild.PurgeUserCache(); + return guild; + } + return null; + } + + internal SocketGlobalUser GetUser(ulong id) + { + if (_users.TryGetValue(id, out SocketGlobalUser user)) + return user; + return null; + } + internal SocketGlobalUser GetOrAddUser(ulong id, Func userFactory) + { + return _users.GetOrAdd(id, userFactory); + } + internal SocketGlobalUser RemoveUser(ulong id) + { + if (_users.TryRemove(id, out SocketGlobalUser user)) + return user; + return null; + } + internal void PurgeUsers() + { + foreach (var guild in _guilds.Values) + guild.PurgeUserCache(); + } + + internal SocketApplicationCommand GetCommand(ulong id) + { + if (_commands.TryGetValue(id, out SocketApplicationCommand command)) + return command; + return null; + } + internal void AddCommand(SocketApplicationCommand command) + { + _commands[command.Id] = command; + } + internal SocketApplicationCommand GetOrAddCommand(ulong id, Func commandFactory) + { + return _commands.GetOrAdd(id, commandFactory); + } + internal SocketApplicationCommand RemoveCommand(ulong id) + { + if (_commands.TryRemove(id, out SocketApplicationCommand command)) + return command; + return null; + } + internal void PurgeCommands(Func precondition) + { + var ids = _commands.Where(x => precondition(x.Value)).Select(x => x.Key); + + foreach (var id in ids) + _commands.TryRemove(id, out var _); + } + + internal void AddEntitlement(ulong id, SocketEntitlement entitlement) + { + _entitlements.TryAdd(id, entitlement); + } + + internal SocketEntitlement GetEntitlement(ulong id) + { + if (_entitlements.TryGetValue(id, out var entitlement)) + return entitlement; + return null; + } + + internal SocketEntitlement GetOrAddEntitlement(ulong id, Func entitlementFactory) + { + return _entitlements.GetOrAdd(id, entitlementFactory); + } + + internal SocketEntitlement RemoveEntitlement(ulong id) + { + if(_entitlements.TryRemove(id, out var entitlement)) + return entitlement; + return null; + } + } +} diff --git a/src/Discord.Net.WebSocket/Commands/ShardedCommandContext.cs b/src/Discord.Net.WebSocket/Commands/ShardedCommandContext.cs new file mode 100644 index 0000000..0ee0cf4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Commands/ShardedCommandContext.cs @@ -0,0 +1,28 @@ +using Discord.WebSocket; + +namespace Discord.Commands +{ + /// The sharded variant of , which may contain the client, user, guild, channel, and message. + public class ShardedCommandContext : SocketCommandContext, ICommandContext + { + #region ShardedCommandContext + /// Gets the that the command is executed with. + public new DiscordShardedClient Client { get; } + + public ShardedCommandContext(DiscordShardedClient client, SocketUserMessage msg) + : base(client.GetShard(GetShardId(client, (msg.Channel as SocketGuildChannel)?.Guild)), msg) + { + Client = client; + } + + /// Gets the shard ID of the command context. + private static int GetShardId(DiscordShardedClient client, IGuild guild) + => guild == null ? 0 : client.GetShardIdFor(guild); + #endregion + + #region ICommandContext + /// + IDiscordClient ICommandContext.Client => Client; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Commands/SocketCommandContext.cs b/src/Discord.Net.WebSocket/Commands/SocketCommandContext.cs new file mode 100644 index 0000000..e57e5ef --- /dev/null +++ b/src/Discord.Net.WebSocket/Commands/SocketCommandContext.cs @@ -0,0 +1,65 @@ +using Discord.WebSocket; + +namespace Discord.Commands +{ + /// + /// Represents a WebSocket-based context of a command. This may include the client, guild, channel, user, and message. + /// + public class SocketCommandContext : ICommandContext + { + #region SocketCommandContext + /// + /// Gets the that the command is executed with. + /// + public DiscordSocketClient Client { get; } + /// + /// Gets the that the command is executed in. + /// + public SocketGuild Guild { get; } + /// + /// Gets the that the command is executed in. + /// + public ISocketMessageChannel Channel { get; } + /// + /// Gets the who executed the command. + /// + public SocketUser User { get; } + /// + /// Gets the that the command is interpreted from. + /// + public SocketUserMessage Message { get; } + + /// + /// Indicates whether the channel that the command is executed in is a private channel. + /// + public bool IsPrivate => Channel is IPrivateChannel; + + /// + /// Initializes a new class with the provided client and message. + /// + /// The underlying client. + /// The underlying message. + public SocketCommandContext(DiscordSocketClient client, SocketUserMessage msg) + { + Client = client; + Guild = (msg.Channel as SocketGuildChannel)?.Guild; + Channel = msg.Channel; + User = msg.Author; + Message = msg; + } + #endregion + + #region ICommandContext + /// + IDiscordClient ICommandContext.Client => Client; + /// + IGuild ICommandContext.Guild => Guild; + /// + IMessageChannel ICommandContext.Channel => Channel; + /// + IUser ICommandContext.User => User; + /// + IUserMessage ICommandContext.Message => Message; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/ConnectionManager.cs b/src/Discord.Net.WebSocket/ConnectionManager.cs new file mode 100644 index 0000000..53d1220 --- /dev/null +++ b/src/Discord.Net.WebSocket/ConnectionManager.cs @@ -0,0 +1,240 @@ +using Discord.Logging; +using Discord.Net; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord +{ + internal class ConnectionManager : IDisposable + { + public event Func Connected { add { _connectedEvent.Add(value); } remove { _connectedEvent.Remove(value); } } + private readonly AsyncEvent> _connectedEvent = new AsyncEvent>(); + public event Func Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + + private readonly SemaphoreSlim _stateLock; + private readonly Logger _logger; + private readonly int _connectionTimeout; + private readonly Func _onConnecting; + private readonly Func _onDisconnecting; + + private TaskCompletionSource _connectionPromise, _readyPromise; + private CancellationTokenSource _combinedCancelToken, _reconnectCancelToken, _connectionCancelToken; + private Task _task; + + private bool _isDisposed; + + public ConnectionState State { get; private set; } + public CancellationToken CancelToken { get; private set; } + + internal ConnectionManager(SemaphoreSlim stateLock, Logger logger, int connectionTimeout, + Func onConnecting, Func onDisconnecting, Action> clientDisconnectHandler) + { + _stateLock = stateLock; + _logger = logger; + _connectionTimeout = connectionTimeout; + _onConnecting = onConnecting; + _onDisconnecting = onDisconnecting; + + clientDisconnectHandler(ex => + { + if (ex != null) + { + var ex2 = ex as WebSocketClosedException; + if (ex2?.CloseCode == 4006) + CriticalError(new Exception("WebSocket session expired", ex)); + else if (ex2?.CloseCode == 4014) + CriticalError(new Exception("WebSocket connection was closed", ex)); + else + Error(new Exception("WebSocket connection was closed", ex)); + } + else + Error(new Exception("WebSocket connection was closed")); + return Task.Delay(0); + }); + } + + public virtual async Task StartAsync() + { + if (State != ConnectionState.Disconnected) + throw new InvalidOperationException("Cannot start an already running client."); + + await AcquireConnectionLock().ConfigureAwait(false); + var reconnectCancelToken = new CancellationTokenSource(); + _reconnectCancelToken?.Dispose(); + _reconnectCancelToken = reconnectCancelToken; + _task = Task.Run(async () => + { + try + { + Random jitter = new Random(); + int nextReconnectDelay = 1000; + while (!reconnectCancelToken.IsCancellationRequested) + { + try + { + await ConnectAsync(reconnectCancelToken).ConfigureAwait(false); + nextReconnectDelay = 1000; //Reset delay + await _connectionPromise.Task.ConfigureAwait(false); + } + // remove for testing. + //catch (OperationCanceledException ex) + //{ + // // Added back for log out / stop to client. The connection promise would cancel and it would be logged as an error, shouldn't be the case. + // // ref #2026 + + // Cancel(); //In case this exception didn't come from another Error call + // await DisconnectAsync(ex, !reconnectCancelToken.IsCancellationRequested).ConfigureAwait(false); + //} + catch (Exception ex) + { + Error(ex); //In case this exception didn't come from another Error call + if (!reconnectCancelToken.IsCancellationRequested) + { + await _logger.WarningAsync(ex).ConfigureAwait(false); + await DisconnectAsync(ex, true).ConfigureAwait(false); + } + else + { + await _logger.ErrorAsync(ex).ConfigureAwait(false); + await DisconnectAsync(ex, false).ConfigureAwait(false); + } + } + + if (!reconnectCancelToken.IsCancellationRequested) + { + //Wait before reconnecting + await Task.Delay(nextReconnectDelay, reconnectCancelToken.Token).ConfigureAwait(false); + nextReconnectDelay = (nextReconnectDelay * 2) + jitter.Next(-250, 250); + if (nextReconnectDelay > 60000) + nextReconnectDelay = 60000; + } + } + } + finally { _stateLock.Release(); } + }); + } + public virtual Task StopAsync() + { + Cancel(); + return Task.CompletedTask; + } + + private async Task ConnectAsync(CancellationTokenSource reconnectCancelToken) + { + _connectionCancelToken?.Dispose(); + _combinedCancelToken?.Dispose(); + _connectionCancelToken = new CancellationTokenSource(); + _combinedCancelToken = CancellationTokenSource.CreateLinkedTokenSource(_connectionCancelToken.Token, reconnectCancelToken.Token); + CancelToken = _combinedCancelToken.Token; + + _connectionPromise = new TaskCompletionSource(); + State = ConnectionState.Connecting; + await _logger.InfoAsync("Connecting").ConfigureAwait(false); + + try + { + var readyPromise = new TaskCompletionSource(); + _readyPromise = readyPromise; + + //Abort connection on timeout + var cancelToken = CancelToken; + var _ = Task.Run(async () => + { + try + { + await Task.Delay(_connectionTimeout, cancelToken).ConfigureAwait(false); + readyPromise.TrySetException(new TimeoutException()); + } + catch (OperationCanceledException) { } + }); + + await _onConnecting().ConfigureAwait(false); + + await _logger.InfoAsync("Connected").ConfigureAwait(false); + State = ConnectionState.Connected; + await _logger.DebugAsync("Raising Event").ConfigureAwait(false); + await _connectedEvent.InvokeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Error(ex); + throw; + } + } + private async Task DisconnectAsync(Exception ex, bool isReconnecting) + { + if (State == ConnectionState.Disconnected) + return; + State = ConnectionState.Disconnecting; + await _logger.InfoAsync("Disconnecting").ConfigureAwait(false); + + await _onDisconnecting(ex).ConfigureAwait(false); + + State = ConnectionState.Disconnected; + await _disconnectedEvent.InvokeAsync(ex, isReconnecting).ConfigureAwait(false); + await _logger.InfoAsync("Disconnected").ConfigureAwait(false); + } + + public Task CompleteAsync() + => _readyPromise.TrySetResultAsync(true); + + public Task WaitAsync() + => _readyPromise.Task; + + public void Cancel() + { + _readyPromise?.TrySetCanceled(); + _connectionPromise?.TrySetCanceled(); + _reconnectCancelToken?.Cancel(); + _connectionCancelToken?.Cancel(); + } + public void Error(Exception ex) + { + _readyPromise.TrySetException(ex); + _connectionPromise.TrySetException(ex); + _connectionCancelToken?.Cancel(); + } + public void CriticalError(Exception ex) + { + _reconnectCancelToken?.Cancel(); + Error(ex); + } + public void Reconnect() + { + _readyPromise.TrySetCanceled(); + _connectionPromise.TrySetCanceled(); + _connectionCancelToken?.Cancel(); + } + private async Task AcquireConnectionLock() + { + while (true) + { + await StopAsync().ConfigureAwait(false); + if (await _stateLock.WaitAsync(0).ConfigureAwait(false)) + break; + } + } + + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _combinedCancelToken?.Dispose(); + _reconnectCancelToken?.Dispose(); + _connectionCancelToken?.Dispose(); + } + + _isDisposed = true; + } + } + + public void Dispose() + { + Dispose(true); + } + } +} diff --git a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj index 74abf5c..cbabc1f 100644 --- a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj +++ b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj @@ -1,10 +1,22 @@ - + + - Exe - net6.0 - enable - enable + Discord.Net.WebSocket + Discord.WebSocket + A core Discord.Net library containing the WebSocket client and models. + net6.0;net5.0;net461;netstandard2.0;netstandard2.1 + true + 5 + True + false + false - + + + + + + + diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.Events.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.Events.cs new file mode 100644 index 0000000..f01e929 --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.Events.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + public partial class DiscordShardedClient + { + #region General + /// Fired when a shard is connected to the Discord gateway. + public event Func ShardConnected + { + add { _shardConnectedEvent.Add(value); } + remove { _shardConnectedEvent.Remove(value); } + } + private readonly AsyncEvent> _shardConnectedEvent = new AsyncEvent>(); + /// Fired when a shard is disconnected from the Discord gateway. + public event Func ShardDisconnected + { + add { _shardDisconnectedEvent.Add(value); } + remove { _shardDisconnectedEvent.Remove(value); } + } + private readonly AsyncEvent> _shardDisconnectedEvent = new AsyncEvent>(); + /// Fired when a guild data for a shard has finished downloading. + public event Func ShardReady + { + add { _shardReadyEvent.Add(value); } + remove { _shardReadyEvent.Remove(value); } + } + private readonly AsyncEvent> _shardReadyEvent = new AsyncEvent>(); + /// Fired when a shard receives a heartbeat from the Discord gateway. + public event Func ShardLatencyUpdated + { + add { _shardLatencyUpdatedEvent.Add(value); } + remove { _shardLatencyUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _shardLatencyUpdatedEvent = new AsyncEvent>(); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs new file mode 100644 index 0000000..1c415cd --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -0,0 +1,677 @@ +using Discord.API; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + public partial class DiscordShardedClient : BaseSocketClient, IDiscordClient + { + #region DiscordShardedClient + private readonly DiscordSocketConfig _baseConfig; + private readonly Dictionary _shardIdsToIndex; + private readonly bool _automaticShards; + private int[] _shardIds; + private DiscordSocketClient[] _shards; + private ImmutableArray> _defaultStickers; + private int _totalShards; + private SemaphoreSlim[] _identifySemaphores; + private object _semaphoreResetLock; + private Task _semaphoreResetTask; + + private bool _isDisposed; + + /// + public override int Latency { get => GetLatency(); protected set { } } + /// + public override UserStatus Status { get => _shards[0].Status; protected set { } } + /// + public override IActivity Activity { get => _shards[0].Activity; protected set { } } + + internal new DiscordSocketApiClient ApiClient + { + get + { + if (base.ApiClient.CurrentUserId == null) + base.ApiClient.CurrentUserId = CurrentUser?.Id; + + return base.ApiClient; + } + } + /// + public override IReadOnlyCollection> DefaultStickerPacks + => _defaultStickers.ToReadOnlyCollection(); + + /// + public override IReadOnlyCollection Guilds => GetGuilds().ToReadOnlyCollection(GetGuildCount); + /// + public override IReadOnlyCollection PrivateChannels => GetPrivateChannels().ToReadOnlyCollection(GetPrivateChannelCount); + public IReadOnlyCollection Shards => _shards; + + /// + /// Provides access to a REST-only client with a shared state from this client. + /// + public override DiscordSocketRestClient Rest => _shards?[0].Rest; + + public override SocketSelfUser CurrentUser { get => _shards?.FirstOrDefault(x => x.CurrentUser != null)?.CurrentUser; protected set => throw new InvalidOperationException(); } + + /// Creates a new REST/WebSocket Discord client. + public DiscordShardedClient() : this(null, new DiscordSocketConfig()) { } + /// Creates a new REST/WebSocket Discord client. +#pragma warning disable IDISP004 + public DiscordShardedClient(DiscordSocketConfig config) : this(null, config, CreateApiClient(config)) { } +#pragma warning restore IDISP004 + /// Creates a new REST/WebSocket Discord client. + public DiscordShardedClient(int[] ids) : this(ids, new DiscordSocketConfig()) { } + /// Creates a new REST/WebSocket Discord client. +#pragma warning disable IDISP004 + public DiscordShardedClient(int[] ids, DiscordSocketConfig config) : this(ids, config, CreateApiClient(config)) { } +#pragma warning restore IDISP004 + private DiscordShardedClient(int[] ids, DiscordSocketConfig config, API.DiscordSocketApiClient client) + : base(config, client) + { + if (config.ShardId != null) + throw new ArgumentException($"{nameof(config.ShardId)} must not be set."); + if (ids != null && config.TotalShards == null) + throw new ArgumentException($"Custom ids are not supported when {nameof(config.TotalShards)} is not specified."); + + _semaphoreResetLock = new object(); + _shardIdsToIndex = new Dictionary(); + config.DisplayInitialLog = false; + _baseConfig = config; + _defaultStickers = ImmutableArray.Create>(); + + if (config.TotalShards == null) + _automaticShards = true; + else + { + _totalShards = config.TotalShards.Value; + _shardIds = ids ?? Enumerable.Range(0, _totalShards).ToArray(); + _shards = new DiscordSocketClient[_shardIds.Length]; + _identifySemaphores = new SemaphoreSlim[config.IdentifyMaxConcurrency]; + for (int i = 0; i < config.IdentifyMaxConcurrency; i++) + _identifySemaphores[i] = new SemaphoreSlim(1, 1); + for (int i = 0; i < _shardIds.Length; i++) + { + _shardIdsToIndex.Add(_shardIds[i], i); + var newConfig = config.Clone(); + newConfig.ShardId = _shardIds[i]; + _shards[i] = new DiscordSocketClient(newConfig, this, i != 0 ? _shards[0] : null); + RegisterEvents(_shards[i], i == 0); + } + } + } + private static API.DiscordSocketApiClient CreateApiClient(DiscordSocketConfig config) + => new DiscordSocketApiClient(config.RestClientProvider, config.WebSocketProvider, DiscordRestConfig.UserAgent, config.GatewayHost, + useSystemClock: config.UseSystemClock, defaultRatelimitCallback: config.DefaultRatelimitCallback); + + internal Task AcquireIdentifyLockAsync(int shardId, CancellationToken token) + { + int semaphoreIdx = shardId % _baseConfig.IdentifyMaxConcurrency; + return _identifySemaphores[semaphoreIdx].WaitAsync(token); + } + + internal void ReleaseIdentifyLock() + { + lock (_semaphoreResetLock) + { + if (_semaphoreResetTask == null) + _semaphoreResetTask = ResetSemaphoresAsync(); + } + } + + private async Task ResetSemaphoresAsync() + { + await Task.Delay(5000).ConfigureAwait(false); + lock (_semaphoreResetLock) + { + foreach (var semaphore in _identifySemaphores) + if (semaphore.CurrentCount == 0) + semaphore.Release(); + _semaphoreResetTask = null; + } + } + + internal override async Task OnLoginAsync(TokenType tokenType, string token) + { + var botGateway = await GetBotGatewayAsync().ConfigureAwait(false); + if (_automaticShards) + { + _shardIds = Enumerable.Range(0, botGateway.Shards).ToArray(); + _totalShards = _shardIds.Length; + _shards = new DiscordSocketClient[_shardIds.Length]; + int maxConcurrency = botGateway.SessionStartLimit.MaxConcurrency; + _baseConfig.IdentifyMaxConcurrency = maxConcurrency; + _identifySemaphores = new SemaphoreSlim[maxConcurrency]; + for (int i = 0; i < maxConcurrency; i++) + _identifySemaphores[i] = new SemaphoreSlim(1, 1); + for (int i = 0; i < _shardIds.Length; i++) + { + _shardIdsToIndex.Add(_shardIds[i], i); + var newConfig = _baseConfig.Clone(); + newConfig.ShardId = _shardIds[i]; + newConfig.TotalShards = _totalShards; + _shards[i] = new DiscordSocketClient(newConfig, this, i != 0 ? _shards[0] : null); + RegisterEvents(_shards[i], i == 0); + } + } + + //Assume thread safe: already in a connection lock + for (int i = 0; i < _shards.Length; i++) + { + // Set the gateway URL to the one returned by Discord, if a custom one isn't set. + _shards[i].ApiClient.GatewayUrl = botGateway.Url; + + await _shards[i].LoginAsync(tokenType, token); + } + + if (_defaultStickers.Length == 0 && _baseConfig.AlwaysDownloadDefaultStickers) + await DownloadDefaultStickersAsync().ConfigureAwait(false); + + } + internal override async Task OnLogoutAsync() + { + //Assume thread safe: already in a connection lock + if (_shards != null) + { + for (int i = 0; i < _shards.Length; i++) + { + // Reset the gateway URL set for the shard. + _shards[i].ApiClient.GatewayUrl = null; + + await _shards[i].LogoutAsync(); + } + } + + if (_automaticShards) + { + _shardIds = new int[0]; + _shardIdsToIndex.Clear(); + _totalShards = 0; + _shards = null; + } + } + + /// + public override Task StartAsync() + => Task.WhenAll(_shards.Select(x => x.StartAsync())); + + /// + public override Task StopAsync() + => Task.WhenAll(_shards.Select(x => x.StopAsync())); + + public DiscordSocketClient GetShard(int id) + { + if (_shardIdsToIndex.TryGetValue(id, out id)) + return _shards[id]; + return null; + } + private int GetShardIdFor(ulong guildId) + => (int)((guildId >> 22) % (uint)_totalShards); + public int GetShardIdFor(IGuild guild) + => GetShardIdFor(guild?.Id ?? 0); + private DiscordSocketClient GetShardFor(ulong guildId) + => GetShard(GetShardIdFor(guildId)); + public DiscordSocketClient GetShardFor(IGuild guild) + => GetShardFor(guild?.Id ?? 0); + + /// + public override Task GetApplicationInfoAsync(RequestOptions options = null) + => _shards[0].GetApplicationInfoAsync(options); + + /// + public override SocketGuild GetGuild(ulong id) + => GetShardFor(id).GetGuild(id); + + /// + public override SocketChannel GetChannel(ulong id) + { + for (int i = 0; i < _shards.Length; i++) + { + var channel = _shards[i].GetChannel(id); + if (channel != null) + return channel; + } + return null; + } + private IEnumerable GetPrivateChannels() + { + for (int i = 0; i < _shards.Length; i++) + { + foreach (var channel in _shards[i].PrivateChannels) + yield return channel; + } + } + private int GetPrivateChannelCount() + { + int result = 0; + for (int i = 0; i < _shards.Length; i++) + result += _shards[i].PrivateChannels.Count; + return result; + } + + private IEnumerable GetGuilds() + { + for (int i = 0; i < _shards.Length; i++) + { + foreach (var guild in _shards[i].Guilds) + yield return guild; + } + } + private int GetGuildCount() + { + int result = 0; + for (int i = 0; i < _shards.Length; i++) + result += _shards[i].Guilds.Count; + return result; + } + /// + public override async Task GetStickerAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + var sticker = _defaultStickers.FirstOrDefault(x => x.Stickers.Any(y => y.Id == id))?.Stickers.FirstOrDefault(x => x.Id == id); + + if (sticker != null) + return sticker; + + foreach (var guild in Guilds) + { + sticker = await guild.GetStickerAsync(id, CacheMode.CacheOnly).ConfigureAwait(false); + + if (sticker != null) + return sticker; + } + + if (mode == CacheMode.CacheOnly) + return null; + + var model = await ApiClient.GetStickerAsync(id, options).ConfigureAwait(false); + + if (model == null) + return null; + + + if (model.GuildId.IsSpecified) + { + var guild = GetGuild(model.GuildId.Value); + sticker = guild.AddOrUpdateSticker(model); + return sticker; + } + else + { + return SocketSticker.Create(_shards[0], model); + } + } + private async Task DownloadDefaultStickersAsync() + { + var models = await ApiClient.ListNitroStickerPacksAsync().ConfigureAwait(false); + + var builder = ImmutableArray.CreateBuilder>(); + + foreach (var model in models.StickerPacks) + { + var stickers = model.Stickers.Select(x => SocketSticker.Create(_shards[0], x)); + + var pack = new StickerPack( + model.Name, + model.Id, + model.SkuId, + model.CoverStickerId.ToNullable(), + model.Description, + model.BannerAssetId, + stickers + ); + + builder.Add(pack); + } + + _defaultStickers = builder.ToImmutable(); + } + + /// + public override SocketUser GetUser(ulong id) + { + for (int i = 0; i < _shards.Length; i++) + { + var user = _shards[i].GetUser(id); + if (user != null) + return user; + } + return null; + } + /// + public override SocketUser GetUser(string username, string discriminator = null) + { + for (int i = 0; i < _shards.Length; i++) + { + var user = _shards[i].GetUser(username, discriminator); + if (user != null) + return user; + } + return null; + } + + /// + public override ValueTask> GetVoiceRegionsAsync(RequestOptions options = null) + => _shards[0].GetVoiceRegionsAsync(); + + /// + public override ValueTask GetVoiceRegionAsync(string id, RequestOptions options = null) + => _shards[0].GetVoiceRegionAsync(id, options); + + /// + /// is + public override async Task DownloadUsersAsync(IEnumerable guilds) + { + if (guilds == null) + throw new ArgumentNullException(nameof(guilds)); + for (int i = 0; i < _shards.Length; i++) + { + int id = _shardIds[i]; + var arr = guilds.Where(x => GetShardIdFor(x) == id).ToArray(); + if (arr.Length > 0) + await _shards[i].DownloadUsersAsync(arr).ConfigureAwait(false); + } + } + + private int GetLatency() + { + int total = 0; + for (int i = 0; i < _shards.Length; i++) + total += _shards[i].Latency; + return (int)Math.Round(total / (double)_shards.Length); + } + + /// + public override async Task SetStatusAsync(UserStatus status) + { + for (int i = 0; i < _shards.Length; i++) + await _shards[i].SetStatusAsync(status).ConfigureAwait(false); + } + /// + public override Task SetGameAsync(string name, string streamUrl = null, ActivityType type = ActivityType.Playing) + { + IActivity activity = null; + if (!string.IsNullOrEmpty(streamUrl)) + activity = new StreamingGame(name, streamUrl); + else if (!string.IsNullOrEmpty(name)) + activity = new Game(name, type); + return SetActivityAsync(activity); + } + /// + public override async Task SetActivityAsync(IActivity activity) + { + for (int i = 0; i < _shards.Length; i++) + await _shards[i].SetActivityAsync(activity).ConfigureAwait(false); + } + + /// + public override async Task SetCustomStatusAsync(string status) + { + var statusGame = new CustomStatusGame(status); + for (int i = 0; i < _shards.Length; i++) + await _shards[i].SetActivityAsync(statusGame).ConfigureAwait(false); + } + + private void RegisterEvents(DiscordSocketClient client, bool isPrimary) + { + client.Log += (msg) => _logEvent.InvokeAsync(msg); + client.LoggedOut += () => + { + var state = LoginState; + if (state == LoginState.LoggedIn || state == LoginState.LoggingIn) + { + //Should only happen if token is changed + var _ = LogoutAsync(); //Signal the logout, fire and forget + } + return Task.Delay(0); + }; + + client.SentRequest += (method, endpoint, millis) => _sentRequest.InvokeAsync(method, endpoint, millis); + + client.Connected += () => _shardConnectedEvent.InvokeAsync(client); + client.Disconnected += (exception) => _shardDisconnectedEvent.InvokeAsync(exception, client); + client.Ready += () => _shardReadyEvent.InvokeAsync(client); + client.LatencyUpdated += (oldLatency, newLatency) => _shardLatencyUpdatedEvent.InvokeAsync(oldLatency, newLatency, client); + + client.ChannelCreated += (channel) => _channelCreatedEvent.InvokeAsync(channel); + client.ChannelDestroyed += (channel) => _channelDestroyedEvent.InvokeAsync(channel); + client.ChannelUpdated += (oldChannel, newChannel) => _channelUpdatedEvent.InvokeAsync(oldChannel, newChannel); + + client.MessageReceived += (msg) => _messageReceivedEvent.InvokeAsync(msg); + client.MessageDeleted += (cache, channel) => _messageDeletedEvent.InvokeAsync(cache, channel); + client.MessagesBulkDeleted += (cache, channel) => _messagesBulkDeletedEvent.InvokeAsync(cache, channel); + client.MessageUpdated += (oldMsg, newMsg, channel) => _messageUpdatedEvent.InvokeAsync(oldMsg, newMsg, channel); + client.ReactionAdded += (cache, channel, reaction) => _reactionAddedEvent.InvokeAsync(cache, channel, reaction); + client.ReactionRemoved += (cache, channel, reaction) => _reactionRemovedEvent.InvokeAsync(cache, channel, reaction); + client.ReactionsCleared += (cache, channel) => _reactionsClearedEvent.InvokeAsync(cache, channel); + client.ReactionsRemovedForEmote += (cache, channel, emote) => _reactionsRemovedForEmoteEvent.InvokeAsync(cache, channel, emote); + + client.RoleCreated += (role) => _roleCreatedEvent.InvokeAsync(role); + client.RoleDeleted += (role) => _roleDeletedEvent.InvokeAsync(role); + client.RoleUpdated += (oldRole, newRole) => _roleUpdatedEvent.InvokeAsync(oldRole, newRole); + + client.JoinedGuild += (guild) => _joinedGuildEvent.InvokeAsync(guild); + client.LeftGuild += (guild) => _leftGuildEvent.InvokeAsync(guild); + client.GuildAvailable += (guild) => _guildAvailableEvent.InvokeAsync(guild); + client.GuildUnavailable += (guild) => _guildUnavailableEvent.InvokeAsync(guild); + client.GuildMembersDownloaded += (guild) => _guildMembersDownloadedEvent.InvokeAsync(guild); + client.GuildUpdated += (oldGuild, newGuild) => _guildUpdatedEvent.InvokeAsync(oldGuild, newGuild); + + client.UserJoined += (user) => _userJoinedEvent.InvokeAsync(user); + client.UserLeft += (guild, user) => _userLeftEvent.InvokeAsync(guild, user); + client.UserBanned += (user, guild) => _userBannedEvent.InvokeAsync(user, guild); + client.UserUnbanned += (user, guild) => _userUnbannedEvent.InvokeAsync(user, guild); + client.UserUpdated += (oldUser, newUser) => _userUpdatedEvent.InvokeAsync(oldUser, newUser); + client.PresenceUpdated += (user, oldPresence, newPresence) => _presenceUpdated.InvokeAsync(user, oldPresence, newPresence); + client.GuildMemberUpdated += (oldUser, newUser) => _guildMemberUpdatedEvent.InvokeAsync(oldUser, newUser); + client.UserVoiceStateUpdated += (user, oldVoiceState, newVoiceState) => _userVoiceStateUpdatedEvent.InvokeAsync(user, oldVoiceState, newVoiceState); + client.VoiceServerUpdated += (server) => _voiceServerUpdatedEvent.InvokeAsync(server); + client.CurrentUserUpdated += (oldUser, newUser) => _selfUpdatedEvent.InvokeAsync(oldUser, newUser); + client.UserIsTyping += (oldUser, newUser) => _userIsTypingEvent.InvokeAsync(oldUser, newUser); + client.RecipientAdded += (user) => _recipientAddedEvent.InvokeAsync(user); + client.RecipientRemoved += (user) => _recipientRemovedEvent.InvokeAsync(user); + + client.InviteCreated += (invite) => _inviteCreatedEvent.InvokeAsync(invite); + client.InviteDeleted += (channel, invite) => _inviteDeletedEvent.InvokeAsync(channel, invite); + + client.InteractionCreated += (interaction) => _interactionCreatedEvent.InvokeAsync(interaction); + client.ButtonExecuted += (arg) => _buttonExecuted.InvokeAsync(arg); + client.SelectMenuExecuted += (arg) => _selectMenuExecuted.InvokeAsync(arg); + client.SlashCommandExecuted += (arg) => _slashCommandExecuted.InvokeAsync(arg); + client.UserCommandExecuted += (arg) => _userCommandExecuted.InvokeAsync(arg); + client.MessageCommandExecuted += (arg) => _messageCommandExecuted.InvokeAsync(arg); + client.AutocompleteExecuted += (arg) => _autocompleteExecuted.InvokeAsync(arg); + client.ModalSubmitted += (arg) => _modalSubmitted.InvokeAsync(arg); + + client.ThreadUpdated += (thread1, thread2) => _threadUpdated.InvokeAsync(thread1, thread2); + client.ThreadCreated += (thread) => _threadCreated.InvokeAsync(thread); + client.ThreadDeleted += (thread) => _threadDeleted.InvokeAsync(thread); + + client.ThreadMemberJoined += (user) => _threadMemberJoined.InvokeAsync(user); + client.ThreadMemberLeft += (user) => _threadMemberLeft.InvokeAsync(user); + client.StageEnded += (stage) => _stageEnded.InvokeAsync(stage); + client.StageStarted += (stage) => _stageStarted.InvokeAsync(stage); + client.StageUpdated += (stage1, stage2) => _stageUpdated.InvokeAsync(stage1, stage2); + + client.RequestToSpeak += (stage, user) => _requestToSpeak.InvokeAsync(stage, user); + client.SpeakerAdded += (stage, user) => _speakerAdded.InvokeAsync(stage, user); + client.SpeakerRemoved += (stage, user) => _speakerRemoved.InvokeAsync(stage, user); + + client.GuildStickerCreated += (sticker) => _guildStickerCreated.InvokeAsync(sticker); + client.GuildStickerDeleted += (sticker) => _guildStickerDeleted.InvokeAsync(sticker); + client.GuildStickerUpdated += (before, after) => _guildStickerUpdated.InvokeAsync(before, after); + client.GuildJoinRequestDeleted += (userId, guildId) => _guildJoinRequestDeletedEvent.InvokeAsync(userId, guildId); + + client.GuildScheduledEventCancelled += (arg) => _guildScheduledEventCancelled.InvokeAsync(arg); + client.GuildScheduledEventCompleted += (arg) => _guildScheduledEventCompleted.InvokeAsync(arg); + client.GuildScheduledEventCreated += (arg) => _guildScheduledEventCreated.InvokeAsync(arg); + client.GuildScheduledEventUpdated += (arg1, arg2) => _guildScheduledEventUpdated.InvokeAsync(arg1, arg2); + client.GuildScheduledEventStarted += (arg) => _guildScheduledEventStarted.InvokeAsync(arg); + client.GuildScheduledEventUserAdd += (arg1, arg2) => _guildScheduledEventUserAdd.InvokeAsync(arg1, arg2); + client.GuildScheduledEventUserRemove += (arg1, arg2) => _guildScheduledEventUserRemove.InvokeAsync(arg1, arg2); + + client.WebhooksUpdated += (arg1, arg2) => _webhooksUpdated.InvokeAsync(arg1, arg2); + client.AuditLogCreated += (arg1, arg2) => _auditLogCreated.InvokeAsync(arg1, arg2); + + client.VoiceChannelStatusUpdated += (arg1, arg2, arg3) => _voiceChannelStatusUpdated.InvokeAsync(arg1, arg2, arg3); + + client.EntitlementCreated += (arg1) => _entitlementCreated.InvokeAsync(arg1); + client.EntitlementUpdated += (arg1, arg2) => _entitlementUpdated.InvokeAsync(arg1, arg2); + client.EntitlementDeleted += (arg1) => _entitlementDeleted.InvokeAsync(arg1); + } + + public async Task CreateGlobalApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options = null) + { + var model = await InteractionHelper.CreateGlobalCommandAsync(this, properties, options).ConfigureAwait(false); + + SocketApplicationCommand entity = null; + + foreach (var shard in _shards) + { + entity = shard.State.GetOrAddCommand(model.Id, (id) => SocketApplicationCommand.Create(shard, model)); + + //Update it in case it was cached + entity.Update(model); + } + + System.Diagnostics.Debug.Assert(entity != null, "There should be at least one shard to get the entity"); + + return entity; + } + + public async Task> BulkOverwriteGlobalApplicationCommandsAsync( + ApplicationCommandProperties[] properties, RequestOptions options = null) + { + var models = await InteractionHelper.BulkOverwriteGlobalCommandsAsync(this, properties, options); + + IEnumerable entities = null; + + foreach (var shard in _shards) + { + entities = models.Select(x => SocketApplicationCommand.Create(shard, x)); + //Purge our previous commands + shard.State.PurgeCommands(x => x.IsGlobalCommand); + + foreach (var entity in entities) + { + shard.State.AddCommand(entity); + } + } + + System.Diagnostics.Debug.Assert(entities != null, "There should be at least one shard to get the entities"); + return entities.ToImmutableArray(); + } + + #endregion + + #region IDiscordClient + /// + ISelfUser IDiscordClient.CurrentUser => CurrentUser; + + /// + async Task IDiscordClient.GetApplicationInfoAsync(RequestOptions options) + => await GetApplicationInfoAsync().ConfigureAwait(false); + + /// + Task IDiscordClient.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetChannel(id)); + /// + Task> IDiscordClient.GetPrivateChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(PrivateChannels); + + /// + async Task> IDiscordClient.GetConnectionsAsync(RequestOptions options) + => await GetConnectionsAsync().ConfigureAwait(false); + + /// + async Task IDiscordClient.GetInviteAsync(string inviteId, RequestOptions options) + => await GetInviteAsync(inviteId, options).ConfigureAwait(false); + + /// + Task IDiscordClient.GetGuildAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetGuild(id)); + /// + Task> IDiscordClient.GetGuildsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(Guilds); + /// + async Task IDiscordClient.CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon, RequestOptions options) + => await CreateGuildAsync(name, region, jpegIcon).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + var user = GetUser(id); + if (user is not null || mode == CacheMode.CacheOnly) + return user; + + return await Rest.GetUserAsync(id, options).ConfigureAwait(false); + } + + /// + Task IDiscordClient.GetUserAsync(string username, string discriminator, RequestOptions options) + => Task.FromResult(GetUser(username, discriminator)); + + /// + async Task> IDiscordClient.GetVoiceRegionsAsync(RequestOptions options) + { + return await GetVoiceRegionsAsync().ConfigureAwait(false); + } + /// + async Task IDiscordClient.GetVoiceRegionAsync(string id, RequestOptions options) + { + return await GetVoiceRegionAsync(id).ConfigureAwait(false); + } + /// + async Task IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options) + => await CreateGlobalApplicationCommandAsync(properties, options).ConfigureAwait(false); + /// + async Task> IDiscordClient.BulkOverwriteGlobalApplicationCommand(ApplicationCommandProperties[] properties, RequestOptions options) + => await BulkOverwriteGlobalApplicationCommandsAsync(properties, options); + #endregion + + #region Dispose + internal override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + if (_shards != null) + { + foreach (var client in _shards) + client?.Dispose(); + } + } + + _isDisposed = true; + } + + base.Dispose(disposing); + } + + internal override ValueTask DisposeAsync(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + if (_shards != null) + { + foreach (var client in _shards) + client?.Dispose(); + } + } + + _isDisposed = true; + } + + return base.DisposeAsync(disposing); + } + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs new file mode 100644 index 0000000..f33199b --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordSocketApiClient.cs @@ -0,0 +1,420 @@ +using Discord.API.Gateway; +using Discord.Net.Queue; +using Discord.Net.Rest; +using Discord.Net.WebSockets; +using Discord.WebSocket; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GameModel = Discord.API.Game; + +namespace Discord.API +{ + internal class DiscordSocketApiClient : DiscordRestApiClient + { + public event Func SentGatewayMessage { add { _sentGatewayMessageEvent.Add(value); } remove { _sentGatewayMessageEvent.Remove(value); } } + private readonly AsyncEvent> _sentGatewayMessageEvent = new AsyncEvent>(); + public event Func ReceivedGatewayEvent { add { _receivedGatewayEvent.Add(value); } remove { _receivedGatewayEvent.Remove(value); } } + private readonly AsyncEvent> _receivedGatewayEvent = new AsyncEvent>(); + + public event Func Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + + private readonly bool _isExplicitUrl; + private CancellationTokenSource _connectCancelToken; + private string _gatewayUrl; + private string _resumeGatewayUrl; + + //Store our decompression streams for zlib shared state + private MemoryStream _compressed; + private DeflateStream _decompressor; + + internal IWebSocketClient WebSocketClient { get; } + + public ConnectionState ConnectionState { get; private set; } + + /// + /// Sets the gateway URL used for identifies. + /// + /// + /// If a custom URL is set, setting this property does nothing. + /// + public string GatewayUrl + { + set + { + // Makes the sharded client not override the custom value. + if (_isExplicitUrl) + return; + + _gatewayUrl = FormatGatewayUrl(value); + } + } + + /// + /// Sets the gateway URL used for resumes. + /// + public string ResumeGatewayUrl + { + set => _resumeGatewayUrl = FormatGatewayUrl(value); + } + + public DiscordSocketApiClient(RestClientProvider restClientProvider, WebSocketProvider webSocketProvider, string userAgent, + string url = null, RetryMode defaultRetryMode = RetryMode.AlwaysRetry, JsonSerializer serializer = null, + bool useSystemClock = true, Func defaultRatelimitCallback = null) + : base(restClientProvider, userAgent, defaultRetryMode, serializer, useSystemClock, defaultRatelimitCallback) + { + _gatewayUrl = url; + if (url != null) + _isExplicitUrl = true; + WebSocketClient = webSocketProvider(); + //WebSocketClient.SetHeader("user-agent", DiscordConfig.UserAgent); (Causes issues in .NET Framework 4.6+) + + WebSocketClient.BinaryMessage += async (data, index, count) => + { + using (var decompressed = new MemoryStream()) + { + if (data[0] == 0x78) + { + //Strip the zlib header + _compressed.Write(data, index + 2, count - 2); + _compressed.SetLength(count - 2); + } + else + { + _compressed.Write(data, index, count); + _compressed.SetLength(count); + } + + //Reset positions so we don't run out of memory + _compressed.Position = 0; + _decompressor.CopyTo(decompressed); + _compressed.Position = 0; + decompressed.Position = 0; + + using (var reader = new StreamReader(decompressed)) + using (var jsonReader = new JsonTextReader(reader)) + { + var msg = _serializer.Deserialize(jsonReader); + + if (msg != null) + { +#if DEBUG_PACKETS + Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); +#endif + + await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); + } + } + } + }; + WebSocketClient.TextMessage += async text => + { + using (var reader = new StringReader(text)) + using (var jsonReader = new JsonTextReader(reader)) + { + var msg = _serializer.Deserialize(jsonReader); + if (msg != null) + { +#if DEBUG_PACKETS + Console.WriteLine($"<- {(GatewayOpCode)msg.Operation} [{msg.Type ?? "none"}] : {(msg.Payload as Newtonsoft.Json.Linq.JToken)}"); +#endif + + await _receivedGatewayEvent.InvokeAsync((GatewayOpCode)msg.Operation, msg.Sequence, msg.Type, msg.Payload).ConfigureAwait(false); + } + } + }; + WebSocketClient.Closed += async ex => + { +#if DEBUG_PACKETS + Console.WriteLine(ex); +#endif + + await DisconnectAsync().ConfigureAwait(false); + await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false); + }; + } + + internal override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _connectCancelToken?.Dispose(); + (WebSocketClient as IDisposable)?.Dispose(); + _decompressor?.Dispose(); + _compressed?.Dispose(); + } + } + + base.Dispose(disposing); + } + +#if NETSTANDARD2_1 + internal override async ValueTask DisposeAsync(bool disposing) +#else + internal override ValueTask DisposeAsync(bool disposing) +#endif + { + if (!_isDisposed) + { + if (disposing) + { + _connectCancelToken?.Dispose(); + (WebSocketClient as IDisposable)?.Dispose(); +#if NETSTANDARD2_1 + if (!(_decompressor is null)) + await _decompressor.DisposeAsync().ConfigureAwait(false); +#else + _decompressor?.Dispose(); +#endif + } + } + +#if NETSTANDARD2_1 + await base.DisposeAsync(disposing).ConfigureAwait(false); +#else + return base.DisposeAsync(disposing); +#endif + } + + /// + /// Appends necessary query parameters to the specified gateway URL. + /// + private static string FormatGatewayUrl(string gatewayUrl) + { + if (gatewayUrl == null) + return null; + + return $"{gatewayUrl}?v={DiscordConfig.APIVersion}&encoding={DiscordSocketConfig.GatewayEncoding}&compress=zlib-stream"; + } + + public async Task ConnectAsync() + { + await _stateLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync().ConfigureAwait(false); + } + finally { _stateLock.Release(); } + } + /// The client must be logged in before connecting. + /// This client is not configured with WebSocket support. + internal override async Task ConnectInternalAsync() + { + if (LoginState != LoginState.LoggedIn) + throw new InvalidOperationException("The client must be logged in before connecting."); + if (WebSocketClient == null) + throw new NotSupportedException("This client is not configured with WebSocket support."); + + RequestQueue.ClearGatewayBuckets(); + + //Re-create streams to reset the zlib state + _compressed?.Dispose(); + _decompressor?.Dispose(); + _compressed = new MemoryStream(); + _decompressor = new DeflateStream(_compressed, CompressionMode.Decompress); + + ConnectionState = ConnectionState.Connecting; + try + { + _connectCancelToken?.Dispose(); + _connectCancelToken = new CancellationTokenSource(); + if (WebSocketClient != null) + WebSocketClient.SetCancelToken(_connectCancelToken.Token); + + string gatewayUrl; + if (_resumeGatewayUrl == null) + { + if (!_isExplicitUrl && _gatewayUrl == null) + { + var gatewayResponse = await GetBotGatewayAsync().ConfigureAwait(false); + _gatewayUrl = FormatGatewayUrl(gatewayResponse.Url); + } + + gatewayUrl = _gatewayUrl; + } + else + { + gatewayUrl = _resumeGatewayUrl; + } + +#if DEBUG_PACKETS + Console.WriteLine("Connecting to gateway: " + gatewayUrl); +#endif + + await WebSocketClient.ConnectAsync(gatewayUrl).ConfigureAwait(false); + + ConnectionState = ConnectionState.Connected; + } + catch + { + await DisconnectInternalAsync().ConfigureAwait(false); + throw; + } + } + + public async Task DisconnectAsync(Exception ex = null) + { + await _stateLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(ex).ConfigureAwait(false); + } + finally { _stateLock.Release(); } + } + /// This client is not configured with WebSocket support. + internal override async Task DisconnectInternalAsync(Exception ex = null) + { + if (WebSocketClient == null) + throw new NotSupportedException("This client is not configured with WebSocket support."); + + if (ConnectionState == ConnectionState.Disconnected) + return; + ConnectionState = ConnectionState.Disconnecting; + + if (ex is GatewayReconnectException) + await WebSocketClient.DisconnectAsync(4000).ConfigureAwait(false); + else + await WebSocketClient.DisconnectAsync().ConfigureAwait(false); + + try + { + _connectCancelToken?.Cancel(false); + } + catch { } + + ConnectionState = ConnectionState.Disconnected; + } + + #region Core + public Task SendGatewayAsync(GatewayOpCode opCode, object payload, RequestOptions options = null) + => SendGatewayInternalAsync(opCode, payload, options); + private async Task SendGatewayInternalAsync(GatewayOpCode opCode, object payload, RequestOptions options) + { + CheckState(); + + //TODO: Add ETF + byte[] bytes = null; + payload = new SocketFrame { Operation = (int)opCode, Payload = payload }; + if (payload != null) + bytes = Encoding.UTF8.GetBytes(SerializeJson(payload)); + + options.IsGatewayBucket = true; + if (options.BucketId == null) + options.BucketId = GatewayBucket.Get(GatewayBucketType.Unbucketed).Id; + await RequestQueue.SendAsync(new WebSocketRequest(WebSocketClient, bytes, true, opCode == GatewayOpCode.Heartbeat, options)).ConfigureAwait(false); + await _sentGatewayMessageEvent.InvokeAsync(opCode).ConfigureAwait(false); + +#if DEBUG_PACKETS + Console.WriteLine($"-> {opCode}:\n{SerializeJson(payload)}"); +#endif + } + + public Task SendIdentifyAsync(int largeThreshold = 100, int shardID = 0, int totalShards = 1, GatewayIntents gatewayIntents = GatewayIntents.AllUnprivileged, (UserStatus, bool, long?, GameModel)? presence = null, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + var props = new Dictionary + { + ["$device"] = "Discord.Net", + ["$os"] = Environment.OSVersion.Platform.ToString(), + ["$browser"] = "Discord.Net" + }; + var msg = new IdentifyParams() + { + Token = AuthToken, + Properties = props, + LargeThreshold = largeThreshold + }; + if (totalShards > 1) + msg.ShardingParams = new int[] { shardID, totalShards }; + + options.BucketId = GatewayBucket.Get(GatewayBucketType.Identify).Id; + + msg.Intents = (int)gatewayIntents; + + if (presence.HasValue) + { + msg.Presence = new PresenceUpdateParams + { + Status = presence.Value.Item1, + IsAFK = presence.Value.Item2, + IdleSince = presence.Value.Item3, + Activities = new object[] { presence.Value.Item4 } + }; + } + + return SendGatewayAsync(GatewayOpCode.Identify, msg, options: options); + } + + public Task SendResumeAsync(string sessionId, int lastSeq, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + var msg = new ResumeParams() + { + Token = AuthToken, + SessionId = sessionId, + Sequence = lastSeq + }; + return SendGatewayAsync(GatewayOpCode.Resume, msg, options: options); + } + + public Task SendHeartbeatAsync(int lastSeq, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendGatewayAsync(GatewayOpCode.Heartbeat, lastSeq, options: options); + } + + public Task SendPresenceUpdateAsync(UserStatus status, bool isAFK, long? since, GameModel game, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + var args = new PresenceUpdateParams + { + Status = status, + IdleSince = since, + IsAFK = isAFK, + Activities = new object[] { game } + }; + options.BucketId = GatewayBucket.Get(GatewayBucketType.PresenceUpdate).Id; + return SendGatewayAsync(GatewayOpCode.PresenceUpdate, args, options: options); + } + + public Task SendRequestMembersAsync(IEnumerable guildIds, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendGatewayAsync(GatewayOpCode.RequestGuildMembers, new RequestMembersParams { GuildIds = guildIds, Query = "", Limit = 0 }, options: options); + } + + public Task SendVoiceStateUpdateAsync(ulong guildId, ulong? channelId, bool selfDeaf, bool selfMute, RequestOptions options = null) + { + var payload = new VoiceStateUpdateParams + { + GuildId = guildId, + ChannelId = channelId, + SelfDeaf = selfDeaf, + SelfMute = selfMute + }; + options = RequestOptions.CreateOrClone(options); + return SendGatewayAsync(GatewayOpCode.VoiceStateUpdate, payload, options: options); + } + + public Task SendVoiceStateUpdateAsync(VoiceStateUpdateParams payload, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendGatewayAsync(GatewayOpCode.VoiceStateUpdate, payload, options: options); + } + + public Task SendGuildSyncAsync(IEnumerable guildIds, RequestOptions options = null) + { + options = RequestOptions.CreateOrClone(options); + return SendGatewayAsync(GatewayOpCode.GuildSync, guildIds, options: options); + } + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.Events.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.Events.cs new file mode 100644 index 0000000..68a7242 --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.Events.cs @@ -0,0 +1,50 @@ +using Discord.API; +using System; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + public partial class DiscordSocketClient + { + #region General + /// Fired when connected to the Discord gateway. + public event Func Connected + { + add { _connectedEvent.Add(value); } + remove { _connectedEvent.Remove(value); } + } + private readonly AsyncEvent> _connectedEvent = new AsyncEvent>(); + /// Fired when disconnected to the Discord gateway. + public event Func Disconnected + { + add { _disconnectedEvent.Add(value); } + remove { _disconnectedEvent.Remove(value); } + } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + /// + /// Fired when guild data has finished downloading. + /// + /// + /// It is possible that some guilds might be unsynced if + /// was not long enough to receive all GUILD_AVAILABLEs before READY. + /// + public event Func Ready + { + add { _readyEvent.Add(value); } + remove { _readyEvent.Remove(value); } + } + private readonly AsyncEvent> _readyEvent = new AsyncEvent>(); + /// Fired when a heartbeat is received from the Discord gateway. + public event Func LatencyUpdated + { + add { _latencyUpdatedEvent.Add(value); } + remove { _latencyUpdatedEvent.Remove(value); } + } + private readonly AsyncEvent> _latencyUpdatedEvent = new AsyncEvent>(); + + internal DiscordSocketClient(DiscordSocketConfig config, DiscordRestApiClient client) : base(config, client) + { + } + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs new file mode 100644 index 0000000..2001ae0 --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -0,0 +1,3617 @@ +using Discord.API; +using Discord.API.Gateway; +using Discord.Logging; +using Discord.Net.Converters; +using Discord.Net.Udp; +using Discord.Net.WebSockets; +using Discord.Rest; +using Discord.Utils; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using GameModel = Discord.API.Game; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based Discord client. + /// + public partial class DiscordSocketClient : BaseSocketClient, IDiscordClient + { + #region DiscordSocketClient + private readonly ConcurrentQueue _largeGuilds; + internal readonly JsonSerializer _serializer; + private readonly DiscordShardedClient _shardedClient; + private readonly DiscordSocketClient _parentClient; + private readonly ConcurrentQueue _heartbeatTimes; + private readonly ConnectionManager _connection; + private readonly Logger _gatewayLogger; + private readonly SemaphoreSlim _stateLock; + + private string _sessionId; + private int _lastSeq; + private ImmutableDictionary _voiceRegions; + private Task _heartbeatTask, _guildDownloadTask; + private int _unavailableGuildCount; + private long _lastGuildAvailableTime, _lastMessageTime; + private int _nextAudioId; + private DateTimeOffset? _statusSince; + private RestApplication _applicationInfo; + private bool _isDisposed; + private GatewayIntents _gatewayIntents; + private ImmutableArray> _defaultStickers; + private SocketSelfUser _previousSessionUser; + + /// + /// Provides access to a REST-only client with a shared state from this client. + /// + public override DiscordSocketRestClient Rest { get; } + /// Gets the shard of this client. + public int ShardId { get; } + /// + public override ConnectionState ConnectionState => _connection.State; + /// + public override int Latency { get; protected set; } + /// + public override UserStatus Status { get => _status ?? UserStatus.Online; protected set => _status = value; } + private UserStatus? _status; + /// + public override IActivity Activity { get => _activity.GetValueOrDefault(); protected set => _activity = Optional.Create(value); } + private Optional _activity; + #endregion + + // From DiscordSocketConfig + internal int TotalShards { get; private set; } + internal int MessageCacheSize { get; private set; } + internal int LargeThreshold { get; private set; } + internal ClientState State { get; private set; } + internal UdpSocketProvider UdpSocketProvider { get; private set; } + internal WebSocketProvider WebSocketProvider { get; private set; } + internal bool AlwaysDownloadUsers { get; private set; } + internal int? HandlerTimeout { get; private set; } + internal bool AlwaysDownloadDefaultStickers { get; private set; } + internal bool AlwaysResolveStickers { get; private set; } + internal bool LogGatewayIntentWarnings { get; private set; } + internal bool SuppressUnknownDispatchWarnings { get; private set; } + internal int AuditLogCacheSize { get; private set; } + + internal new DiscordSocketApiClient ApiClient => base.ApiClient; + /// + public override IReadOnlyCollection Guilds => State.Guilds; + /// + public override IReadOnlyCollection> DefaultStickerPacks + { + get + { + if (_shardedClient != null) + return _shardedClient.DefaultStickerPacks; + else + return _defaultStickers.ToReadOnlyCollection(); + } + } + /// + public override IReadOnlyCollection PrivateChannels => State.PrivateChannels; + /// + /// Gets a collection of direct message channels opened in this session. + /// + /// + /// This method returns a collection of currently opened direct message channels. + /// + /// This method will not return previously opened DM channels outside of the current session! If you + /// have just started the client, this may return an empty collection. + /// + /// + /// + /// A collection of DM channels that have been opened in this session. + /// + public IReadOnlyCollection DMChannels + => State.PrivateChannels.OfType().ToImmutableArray(); + /// + /// Gets a collection of group channels opened in this session. + /// + /// + /// This method returns a collection of currently opened group channels. + /// + /// This method will not return previously opened group channels outside of the current session! If you + /// have just started the client, this may return an empty collection. + /// + /// + /// + /// A collection of group channels that have been opened in this session. + /// + public IReadOnlyCollection GroupChannels + => State.PrivateChannels.OfType().ToImmutableArray(); + + /// + /// Initializes a new REST/WebSocket-based Discord client. + /// + public DiscordSocketClient() : this(new DiscordSocketConfig()) { } + /// + /// Initializes a new REST/WebSocket-based Discord client with the provided configuration. + /// + /// The configuration to be used with the client. +#pragma warning disable IDISP004 + public DiscordSocketClient(DiscordSocketConfig config) : this(config, CreateApiClient(config), null, null) { } + internal DiscordSocketClient(DiscordSocketConfig config, DiscordShardedClient shardedClient, DiscordSocketClient parentClient) : this(config, CreateApiClient(config), shardedClient, parentClient) { } +#pragma warning restore IDISP004 + private DiscordSocketClient(DiscordSocketConfig config, API.DiscordSocketApiClient client, DiscordShardedClient shardedClient, DiscordSocketClient parentClient) + : base(config, client) + { + ShardId = config.ShardId ?? 0; + TotalShards = config.TotalShards ?? 1; + MessageCacheSize = config.MessageCacheSize; + LargeThreshold = config.LargeThreshold; + UdpSocketProvider = config.UdpSocketProvider; + WebSocketProvider = config.WebSocketProvider; + AlwaysDownloadUsers = config.AlwaysDownloadUsers; + AlwaysDownloadDefaultStickers = config.AlwaysDownloadDefaultStickers; + AlwaysResolveStickers = config.AlwaysResolveStickers; + LogGatewayIntentWarnings = config.LogGatewayIntentWarnings; + SuppressUnknownDispatchWarnings = config.SuppressUnknownDispatchWarnings; + HandlerTimeout = config.HandlerTimeout; + State = new ClientState(0, 0); + Rest = new DiscordSocketRestClient(config, ApiClient); + _heartbeatTimes = new ConcurrentQueue(); + _gatewayIntents = config.GatewayIntents; + _defaultStickers = ImmutableArray.Create>(); + + _stateLock = new SemaphoreSlim(1, 1); + _gatewayLogger = LogManager.CreateLogger(ShardId == 0 && TotalShards == 1 ? "Gateway" : $"Shard #{ShardId}"); + _connection = new ConnectionManager(_stateLock, _gatewayLogger, config.ConnectionTimeout, + OnConnectingAsync, OnDisconnectingAsync, x => ApiClient.Disconnected += x); + _connection.Connected += () => TimedInvokeAsync(_connectedEvent, nameof(Connected)); + _connection.Disconnected += (ex, recon) => TimedInvokeAsync(_disconnectedEvent, nameof(Disconnected), ex); + + _nextAudioId = 1; + _shardedClient = shardedClient; + _parentClient = parentClient; + + _serializer = new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + _serializer.Error += (s, e) => + { + _gatewayLogger.WarningAsync("Serializer Error", e.ErrorContext.Error).GetAwaiter().GetResult(); + e.ErrorContext.Handled = true; + }; + + ApiClient.SentGatewayMessage += async opCode => await _gatewayLogger.DebugAsync($"Sent {opCode}").ConfigureAwait(false); + ApiClient.ReceivedGatewayEvent += ProcessMessageAsync; + + LeftGuild += async g => await _gatewayLogger.InfoAsync($"Left {g.Name}").ConfigureAwait(false); + JoinedGuild += async g => await _gatewayLogger.InfoAsync($"Joined {g.Name}").ConfigureAwait(false); + GuildAvailable += async g => await _gatewayLogger.VerboseAsync($"Connected to {g.Name}").ConfigureAwait(false); + GuildUnavailable += async g => await _gatewayLogger.VerboseAsync($"Disconnected from {g.Name}").ConfigureAwait(false); + LatencyUpdated += async (old, val) => await _gatewayLogger.DebugAsync($"Latency = {val} ms").ConfigureAwait(false); + + GuildAvailable += g => + { + if (_guildDownloadTask?.IsCompleted == true && ConnectionState == ConnectionState.Connected && AlwaysDownloadUsers && !g.HasAllMembers) + { + var _ = g.DownloadUsersAsync(); + } + return Task.Delay(0); + }; + + _largeGuilds = new ConcurrentQueue(); + AuditLogCacheSize = config.AuditLogCacheSize; + } + private static API.DiscordSocketApiClient CreateApiClient(DiscordSocketConfig config) + => new DiscordSocketApiClient(config.RestClientProvider, config.WebSocketProvider, DiscordRestConfig.UserAgent, config.GatewayHost, + useSystemClock: config.UseSystemClock, defaultRatelimitCallback: config.DefaultRatelimitCallback); + /// + internal override void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + StopAsync().GetAwaiter().GetResult(); + ApiClient?.Dispose(); + _stateLock?.Dispose(); + } + _isDisposed = true; + } + + base.Dispose(disposing); + } + + + internal override async ValueTask DisposeAsync(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + await StopAsync().ConfigureAwait(false); + + if (!(ApiClient is null)) + await ApiClient.DisposeAsync().ConfigureAwait(false); + + _stateLock?.Dispose(); + } + _isDisposed = true; + } + + await base.DisposeAsync(disposing).ConfigureAwait(false); + } + + /// + internal override async Task OnLoginAsync(TokenType tokenType, string token) + { + if (_shardedClient == null && _defaultStickers.Length == 0 && AlwaysDownloadDefaultStickers) + { + var models = await ApiClient.ListNitroStickerPacksAsync().ConfigureAwait(false); + + var builder = ImmutableArray.CreateBuilder>(); + + foreach (var model in models.StickerPacks) + { + var stickers = model.Stickers.Select(x => SocketSticker.Create(this, x)); + + var pack = new StickerPack( + model.Name, + model.Id, + model.SkuId, + model.CoverStickerId.ToNullable(), + model.Description, + model.BannerAssetId, + stickers + ); + + builder.Add(pack); + } + + _defaultStickers = builder.ToImmutable(); + } + } + + /// + internal override async Task OnLogoutAsync() + { + await StopAsync().ConfigureAwait(false); + _applicationInfo = null; + _voiceRegions = null; + await Rest.OnLogoutAsync(); + } + + /// + public override Task StartAsync() + => _connection.StartAsync(); + + /// + public override Task StopAsync() + => _connection.StopAsync(); + + private async Task OnConnectingAsync() + { + bool locked = false; + if (_shardedClient != null && _sessionId == null) + { + await _shardedClient.AcquireIdentifyLockAsync(ShardId, _connection.CancelToken).ConfigureAwait(false); + locked = true; + } + try + { + await _gatewayLogger.DebugAsync("Connecting ApiClient").ConfigureAwait(false); + await ApiClient.ConnectAsync().ConfigureAwait(false); + + if (_sessionId != null) + { + await _gatewayLogger.DebugAsync("Resuming").ConfigureAwait(false); + await ApiClient.SendResumeAsync(_sessionId, _lastSeq).ConfigureAwait(false); + } + else + { + await _gatewayLogger.DebugAsync("Identifying").ConfigureAwait(false); + await ApiClient.SendIdentifyAsync(shardID: ShardId, totalShards: TotalShards, gatewayIntents: _gatewayIntents, presence: BuildCurrentStatus()).ConfigureAwait(false); + } + } + finally + { + if (locked) + _shardedClient.ReleaseIdentifyLock(); + } + + //Wait for READY + await _connection.WaitAsync().ConfigureAwait(false); + + // Log warnings on ready event + if (LogGatewayIntentWarnings) + await LogGatewayIntentsWarning().ConfigureAwait(false); + } + private async Task OnDisconnectingAsync(Exception ex) + { + await _gatewayLogger.DebugAsync("Disconnecting ApiClient").ConfigureAwait(false); + await ApiClient.DisconnectAsync(ex).ConfigureAwait(false); + + //Wait for tasks to complete + await _gatewayLogger.DebugAsync("Waiting for heartbeater").ConfigureAwait(false); + var heartbeatTask = _heartbeatTask; + if (heartbeatTask != null) + await heartbeatTask.ConfigureAwait(false); + _heartbeatTask = null; + + while (_heartbeatTimes.TryDequeue(out _)) + { } + _lastMessageTime = 0; + + await _gatewayLogger.DebugAsync("Waiting for guild downloader").ConfigureAwait(false); + var guildDownloadTask = _guildDownloadTask; + if (guildDownloadTask != null) + await guildDownloadTask.ConfigureAwait(false); + _guildDownloadTask = null; + + //Clear large guild queue + await _gatewayLogger.DebugAsync("Clearing large guild queue").ConfigureAwait(false); + while (_largeGuilds.TryDequeue(out _)) + { } + + //Raise virtual GUILD_UNAVAILABLEs + await _gatewayLogger.DebugAsync("Raising virtual GuildUnavailables").ConfigureAwait(false); + foreach (var guild in State.Guilds) + { + if (guild.IsAvailable) + await GuildUnavailableAsync(guild).ConfigureAwait(false); + } + } + + /// + public override async Task GetApplicationInfoAsync(RequestOptions options = null) + => _applicationInfo ??= await ClientHelper.GetApplicationInfoAsync(this, options ?? RequestOptions.Default).ConfigureAwait(false); + + /// + public override SocketGuild GetGuild(ulong id) + => State.GetGuild(id); + + /// + public override SocketChannel GetChannel(ulong id) + => State.GetChannel(id); + /// + /// Gets a generic channel from the cache or does a rest request if unavailable. + /// + /// + /// + /// var channel = await _client.GetChannelAsync(381889909113225237); + /// if (channel != null && channel is IMessageChannel msgChannel) + /// { + /// await msgChannel.SendMessageAsync($"{msgChannel} is created at {msgChannel.CreatedAt}"); + /// } + /// + /// + /// The snowflake identifier of the channel (e.g. `381889909113225237`). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the channel associated + /// with the snowflake identifier; when the channel cannot be found. + /// + public async ValueTask GetChannelAsync(ulong id, RequestOptions options = null) + => GetChannel(id) ?? (IChannel)await ClientHelper.GetChannelAsync(this, id, options).ConfigureAwait(false); + /// + /// Gets a user from the cache or does a rest request if unavailable. + /// + /// + /// + /// var user = await _client.GetUserAsync(168693960628371456); + /// if (user != null) + /// Console.WriteLine($"{user} is created at {user.CreatedAt}."; + /// + /// + /// The snowflake identifier of the user (e.g. `168693960628371456`). + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the user associated with + /// the snowflake identifier; if the user is not found. + /// + public async ValueTask GetUserAsync(ulong id, RequestOptions options = null) + => await ((IDiscordClient)this).GetUserAsync(id, CacheMode.AllowDownload, options).ConfigureAwait(false); + /// + /// Clears all cached channels from the client. + /// + public void PurgeChannelCache() => State.PurgeAllChannels(); + /// + /// Clears cached DM channels from the client. + /// + public void PurgeDMChannelCache() => RemoveDMChannels(); + + /// + public override SocketUser GetUser(ulong id) + => State.GetUser(id); + /// + public override SocketUser GetUser(string username, string discriminator = null) + => State.Users.FirstOrDefault(x => (discriminator is null || x.Discriminator == discriminator) && x.Username == username); + + /// + public Task CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options = null) + => ClientHelper.CreateTestEntitlementAsync(this, skuId, ownerId, ownerType, options); + + /// + public Task DeleteTestEntitlementAsync(ulong entitlementId, RequestOptions options = null) + => ApiClient.DeleteEntitlementAsync(entitlementId, options); + + /// + public IAsyncEnumerable> GetEntitlementsAsync(BaseDiscordClient client, int? limit = 100, + ulong? afterId = null, ulong? beforeId = null, bool excludeEnded = false, ulong? guildId = null, ulong? userId = null, + ulong[] skuIds = null, RequestOptions options = null) + => ClientHelper.ListEntitlementsAsync(this, limit, afterId, beforeId, excludeEnded, guildId, userId, skuIds, options); + + /// + public Task> GetSKUsAsync(RequestOptions options = null) + => ClientHelper.ListSKUsAsync(this, options); + + /// + /// Gets entitlements from cache. + /// + public IReadOnlyCollection Entitlements => State.Entitlements; + + /// + /// Gets an entitlement from cache. if not found. + /// + public SocketEntitlement GetEntitlement(ulong id) + => State.GetEntitlement(id); + + /// + /// Gets a global application command. + /// + /// The id of the command. + /// The options to be used when sending the request. + /// + /// A ValueTask that represents the asynchronous get operation. The task result contains the application command if found, otherwise + /// . + /// + public async ValueTask GetGlobalApplicationCommandAsync(ulong id, RequestOptions options = null) + { + var command = State.GetCommand(id); + + if (command != null) + return command; + + var model = await ApiClient.GetGlobalApplicationCommandAsync(id, options); + + if (model == null) + return null; + + command = SocketApplicationCommand.Create(this, model); + + State.AddCommand(command); + + return command; + } + /// + /// Gets a collection of all global commands. + /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of global + /// application commands. + /// + public async Task> GetGlobalApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null) + { + var commands = (await ApiClient.GetGlobalApplicationCommandsAsync(withLocalizations, locale, options)).Select(x => SocketApplicationCommand.Create(this, x)); + + foreach (var command in commands) + { + State.AddCommand(command); + } + + return commands.ToImmutableArray(); + } + + public async Task CreateGlobalApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options = null) + { + var model = await InteractionHelper.CreateGlobalCommandAsync(this, properties, options).ConfigureAwait(false); + + var entity = State.GetOrAddCommand(model.Id, (id) => SocketApplicationCommand.Create(this, model)); + + //Update it in case it was cached + entity.Update(model); + + return entity; + } + public async Task> BulkOverwriteGlobalApplicationCommandsAsync( + ApplicationCommandProperties[] properties, RequestOptions options = null) + { + var models = await InteractionHelper.BulkOverwriteGlobalCommandsAsync(this, properties, options); + + var entities = models.Select(x => SocketApplicationCommand.Create(this, x)); + + //Purge our previous commands + State.PurgeCommands(x => x.IsGlobalCommand); + + foreach (var entity in entities) + { + State.AddCommand(entity); + } + + return entities.ToImmutableArray(); + } + + /// + /// Clears cached users from the client. + /// + public void PurgeUserCache() => State.PurgeUsers(); + internal SocketGlobalUser GetOrCreateUser(ClientState state, Discord.API.User model) + { + return state.GetOrAddUser(model.Id, x => SocketGlobalUser.Create(this, state, model)); + } + internal SocketUser GetOrCreateTemporaryUser(ClientState state, Discord.API.User model) + { + return state.GetUser(model.Id) ?? (SocketUser)SocketUnknownUser.Create(this, state, model); + } + internal SocketGlobalUser GetOrCreateSelfUser(ClientState state, Discord.API.User model) + { + return state.GetOrAddUser(model.Id, x => + { + var user = SocketGlobalUser.Create(this, state, model); + user.GlobalUser.AddRef(); + user.Presence = new SocketPresence(UserStatus.Online, null, null); + return user; + }); + } + internal void RemoveUser(ulong id) + => State.RemoveUser(id); + + /// + public override async Task GetStickerAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + var sticker = _defaultStickers.FirstOrDefault(x => x.Stickers.Any(y => y.Id == id))?.Stickers.FirstOrDefault(x => x.Id == id); + + if (sticker != null) + return sticker; + + foreach (var guild in Guilds) + { + sticker = await guild.GetStickerAsync(id, CacheMode.CacheOnly).ConfigureAwait(false); + + if (sticker != null) + return sticker; + } + + if (mode == CacheMode.CacheOnly) + return null; + + var model = await ApiClient.GetStickerAsync(id, options).ConfigureAwait(false); + + if (model == null) + return null; + + + if (model.GuildId.IsSpecified) + { + var guild = State.GetGuild(model.GuildId.Value); + + //Since the sticker can be from another guild, check if we are in the guild or its in the cache + if (guild != null) + sticker = guild.AddOrUpdateSticker(model); + else + sticker = SocketSticker.Create(this, model); + return sticker; + } + else + { + return SocketSticker.Create(this, model); + } + } + + /// + /// Gets a sticker. + /// + /// The unique identifier of the sticker. + /// A sticker if found, otherwise . + public SocketSticker GetSticker(ulong id) + => GetStickerAsync(id, CacheMode.CacheOnly).GetAwaiter().GetResult(); + + /// + public override async ValueTask> GetVoiceRegionsAsync(RequestOptions options = null) + { + if (_parentClient == null) + { + if (_voiceRegions == null) + { + options = RequestOptions.CreateOrClone(options); + options.IgnoreState = true; + var voiceRegions = await ApiClient.GetVoiceRegionsAsync(options).ConfigureAwait(false); + _voiceRegions = voiceRegions.Select(x => RestVoiceRegion.Create(this, x)).ToImmutableDictionary(x => x.Id); + } + return _voiceRegions.ToReadOnlyCollection(); + } + return await _parentClient.GetVoiceRegionsAsync().ConfigureAwait(false); + } + + /// + public override async ValueTask GetVoiceRegionAsync(string id, RequestOptions options = null) + { + if (_parentClient == null) + { + if (_voiceRegions == null) + await GetVoiceRegionsAsync().ConfigureAwait(false); + if (_voiceRegions.TryGetValue(id, out RestVoiceRegion region)) + return region; + return null; + } + return await _parentClient.GetVoiceRegionAsync(id, options).ConfigureAwait(false); + } + + /// + public override Task DownloadUsersAsync(IEnumerable guilds) + { + if (ConnectionState == ConnectionState.Connected) + { + EnsureGatewayIntent(GatewayIntents.GuildMembers); + //Race condition leads to guilds being requested twice, probably okay + return ProcessUserDownloadsAsync(guilds.Select(x => GetGuild(x.Id)).Where(x => x != null)); + } + return Task.CompletedTask; + } + + private async Task ProcessUserDownloadsAsync(IEnumerable guilds) + { + var cachedGuilds = guilds.ToImmutableArray(); + + const short batchSize = 1; + ulong[] batchIds = new ulong[Math.Min(batchSize, cachedGuilds.Length)]; + Task[] batchTasks = new Task[batchIds.Length]; + int batchCount = (cachedGuilds.Length + (batchSize - 1)) / batchSize; + + for (int i = 0, k = 0; i < batchCount; i++) + { + bool isLast = i == batchCount - 1; + int count = isLast ? (cachedGuilds.Length - (batchCount - 1) * batchSize) : batchSize; + + for (int j = 0; j < count; j++, k++) + { + var guild = cachedGuilds[k]; + batchIds[j] = guild.Id; + batchTasks[j] = guild.DownloaderPromise; + } + + await ApiClient.SendRequestMembersAsync(batchIds).ConfigureAwait(false); + + if (isLast && batchCount > 1) + await Task.WhenAll(batchTasks.Take(count)).ConfigureAwait(false); + else + await Task.WhenAll(batchTasks).ConfigureAwait(false); + } + } + + /// + /// + /// The following example sets the status of the current user to Do Not Disturb. + /// + /// await client.SetStatusAsync(UserStatus.DoNotDisturb); + /// + /// + public override Task SetStatusAsync(UserStatus status) + { + Status = status; + if (status == UserStatus.AFK) + _statusSince = DateTimeOffset.UtcNow; + else + _statusSince = null; + return SendStatusAsync(); + } + + /// + /// + /// + /// The following example sets the activity of the current user to the specified game name. + /// + /// await client.SetGameAsync("A Strange Game"); + /// + /// + /// + /// The following example sets the activity of the current user to a streaming status. + /// + /// await client.SetGameAsync("Great Stream 10/10", "https://twitch.tv/MyAmazingStream1337", ActivityType.Streaming); + /// + /// + /// + public override Task SetGameAsync(string name, string streamUrl = null, ActivityType type = ActivityType.Playing) + { + if (!string.IsNullOrEmpty(streamUrl)) + Activity = new StreamingGame(name, streamUrl); + else if (!string.IsNullOrEmpty(name)) + { + if (type is ActivityType.CustomStatus) + Activity = new CustomStatusGame(name); + else + Activity = new Game(name, type); + } + else + Activity = null; + return SendStatusAsync(); + } + + /// + public override Task SetActivityAsync(IActivity activity) + { + Activity = activity; + return SendStatusAsync(); + } + + /// + public override Task SetCustomStatusAsync(string status) + { + var statusGame = new CustomStatusGame(status); + return SetActivityAsync(statusGame); + } + + private Task SendStatusAsync() + { + if (CurrentUser == null) + return Task.CompletedTask; + var activities = _activity.IsSpecified + ? ImmutableList.Create(_activity.Value) + : null; + CurrentUser.Presence = new SocketPresence(Status, null, activities); + + var presence = BuildCurrentStatus() ?? (UserStatus.Online, false, null, null); + + return ApiClient.SendPresenceUpdateAsync( + status: presence.Item1, + isAFK: presence.Item2, + since: presence.Item3, + game: presence.Item4); + } + + private (UserStatus, bool, long?, GameModel)? BuildCurrentStatus() + { + var status = _status; + var statusSince = _statusSince; + var activity = _activity; + + if (status == null && !activity.IsSpecified) + return null; + + GameModel game = null; + //Discord only accepts rich presence over RPC, don't even bother building a payload + + if (activity.GetValueOrDefault() != null) + { + var gameModel = new GameModel(); + if (activity.Value is RichGame) + throw new NotSupportedException("Outgoing Rich Presences are not supported via WebSocket."); + gameModel.Name = Activity.Name; + gameModel.Type = Activity.Type; + if (Activity is StreamingGame streamGame) + gameModel.StreamUrl = streamGame.Url; + if (Activity is CustomStatusGame customStatus) + gameModel.State = customStatus.State; + game = gameModel; + } + else if (activity.IsSpecified) + game = null; + + return (status ?? UserStatus.Online, + status == UserStatus.AFK, + statusSince != null ? _statusSince.Value.ToUnixTimeMilliseconds() : (long?)null, + game); + } + + private async Task LogGatewayIntentsWarning() + { + if (_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && + (_shardedClient is null && !_presenceUpdated.HasSubscribers || + (_shardedClient is not null && !_shardedClient._presenceUpdated.HasSubscribers))) + { + await _gatewayLogger.WarningAsync("You're using the GuildPresences intent without listening to the PresenceUpdate event, consider removing the intent from your config.").ConfigureAwait(false); + } + + if (!_gatewayIntents.HasFlag(GatewayIntents.GuildPresences) && + ((_shardedClient is null && _presenceUpdated.HasSubscribers) || + (_shardedClient is not null && _shardedClient._presenceUpdated.HasSubscribers))) + { + await _gatewayLogger.WarningAsync("You're using the PresenceUpdate event without specifying the GuildPresences intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); + } + + bool hasGuildScheduledEventsSubscribers = + _guildScheduledEventCancelled.HasSubscribers || + _guildScheduledEventUserRemove.HasSubscribers || + _guildScheduledEventCompleted.HasSubscribers || + _guildScheduledEventCreated.HasSubscribers || + _guildScheduledEventStarted.HasSubscribers || + _guildScheduledEventUpdated.HasSubscribers || + _guildScheduledEventUserAdd.HasSubscribers; + + bool shardedClientHasGuildScheduledEventsSubscribers = + _shardedClient is not null && + (_shardedClient._guildScheduledEventCancelled.HasSubscribers || + _shardedClient._guildScheduledEventUserRemove.HasSubscribers || + _shardedClient._guildScheduledEventCompleted.HasSubscribers || + _shardedClient._guildScheduledEventCreated.HasSubscribers || + _shardedClient._guildScheduledEventStarted.HasSubscribers || + _shardedClient._guildScheduledEventUpdated.HasSubscribers || + _shardedClient._guildScheduledEventUserAdd.HasSubscribers); + + if (_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && + ((_shardedClient is null && !hasGuildScheduledEventsSubscribers) || + (_shardedClient is not null && !shardedClientHasGuildScheduledEventsSubscribers))) + { + await _gatewayLogger.WarningAsync("You're using the GuildScheduledEvents gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); + } + + if (!_gatewayIntents.HasFlag(GatewayIntents.GuildScheduledEvents) && + ((_shardedClient is null && hasGuildScheduledEventsSubscribers) || + (_shardedClient is not null && shardedClientHasGuildScheduledEventsSubscribers))) + { + await _gatewayLogger.WarningAsync("You're using events related to the GuildScheduledEvents gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); + } + + bool hasInviteEventSubscribers = + _inviteCreatedEvent.HasSubscribers || + _inviteDeletedEvent.HasSubscribers; + + bool shardedClientHasInviteEventSubscribers = + _shardedClient is not null && + (_shardedClient._inviteCreatedEvent.HasSubscribers || + _shardedClient._inviteDeletedEvent.HasSubscribers); + + if (_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && + ((_shardedClient is null && !hasInviteEventSubscribers) || + (_shardedClient is not null && !shardedClientHasInviteEventSubscribers))) + { + await _gatewayLogger.WarningAsync("You're using the GuildInvites gateway intent without listening to any events related to that intent, consider removing the intent from your config.").ConfigureAwait(false); + } + + if (!_gatewayIntents.HasFlag(GatewayIntents.GuildInvites) && + ((_shardedClient is null && hasInviteEventSubscribers) || + (_shardedClient is not null && shardedClientHasInviteEventSubscribers))) + { + await _gatewayLogger.WarningAsync("You're using events related to the GuildInvites gateway intent without specifying the intent. Discord wont send this event to your client without the intent set in your config.").ConfigureAwait(false); + } + } + + #region ProcessMessageAsync + private async Task ProcessMessageAsync(GatewayOpCode opCode, int? seq, string type, object payload) + { + if (seq != null) + _lastSeq = seq.Value; + _lastMessageTime = Environment.TickCount; + + try + { + switch (opCode) + { + case GatewayOpCode.Hello: + { + await _gatewayLogger.DebugAsync("Received Hello").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + _heartbeatTask = RunHeartbeatAsync(data.HeartbeatInterval, _connection.CancelToken); + } + break; + case GatewayOpCode.Heartbeat: + { + await _gatewayLogger.DebugAsync("Received Heartbeat").ConfigureAwait(false); + + await ApiClient.SendHeartbeatAsync(_lastSeq).ConfigureAwait(false); + } + break; + case GatewayOpCode.HeartbeatAck: + { + await _gatewayLogger.DebugAsync("Received HeartbeatAck").ConfigureAwait(false); + + if (_heartbeatTimes.TryDequeue(out long time)) + { + int latency = (int)(Environment.TickCount - time); + int before = Latency; + Latency = latency; + + await TimedInvokeAsync(_latencyUpdatedEvent, nameof(LatencyUpdated), before, latency).ConfigureAwait(false); + } + } + break; + case GatewayOpCode.InvalidSession: + { + await _gatewayLogger.DebugAsync("Received InvalidSession").ConfigureAwait(false); + await _gatewayLogger.WarningAsync("Failed to resume previous session").ConfigureAwait(false); + + _sessionId = null; + _lastSeq = 0; + ApiClient.ResumeGatewayUrl = null; + + if (_shardedClient != null) + { + await _shardedClient.AcquireIdentifyLockAsync(ShardId, _connection.CancelToken).ConfigureAwait(false); + try + { + await ApiClient.SendIdentifyAsync(shardID: ShardId, totalShards: TotalShards, gatewayIntents: _gatewayIntents, presence: BuildCurrentStatus()).ConfigureAwait(false); + } + finally + { + _shardedClient.ReleaseIdentifyLock(); + } + } + else + await ApiClient.SendIdentifyAsync(shardID: ShardId, totalShards: TotalShards, gatewayIntents: _gatewayIntents, presence: BuildCurrentStatus()).ConfigureAwait(false); + } + break; + case GatewayOpCode.Reconnect: + { + await _gatewayLogger.DebugAsync("Received Reconnect").ConfigureAwait(false); + _connection.Error(new GatewayReconnectException("Server requested a reconnect")); + } + break; + case GatewayOpCode.Dispatch: + switch (type) + { + #region Connection + case "READY": + { + try + { + await _gatewayLogger.DebugAsync("Received Dispatch (READY)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var state = new ClientState(data.Guilds.Length, data.PrivateChannels.Length); + + var currentUser = SocketSelfUser.Create(this, state, data.User); + Rest.CreateRestSelfUser(data.User); + var activities = _activity.IsSpecified ? ImmutableList.Create(_activity.Value) : null; + currentUser.Presence = new SocketPresence(Status, null, activities); + ApiClient.CurrentUserId = currentUser.Id; + ApiClient.CurrentApplicationId = data.Application?.Id; + Rest.CurrentUser = RestSelfUser.Create(this, data.User); + int unavailableGuilds = 0; + for (int i = 0; i < data.Guilds.Length; i++) + { + var model = data.Guilds[i]; + var guild = AddGuild(model, state); + if (!guild.IsAvailable) + unavailableGuilds++; + else + await GuildAvailableAsync(guild).ConfigureAwait(false); + } + for (int i = 0; i < data.PrivateChannels.Length; i++) + AddPrivateChannel(data.PrivateChannels[i], state); + + _sessionId = data.SessionId; + ApiClient.ResumeGatewayUrl = data.ResumeGatewayUrl; + _unavailableGuildCount = unavailableGuilds; + CurrentUser = currentUser; + _previousSessionUser = CurrentUser; + State = state; + } + catch (Exception ex) + { + _connection.CriticalError(new Exception("Processing READY failed", ex)); + return; + } + + _lastGuildAvailableTime = Environment.TickCount; + _guildDownloadTask = WaitForGuildsAsync(_connection.CancelToken, _gatewayLogger) + .ContinueWith(async x => + { + if (x.IsFaulted) + { + _connection.Error(x.Exception); + return; + } + else if (_connection.CancelToken.IsCancellationRequested) + return; + + if (BaseConfig.AlwaysDownloadUsers) + try + { + _ = DownloadUsersAsync(Guilds.Where(x => x.IsAvailable && !x.HasAllMembers)); + } + catch (Exception ex) + { + await _gatewayLogger.WarningAsync(ex); + } + + await TimedInvokeAsync(_readyEvent, nameof(Ready)).ConfigureAwait(false); + await _gatewayLogger.InfoAsync("Ready").ConfigureAwait(false); + }); + _ = _connection.CompleteAsync(); + } + break; + case "RESUMED": + { + await _gatewayLogger.DebugAsync("Received Dispatch (RESUMED)").ConfigureAwait(false); + + _ = _connection.CompleteAsync(); + + //Notify the client that these guilds are available again + foreach (var guild in State.Guilds) + { + if (guild.IsAvailable) + await GuildAvailableAsync(guild).ConfigureAwait(false); + } + + // Restore the previous sessions current user + CurrentUser = _previousSessionUser; + + await _gatewayLogger.InfoAsync("Resumed previous session").ConfigureAwait(false); + } + break; + #endregion + + #region Guilds + case "GUILD_CREATE": + { + var data = (payload as JToken).ToObject(_serializer); + + if (data.Unavailable == false) + { + type = "GUILD_AVAILABLE"; + _lastGuildAvailableTime = Environment.TickCount; + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_AVAILABLE)").ConfigureAwait(false); + + var guild = State.GetGuild(data.Id); + if (guild != null) + { + guild.Update(State, data); + + if (_unavailableGuildCount != 0) + _unavailableGuildCount--; + await GuildAvailableAsync(guild).ConfigureAwait(false); + + if (guild.DownloadedMemberCount >= guild.MemberCount && !guild.DownloaderPromise.IsCompleted) + { + guild.CompleteDownloadUsers(); + await TimedInvokeAsync(_guildMembersDownloadedEvent, nameof(GuildMembersDownloaded), guild).ConfigureAwait(false); + } + } + else + { + await UnknownGuildAsync(type, data.Id).ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_CREATE)").ConfigureAwait(false); + + var guild = AddGuild(data, State); + if (guild != null) + { + await TimedInvokeAsync(_joinedGuildEvent, nameof(JoinedGuild), guild).ConfigureAwait(false); + await GuildAvailableAsync(guild).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.Id).ConfigureAwait(false); + return; + } + } + } + break; + case "GUILD_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.Id); + if (guild != null) + { + var before = guild.Clone(); + guild.Update(State, data); + await TimedInvokeAsync(_guildUpdatedEvent, nameof(GuildUpdated), before, guild).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.Id).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_EMOJIS_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_EMOJIS_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + var before = guild.Clone(); + guild.Update(State, data); + await TimedInvokeAsync(_guildUpdatedEvent, nameof(GuildUpdated), before, guild).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_SYNC": + { + await _gatewayLogger.DebugAsync("Ignored Dispatch (GUILD_SYNC)").ConfigureAwait(false); + /*await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_SYNC)").ConfigureAwait(false); //TODO remove? userbot related + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.Id); + if (guild != null) + { + var before = guild.Clone(); + guild.Update(State, data); + //This is treated as an extension of GUILD_AVAILABLE + _unavailableGuildCount--; + _lastGuildAvailableTime = Environment.TickCount; + await GuildAvailableAsync(guild).ConfigureAwait(false); + await TimedInvokeAsync(_guildUpdatedEvent, nameof(GuildUpdated), before, guild).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.Id).ConfigureAwait(false); + return; + }*/ + } + break; + case "GUILD_DELETE": + { + var data = (payload as JToken).ToObject(_serializer); + if (data.Unavailable == true) + { + type = "GUILD_UNAVAILABLE"; + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_UNAVAILABLE)").ConfigureAwait(false); + + var guild = State.GetGuild(data.Id); + if (guild != null) + { + await GuildUnavailableAsync(guild).ConfigureAwait(false); + _unavailableGuildCount++; + } + else + { + await UnknownGuildAsync(type, data.Id).ConfigureAwait(false); + return; + } + } + else + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_DELETE)").ConfigureAwait(false); + + var guild = RemoveGuild(data.Id); + if (guild != null) + { + await GuildUnavailableAsync(guild).ConfigureAwait(false); + await TimedInvokeAsync(_leftGuildEvent, nameof(LeftGuild), guild).ConfigureAwait(false); + (guild as IDisposable).Dispose(); + } + else + { + await UnknownGuildAsync(type, data.Id).ConfigureAwait(false); + return; + } + } + } + break; + case "GUILD_STICKERS_UPDATE": + { + await _gatewayLogger.DebugAsync($"Received Dispatch (GUILD_STICKERS_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var newStickers = data.Stickers.Where(x => !guild.Stickers.Any(y => y.Id == x.Id)); + var deletedStickers = guild.Stickers.Where(x => !data.Stickers.Any(y => y.Id == x.Id)); + var updatedStickers = data.Stickers.Select(x => + { + var s = guild.Stickers.FirstOrDefault(y => y.Id == x.Id); + if (s == null) + return null; + + var e = s.Equals(x); + if (!e) + { + return (s, x) as (SocketCustomSticker Entity, API.Sticker Model)?; + } + else + { + return null; + } + }).Where(x => x.HasValue).Select(x => x.Value).ToArray(); + + foreach (var model in newStickers) + { + var entity = guild.AddSticker(model); + await TimedInvokeAsync(_guildStickerCreated, nameof(GuildStickerCreated), entity); + } + foreach (var sticker in deletedStickers) + { + var entity = guild.RemoveSticker(sticker.Id); + await TimedInvokeAsync(_guildStickerDeleted, nameof(GuildStickerDeleted), entity); + } + foreach (var entityModelPair in updatedStickers) + { + var before = entityModelPair.Entity.Clone(); + + entityModelPair.Entity.Update(entityModelPair.Model); + + await TimedInvokeAsync(_guildStickerUpdated, nameof(GuildStickerUpdated), before, entityModelPair.Entity); + } + } + break; + #endregion + + #region Channels + case "CHANNEL_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + SocketChannel channel = null; + if (data.GuildId.IsSpecified) + { + var guild = State.GetGuild(data.GuildId.Value); + if (guild != null) + { + channel = guild.AddChannel(State, data); + + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + } + else + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + } + else + { + channel = State.GetChannel(data.Id); + if (channel != null) + return; //Discord may send duplicate CHANNEL_CREATEs for DMs + channel = AddPrivateChannel(data, State) as SocketChannel; + } + + if (channel != null) + await TimedInvokeAsync(_channelCreatedEvent, nameof(ChannelCreated), channel).ConfigureAwait(false); + } + break; + case "CHANNEL_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = State.GetChannel(data.Id); + if (channel != null) + { + var before = channel.Clone(); + channel.Update(State, data); + + var guild = (channel as SocketGuildChannel)?.Guild; + if (!(guild?.IsSynced ?? true)) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_channelUpdatedEvent, nameof(ChannelUpdated), before, channel).ConfigureAwait(false); + } + else + { + await UnknownChannelAsync(type, data.Id).ConfigureAwait(false); + return; + } + } + break; + case "CHANNEL_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_DELETE)").ConfigureAwait(false); + + SocketChannel channel = null; + var data = (payload as JToken).ToObject(_serializer); + if (data.GuildId.IsSpecified) + { + var guild = State.GetGuild(data.GuildId.Value); + if (guild != null) + { + channel = guild.RemoveChannel(State, data.Id); + + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + } + else + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + } + else + channel = RemovePrivateChannel(data.Id) as SocketChannel; + + if (channel != null) + await TimedInvokeAsync(_channelDestroyedEvent, nameof(ChannelDestroyed), channel).ConfigureAwait(false); + else + { + await UnknownChannelAsync(type, data.Id, data.GuildId.GetValueOrDefault(0)).ConfigureAwait(false); + return; + } + } + break; + #endregion + + #region Members + case "GUILD_MEMBER_ADD": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBER_ADD)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + var user = guild.AddOrUpdateUser(data); + guild.MemberCount++; + + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_userJoinedEvent, nameof(UserJoined), user).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_MEMBER_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBER_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + var user = guild.GetUser(data.User.Id); + + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + if (user != null) + { + var before = user.Clone(); + if (user.GlobalUser.Update(State, data.User)) + { + //Global data was updated, trigger UserUpdated + await TimedInvokeAsync(_userUpdatedEvent, nameof(UserUpdated), before.GlobalUser, user).ConfigureAwait(false); + } + + user.Update(State, data); + + var cacheableBefore = new Cacheable(before, user.Id, true, () => Task.FromResult(null)); + await TimedInvokeAsync(_guildMemberUpdatedEvent, nameof(GuildMemberUpdated), cacheableBefore, user).ConfigureAwait(false); + } + else + { + user = guild.AddOrUpdateUser(data); + var cacheableBefore = new Cacheable(null, user.Id, false, () => Task.FromResult(null)); + await TimedInvokeAsync(_guildMemberUpdatedEvent, nameof(GuildMemberUpdated), cacheableBefore, user).ConfigureAwait(false); + } + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_MEMBER_REMOVE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBER_REMOVE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + SocketUser user = guild.RemoveUser(data.User.Id); + guild.MemberCount--; + + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + user ??= State.GetUser(data.User.Id); + + if (user != null) + user.Update(State, data.User); + else + user = State.GetOrAddUser(data.User.Id, (x) => SocketGlobalUser.Create(this, State, data.User)); + + await TimedInvokeAsync(_userLeftEvent, nameof(UserLeft), guild, user).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_MEMBERS_CHUNK": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_MEMBERS_CHUNK)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + foreach (var memberModel in data.Members) + guild.AddOrUpdateUser(memberModel); + + if (guild.DownloadedMemberCount >= guild.MemberCount && !guild.DownloaderPromise.IsCompleted) + { + guild.CompleteDownloadUsers(); + await TimedInvokeAsync(_guildMembersDownloadedEvent, nameof(GuildMembersDownloaded), guild).ConfigureAwait(false); + } + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_JOIN_REQUEST_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_JOIN_REQUEST_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var user = guild.RemoveUser(data.UserId); + guild.MemberCount--; + + var cacheableUser = new Cacheable(user, data.UserId, user != null, () => Task.FromResult((SocketGuildUser)null)); + + await TimedInvokeAsync(_guildJoinRequestDeletedEvent, nameof(GuildJoinRequestDeleted), cacheableUser, guild).ConfigureAwait(false); + } + break; + #endregion + + #region DM Channels + + case "CHANNEL_RECIPIENT_ADD": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_RECIPIENT_ADD)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (State.GetChannel(data.ChannelId) is SocketGroupChannel channel) + { + var user = channel.GetOrAddUser(data.User); + await TimedInvokeAsync(_recipientAddedEvent, nameof(RecipientAdded), user).ConfigureAwait(false); + } + else + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + } + break; + case "CHANNEL_RECIPIENT_REMOVE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (CHANNEL_RECIPIENT_REMOVE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (State.GetChannel(data.ChannelId) is SocketGroupChannel channel) + { + var user = channel.RemoveUser(data.User.Id); + if (user != null) + await TimedInvokeAsync(_recipientRemovedEvent, nameof(RecipientRemoved), user).ConfigureAwait(false); + else + { + await UnknownChannelUserAsync(type, data.User.Id, data.ChannelId).ConfigureAwait(false); + return; + } + } + else + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + } + break; + + #endregion + + #region Roles + case "GUILD_ROLE_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_ROLE_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + var role = guild.AddRole(data.Role); + + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + await TimedInvokeAsync(_roleCreatedEvent, nameof(RoleCreated), role).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_ROLE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_ROLE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + var role = guild.GetRole(data.Role.Id); + if (role != null) + { + var before = role.Clone(); + role.Update(State, data.Role); + + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_roleUpdatedEvent, nameof(RoleUpdated), before, role).ConfigureAwait(false); + } + else + { + await UnknownRoleAsync(type, data.Role.Id, guild.Id).ConfigureAwait(false); + return; + } + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_ROLE_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_ROLE_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + var role = guild.RemoveRole(data.RoleId); + if (role != null) + { + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_roleDeletedEvent, nameof(RoleDeleted), role).ConfigureAwait(false); + } + else + { + await UnknownRoleAsync(type, data.RoleId, guild.Id).ConfigureAwait(false); + return; + } + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + #endregion + + #region Bans + case "GUILD_BAN_ADD": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_BAN_ADD)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + SocketUser user = guild.GetUser(data.User.Id); + if (user == null) + user = SocketUnknownUser.Create(this, State, data.User); + await TimedInvokeAsync(_userBannedEvent, nameof(UserBanned), user, guild).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + case "GUILD_BAN_REMOVE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_BAN_REMOVE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + if (guild != null) + { + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + SocketUser user = State.GetUser(data.User.Id); + if (user == null) + user = SocketUnknownUser.Create(this, State, data.User); + await TimedInvokeAsync(_userUnbannedEvent, nameof(UserUnbanned), user, guild).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + #endregion + + #region Messages + case "MESSAGE_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var guild = (channel as SocketGuildChannel)?.Guild; + if (guild != null && !guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + if (channel == null) + { + if (!data.GuildId.IsSpecified) // assume it is a DM + { + channel = CreateDMChannel(data.ChannelId, data.Author.Value, State); + } + else + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + } + + SocketUser author; + if (guild != null) + { + if (data.WebhookId.IsSpecified) + author = SocketWebhookUser.Create(guild, State, data.Author.Value, data.WebhookId.Value); + else + author = guild.GetUser(data.Author.Value.Id); + } + else + author = (channel as SocketChannel).GetUser(data.Author.Value.Id); + + if (author == null) + { + if (guild != null) + { + if (data.Member.IsSpecified) // member isn't always included, but use it when we can + { + data.Member.Value.User = data.Author.Value; + author = guild.AddOrUpdateUser(data.Member.Value); + } + else + author = guild.AddOrUpdateUser(data.Author.Value); // user has no guild-specific data + } + else if (channel is SocketGroupChannel groupChannel) + author = groupChannel.GetOrAddUser(data.Author.Value); + else + { + await UnknownChannelUserAsync(type, data.Author.Value.Id, channel.Id).ConfigureAwait(false); + return; + } + } + + var msg = SocketMessage.Create(this, State, author, channel, data); + SocketChannelHelper.AddMessage(channel, this, msg); + await TimedInvokeAsync(_messageReceivedEvent, nameof(MessageReceived), msg).ConfigureAwait(false); + } + break; + case "MESSAGE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var guild = (channel as SocketGuildChannel)?.Guild; + if (guild != null && !guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + SocketMessage before = null, after = null; + SocketMessage cachedMsg = channel?.GetCachedMessage(data.Id); + bool isCached = cachedMsg != null; + if (isCached) + { + before = cachedMsg.Clone(); + cachedMsg.Update(State, data); + after = cachedMsg; + } + else + { + //Edited message isn't in cache, create a detached one + SocketUser author; + if (data.Author.IsSpecified) + { + if (guild != null) + { + if (data.WebhookId.IsSpecified) + author = SocketWebhookUser.Create(guild, State, data.Author.Value, data.WebhookId.Value); + else + author = guild.GetUser(data.Author.Value.Id); + } + else + author = (channel as SocketChannel)?.GetUser(data.Author.Value.Id); + + if (author == null) + { + if (guild != null) + { + if (data.Member.IsSpecified) // member isn't always included, but use it when we can + { + data.Member.Value.User = data.Author.Value; + author = guild.AddOrUpdateUser(data.Member.Value); + } + else + author = guild.AddOrUpdateUser(data.Author.Value); // user has no guild-specific data + } + else if (channel is SocketGroupChannel groupChannel) + author = groupChannel.GetOrAddUser(data.Author.Value); + } + } + else + // Message author wasn't specified in the payload, so create a completely anonymous unknown user + author = new SocketUnknownUser(this, id: 0); + + if (channel == null) + { + if (!data.GuildId.IsSpecified) // assume it is a DM + { + if (data.Author.IsSpecified) + { + var dmChannel = CreateDMChannel(data.ChannelId, data.Author.Value, State); + channel = dmChannel; + author = dmChannel.Recipient; + } + else + channel = CreateDMChannel(data.ChannelId, author, State); + } + else + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + } + + after = SocketMessage.Create(this, State, author, channel, data); + } + var cacheableBefore = new Cacheable(before, data.Id, isCached, async () => await channel.GetMessageAsync(data.Id).ConfigureAwait(false)); + + await TimedInvokeAsync(_messageUpdatedEvent, nameof(MessageUpdated), cacheableBefore, after, channel).ConfigureAwait(false); + } + break; + case "MESSAGE_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var guild = (channel as SocketGuildChannel)?.Guild; + if (!(guild?.IsSynced ?? true)) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + SocketMessage msg = null; + if (channel != null) + msg = SocketChannelHelper.RemoveMessage(channel, this, data.Id); + var cacheableMsg = new Cacheable(msg, data.Id, msg != null, () => Task.FromResult((IMessage)null)); + var cacheableChannel = new Cacheable(channel, data.ChannelId, channel != null, async () => await GetChannelAsync(data.ChannelId).ConfigureAwait(false) as IMessageChannel); + + await TimedInvokeAsync(_messageDeletedEvent, nameof(MessageDeleted), cacheableMsg, cacheableChannel).ConfigureAwait(false); + } + break; + case "MESSAGE_REACTION_ADD": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_REACTION_ADD)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var cachedMsg = channel?.GetCachedMessage(data.MessageId) as SocketUserMessage; + bool isMsgCached = cachedMsg != null; + IUser user = null; + if (channel != null) + user = await channel.GetUserAsync(data.UserId, CacheMode.CacheOnly).ConfigureAwait(false); + + var optionalMsg = !isMsgCached + ? Optional.Create() + : Optional.Create(cachedMsg); + + if (data.Member.IsSpecified) + { + var guild = (channel as SocketGuildChannel)?.Guild; + + if (guild != null) + user = guild.AddOrUpdateUser(data.Member.Value); + } + else + user = GetUser(data.UserId); + + var optionalUser = user is null + ? Optional.Create() + : Optional.Create(user); + + var cacheableChannel = new Cacheable(channel, data.ChannelId, channel != null, async () => await GetChannelAsync(data.ChannelId).ConfigureAwait(false) as IMessageChannel); + var cacheableMsg = new Cacheable(cachedMsg, data.MessageId, isMsgCached, async () => + { + var channelObj = await cacheableChannel.GetOrDownloadAsync().ConfigureAwait(false); + return await channelObj.GetMessageAsync(data.MessageId).ConfigureAwait(false) as IUserMessage; + }); + var reaction = SocketReaction.Create(data, channel, optionalMsg, optionalUser); + + cachedMsg?.AddReaction(reaction); + + await TimedInvokeAsync(_reactionAddedEvent, nameof(ReactionAdded), cacheableMsg, cacheableChannel, reaction).ConfigureAwait(false); + } + break; + case "MESSAGE_REACTION_REMOVE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_REACTION_REMOVE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var cachedMsg = channel?.GetCachedMessage(data.MessageId) as SocketUserMessage; + bool isMsgCached = cachedMsg != null; + IUser user = null; + if (channel != null) + user = await channel.GetUserAsync(data.UserId, CacheMode.CacheOnly).ConfigureAwait(false); + else if (!data.GuildId.IsSpecified) + user = GetUser(data.UserId); + + var optionalMsg = !isMsgCached + ? Optional.Create() + : Optional.Create(cachedMsg); + + var optionalUser = user is null + ? Optional.Create() + : Optional.Create(user); + + var cacheableChannel = new Cacheable(channel, data.ChannelId, channel != null, async () => await GetChannelAsync(data.ChannelId).ConfigureAwait(false) as IMessageChannel); + var cacheableMsg = new Cacheable(cachedMsg, data.MessageId, isMsgCached, async () => + { + var channelObj = await cacheableChannel.GetOrDownloadAsync().ConfigureAwait(false); + return await channelObj.GetMessageAsync(data.MessageId).ConfigureAwait(false) as IUserMessage; + }); + var reaction = SocketReaction.Create(data, channel, optionalMsg, optionalUser); + + cachedMsg?.RemoveReaction(reaction); + + await TimedInvokeAsync(_reactionRemovedEvent, nameof(ReactionRemoved), cacheableMsg, cacheableChannel, reaction).ConfigureAwait(false); + } + break; + case "MESSAGE_REACTION_REMOVE_ALL": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_REACTION_REMOVE_ALL)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var cacheableChannel = new Cacheable(channel, data.ChannelId, channel != null, async () => await GetChannelAsync(data.ChannelId).ConfigureAwait(false) as IMessageChannel); + var cachedMsg = channel?.GetCachedMessage(data.MessageId) as SocketUserMessage; + bool isMsgCached = cachedMsg != null; + var cacheableMsg = new Cacheable(cachedMsg, data.MessageId, isMsgCached, async () => + { + var channelObj = await cacheableChannel.GetOrDownloadAsync().ConfigureAwait(false); + return await channelObj.GetMessageAsync(data.MessageId).ConfigureAwait(false) as IUserMessage; + }); + + cachedMsg?.ClearReactions(); + + await TimedInvokeAsync(_reactionsClearedEvent, nameof(ReactionsCleared), cacheableMsg, cacheableChannel).ConfigureAwait(false); + } + break; + case "MESSAGE_REACTION_REMOVE_EMOJI": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_REACTION_REMOVE_EMOJI)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var cachedMsg = channel?.GetCachedMessage(data.MessageId) as SocketUserMessage; + bool isMsgCached = cachedMsg != null; + + var optionalMsg = !isMsgCached + ? Optional.Create() + : Optional.Create(cachedMsg); + + var cacheableChannel = new Cacheable(channel, data.ChannelId, channel != null, async () => await GetChannelAsync(data.ChannelId).ConfigureAwait(false) as IMessageChannel); + var cacheableMsg = new Cacheable(cachedMsg, data.MessageId, isMsgCached, async () => + { + var channelObj = await cacheableChannel.GetOrDownloadAsync().ConfigureAwait(false); + return await channelObj.GetMessageAsync(data.MessageId).ConfigureAwait(false) as IUserMessage; + }); + var emote = data.Emoji.ToIEmote(); + + cachedMsg?.RemoveReactionsForEmote(emote); + + await TimedInvokeAsync(_reactionsRemovedForEmoteEvent, nameof(ReactionsRemovedForEmote), cacheableMsg, cacheableChannel, emote).ConfigureAwait(false); + } + break; + case "MESSAGE_DELETE_BULK": + { + await _gatewayLogger.DebugAsync("Received Dispatch (MESSAGE_DELETE_BULK)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var guild = (channel as SocketGuildChannel)?.Guild; + if (!(guild?.IsSynced ?? true)) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + var cacheableChannel = new Cacheable(channel, data.ChannelId, channel != null, async () => await GetChannelAsync(data.ChannelId).ConfigureAwait(false) as IMessageChannel); + var cacheableList = new List>(data.Ids.Length); + foreach (ulong id in data.Ids) + { + SocketMessage msg = null; + if (channel != null) + msg = SocketChannelHelper.RemoveMessage(channel, this, id); + bool isMsgCached = msg != null; + var cacheableMsg = new Cacheable(msg, id, isMsgCached, () => Task.FromResult((IMessage)null)); + cacheableList.Add(cacheableMsg); + } + + await TimedInvokeAsync(_messagesBulkDeletedEvent, nameof(MessagesBulkDeleted), cacheableList, cacheableChannel).ConfigureAwait(false); + } + break; + #endregion + + #region Statuses + case "PRESENCE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (PRESENCE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + SocketUser user = null; + + if (data.GuildId.IsSpecified) + { + var guild = State.GetGuild(data.GuildId.Value); + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + user = guild.GetUser(data.User.Id); + if (user == null) + { + if (data.Status == UserStatus.Offline) + { + return; + } + user = guild.AddOrUpdateUser(data); + } + else + { + var globalBefore = user.GlobalUser.Clone(); + if (user.GlobalUser.Update(State, data.User)) + { + //Global data was updated, trigger UserUpdated + await TimedInvokeAsync(_userUpdatedEvent, nameof(UserUpdated), globalBefore, user).ConfigureAwait(false); + } + } + } + else + { + user = State.GetUser(data.User.Id); + if (user == null) + { + await UnknownGlobalUserAsync(type, data.User.Id).ConfigureAwait(false); + return; + } + } + + var before = user.Presence?.Clone(); + user.Update(State, data.User); + user.Update(data); + await TimedInvokeAsync(_presenceUpdated, nameof(PresenceUpdated), user, before, user.Presence).ConfigureAwait(false); + } + break; + case "TYPING_START": + { + await _gatewayLogger.DebugAsync("Received Dispatch (TYPING_START)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var channel = GetChannel(data.ChannelId) as ISocketMessageChannel; + + var guild = (channel as SocketGuildChannel)?.Guild; + if (!(guild?.IsSynced ?? true)) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + var cacheableChannel = new Cacheable(channel, data.ChannelId, channel != null, async () => await GetChannelAsync(data.ChannelId).ConfigureAwait(false) as IMessageChannel); + + var user = (channel as SocketChannel)?.GetUser(data.UserId); + if (user == null) + { + if (guild != null && data.Member.IsSpecified) + user = guild.AddOrUpdateUser(data.Member.Value); + } + var cacheableUser = new Cacheable(user, data.UserId, user != null, async () => await GetUserAsync(data.UserId).ConfigureAwait(false)); + + await TimedInvokeAsync(_userIsTypingEvent, nameof(UserIsTyping), cacheableUser, cacheableChannel).ConfigureAwait(false); + } + break; + #endregion + + #region Integrations + case "INTEGRATION_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (INTEGRATION_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + // Integrations from Gateway should always have guild IDs specified. + if (!data.GuildId.IsSpecified) + return; + + var guild = State.GetGuild(data.GuildId.Value); + + if (guild != null) + { + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_integrationCreated, nameof(IntegrationCreated), RestIntegration.Create(this, guild, data)).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + } + break; + case "INTEGRATION_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (INTEGRATION_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + // Integrations from Gateway should always have guild IDs specified. + if (!data.GuildId.IsSpecified) + return; + + var guild = State.GetGuild(data.GuildId.Value); + + if (guild != null) + { + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_integrationUpdated, nameof(IntegrationUpdated), RestIntegration.Create(this, guild, data)).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + } + break; + case "INTEGRATION_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (INTEGRATION_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild != null) + { + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_integrationDeleted, nameof(IntegrationDeleted), guild, data.Id, data.ApplicationID).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + } + break; + #endregion + + #region Users + case "USER_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (USER_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (data.Id == CurrentUser.Id) + { + var before = CurrentUser.Clone(); + CurrentUser.Update(State, data); + await TimedInvokeAsync(_selfUpdatedEvent, nameof(CurrentUserUpdated), before, CurrentUser).ConfigureAwait(false); + } + else + { + await _gatewayLogger.WarningAsync("Received USER_UPDATE for wrong user.").ConfigureAwait(false); + return; + } + } + break; + #endregion + + #region Voice + case "VOICE_STATE_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (VOICE_STATE_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + SocketUser user; + SocketVoiceState before, after; + if (data.GuildId != null) + { + var guild = State.GetGuild(data.GuildId.Value); + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + else if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + if (data.ChannelId != null) + { + before = guild.GetVoiceState(data.UserId)?.Clone() ?? SocketVoiceState.Default; + after = await guild.AddOrUpdateVoiceStateAsync(State, data).ConfigureAwait(false); + /*if (data.UserId == CurrentUser.Id) + { + var _ = guild.FinishJoinAudioChannel().ConfigureAwait(false); + }*/ + } + else + { + before = await guild.RemoveVoiceStateAsync(data.UserId).ConfigureAwait(false) ?? SocketVoiceState.Default; + after = SocketVoiceState.Create(null, data); + } + + //Per g250k, this should always be sent, but apparently not always + user = guild.GetUser(data.UserId) + ?? (data.Member.IsSpecified ? guild.AddOrUpdateUser(data.Member.Value) : null); + if (user == null) + { + await UnknownGuildUserAsync(type, data.UserId, guild.Id).ConfigureAwait(false); + return; + } + } + else + { + var groupChannel = GetChannel(data.ChannelId.Value) as SocketGroupChannel; + if (groupChannel == null) + { + await UnknownChannelAsync(type, data.ChannelId.Value).ConfigureAwait(false); + return; + } + if (data.ChannelId != null) + { + before = groupChannel.GetVoiceState(data.UserId)?.Clone() ?? SocketVoiceState.Default; + after = groupChannel.AddOrUpdateVoiceState(State, data); + } + else + { + before = groupChannel.RemoveVoiceState(data.UserId) ?? SocketVoiceState.Default; + after = SocketVoiceState.Create(null, data); + } + user = groupChannel.GetUser(data.UserId); + if (user == null) + { + await UnknownChannelUserAsync(type, data.UserId, groupChannel.Id).ConfigureAwait(false); + return; + } + } + + if (user is SocketGuildUser guildUser && data.ChannelId.HasValue) + { + SocketStageChannel stage = guildUser.Guild.GetStageChannel(data.ChannelId.Value); + + if (stage != null && before.VoiceChannel != null && after.VoiceChannel != null) + { + if (!before.RequestToSpeakTimestamp.HasValue && after.RequestToSpeakTimestamp.HasValue) + { + await TimedInvokeAsync(_requestToSpeak, nameof(RequestToSpeak), stage, guildUser); + return; + } + if (before.IsSuppressed && !after.IsSuppressed) + { + await TimedInvokeAsync(_speakerAdded, nameof(SpeakerAdded), stage, guildUser); + return; + } + if (!before.IsSuppressed && after.IsSuppressed) + { + await TimedInvokeAsync(_speakerRemoved, nameof(SpeakerRemoved), stage, guildUser); + } + } + } + + await TimedInvokeAsync(_userVoiceStateUpdatedEvent, nameof(UserVoiceStateUpdated), user, before, after).ConfigureAwait(false); + } + break; + case "VOICE_SERVER_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (VOICE_SERVER_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + var isCached = guild != null; + var cachedGuild = new Cacheable(guild, data.GuildId, isCached, + () => Task.FromResult(State.GetGuild(data.GuildId) as IGuild)); + + var voiceServer = new SocketVoiceServer(cachedGuild, data.Endpoint, data.Token); + await TimedInvokeAsync(_voiceServerUpdatedEvent, nameof(UserVoiceStateUpdated), voiceServer).ConfigureAwait(false); + + if (isCached) + { + var endpoint = data.Endpoint; + + //Only strip out the port if the endpoint contains it + var portBegin = endpoint.LastIndexOf(':'); + if (portBegin > 0) + endpoint = endpoint.Substring(0, portBegin); + + var _ = guild.FinishConnectAudio(endpoint, data.Token).ConfigureAwait(false); + } + else + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + } + + } + break; + + case "VOICE_CHANNEL_STATUS_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (VOICE_CHANNEL_STATUS_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId); + + var channel = State.GetChannel(data.Id) as SocketVoiceChannel; + var channelCacheable = new Cacheable(channel, data.Id, channel is not null, () => null); + + var before = (string)channel?.Status?.Clone(); + var after = data.Status; + channel?.UpdateVoiceStatus(data.Status); + + await TimedInvokeAsync(_voiceChannelStatusUpdated, nameof(VoiceChannelStatusUpdated), channelCacheable, before, after); + } + break; + #endregion + + #region Invites + case "INVITE_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (INVITE_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (State.GetChannel(data.ChannelId) is SocketGuildChannel channel) + { + var guild = channel.Guild; + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + SocketGuildUser inviter = data.Inviter.IsSpecified + ? (guild.GetUser(data.Inviter.Value.Id) ?? guild.AddOrUpdateUser(data.Inviter.Value)) + : null; + + SocketUser target = data.TargetUser.IsSpecified + ? (guild.GetUser(data.TargetUser.Value.Id) ?? (SocketUser)SocketUnknownUser.Create(this, State, data.TargetUser.Value)) + : null; + + var invite = SocketInvite.Create(this, guild, channel, inviter, target, data); + + await TimedInvokeAsync(_inviteCreatedEvent, nameof(InviteCreated), invite).ConfigureAwait(false); + } + else + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + } + break; + case "INVITE_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (INVITE_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + if (State.GetChannel(data.ChannelId) is SocketGuildChannel channel) + { + var guild = channel.Guild; + if (!guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_inviteDeletedEvent, nameof(InviteDeleted), channel, data.Code).ConfigureAwait(false); + } + else + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + } + break; + #endregion + + #region Interactions + case "INTERACTION_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (INTERACTION_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = data.GuildId.IsSpecified ? GetGuild(data.GuildId.Value) : null; + + if (guild != null && !guild.IsSynced) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + } + + SocketUser user = data.User.IsSpecified + ? State.GetOrAddUser(data.User.Value.Id, (_) => SocketGlobalUser.Create(this, State, data.User.Value)) + : guild != null + ? guild.AddOrUpdateUser(data.Member.Value) // null if the bot scope isn't set, so the guild cannot be retrieved. + : State.GetOrAddUser(data.Member.Value.User.Id, (_) => SocketGlobalUser.Create(this, State, data.Member.Value.User)); + + SocketChannel channel = null; + if (data.ChannelId.IsSpecified) + { + channel = State.GetChannel(data.ChannelId.Value); + + if (channel == null) + { + if (!data.GuildId.IsSpecified) // assume it is a DM + { + channel = CreateDMChannel(data.ChannelId.Value, user, State); + } + + // The channel isn't required when responding to an interaction, so we can leave the channel null. + } + } + else if (data.User.IsSpecified) + { + channel = State.GetDMChannel(data.User.Value.Id); + } + + var interaction = SocketInteraction.Create(this, data, channel as ISocketMessageChannel, user); + + await TimedInvokeAsync(_interactionCreatedEvent, nameof(InteractionCreated), interaction).ConfigureAwait(false); + + switch (interaction) + { + case SocketSlashCommand slashCommand: + await TimedInvokeAsync(_slashCommandExecuted, nameof(SlashCommandExecuted), slashCommand).ConfigureAwait(false); + break; + case SocketMessageComponent messageComponent: + if (messageComponent.Data.Type.IsSelectType()) + await TimedInvokeAsync(_selectMenuExecuted, nameof(SelectMenuExecuted), messageComponent).ConfigureAwait(false); + if (messageComponent.Data.Type == ComponentType.Button) + await TimedInvokeAsync(_buttonExecuted, nameof(ButtonExecuted), messageComponent).ConfigureAwait(false); + break; + case SocketUserCommand userCommand: + await TimedInvokeAsync(_userCommandExecuted, nameof(UserCommandExecuted), userCommand).ConfigureAwait(false); + break; + case SocketMessageCommand messageCommand: + await TimedInvokeAsync(_messageCommandExecuted, nameof(MessageCommandExecuted), messageCommand).ConfigureAwait(false); + break; + case SocketAutocompleteInteraction autocomplete: + await TimedInvokeAsync(_autocompleteExecuted, nameof(AutocompleteExecuted), autocomplete).ConfigureAwait(false); + break; + case SocketModal modal: + await TimedInvokeAsync(_modalSubmitted, nameof(ModalSubmitted), modal).ConfigureAwait(false); + break; + } + } + break; + case "APPLICATION_COMMAND_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (APPLICATION_COMMAND_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + if (data.GuildId.IsSpecified) + { + var guild = State.GetGuild(data.GuildId.Value); + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + } + + var applicationCommand = SocketApplicationCommand.Create(this, data); + + State.AddCommand(applicationCommand); + + await TimedInvokeAsync(_applicationCommandCreated, nameof(ApplicationCommandCreated), applicationCommand).ConfigureAwait(false); + } + break; + case "APPLICATION_COMMAND_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (APPLICATION_COMMAND_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + if (data.GuildId.IsSpecified) + { + var guild = State.GetGuild(data.GuildId.Value); + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + } + + var applicationCommand = SocketApplicationCommand.Create(this, data); + + State.AddCommand(applicationCommand); + + await TimedInvokeAsync(_applicationCommandUpdated, nameof(ApplicationCommandUpdated), applicationCommand).ConfigureAwait(false); + } + break; + case "APPLICATION_COMMAND_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (APPLICATION_COMMAND_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + if (data.GuildId.IsSpecified) + { + var guild = State.GetGuild(data.GuildId.Value); + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + } + + var applicationCommand = SocketApplicationCommand.Create(this, data); + + State.RemoveCommand(applicationCommand.Id); + + await TimedInvokeAsync(_applicationCommandDeleted, nameof(ApplicationCommandDeleted), applicationCommand).ConfigureAwait(false); + } + break; + #endregion + + #region Threads + case "THREAD_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (THREAD_CREATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId.Value); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value); + return; + } + + SocketThreadChannel threadChannel = null; + + if ((threadChannel = guild.ThreadChannels.FirstOrDefault(x => x.Id == data.Id)) != null) + { + threadChannel.Update(State, data); + + if (data.ThreadMember.IsSpecified) + threadChannel.AddOrUpdateThreadMember(data.ThreadMember.Value, guild.CurrentUser); + } + else + { + threadChannel = (SocketThreadChannel)guild.AddChannel(State, data); + if (data.ThreadMember.IsSpecified) + threadChannel.AddOrUpdateThreadMember(data.ThreadMember.Value, guild.CurrentUser); + } + + await TimedInvokeAsync(_threadCreated, nameof(ThreadCreated), threadChannel).ConfigureAwait(false); + } + + break; + case "THREAD_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (THREAD_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + var guild = State.GetGuild(data.GuildId.Value); + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value); + return; + } + + var threadChannel = guild.ThreadChannels.FirstOrDefault(x => x.Id == data.Id); + var before = threadChannel != null + ? new Cacheable(threadChannel.Clone(), data.Id, true, () => Task.FromResult((SocketThreadChannel)null)) + : new Cacheable(null, data.Id, false, () => Task.FromResult((SocketThreadChannel)null)); + + if (threadChannel != null) + { + threadChannel.Update(State, data); + + if (data.ThreadMember.IsSpecified) + threadChannel.AddOrUpdateThreadMember(data.ThreadMember.Value, guild.CurrentUser); + } + else + { + //Thread is updated but was not cached, likely meaning the thread was unarchived. + threadChannel = (SocketThreadChannel)guild.AddChannel(State, data); + if (data.ThreadMember.IsSpecified) + threadChannel.AddOrUpdateThreadMember(data.ThreadMember.Value, guild.CurrentUser); + } + + if (!(guild?.IsSynced ?? true)) + { + await UnsyncedGuildAsync(type, guild.Id).ConfigureAwait(false); + return; + } + + await TimedInvokeAsync(_threadUpdated, nameof(ThreadUpdated), before, threadChannel).ConfigureAwait(false); + } + break; + case "THREAD_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (THREAD_DELETE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId.Value); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId.Value).ConfigureAwait(false); + return; + } + + var thread = (SocketThreadChannel)guild.RemoveChannel(State, data.Id); + + var cacheable = new Cacheable(thread, data.Id, thread != null, null); + + await TimedInvokeAsync(_threadDeleted, nameof(ThreadDeleted), cacheable).ConfigureAwait(false); + } + break; + case "THREAD_LIST_SYNC": + { + await _gatewayLogger.DebugAsync("Received Dispatch (THREAD_LIST_SYNC)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + foreach (var thread in data.Threads) + { + var entity = guild.ThreadChannels.FirstOrDefault(x => x.Id == thread.Id); + + if (entity == null) + { + entity = (SocketThreadChannel)guild.AddChannel(State, thread); + } + else + { + entity.Update(State, thread); + } + + foreach (var member in data.Members.Where(x => x.Id.Value == entity.Id)) + { + var guildMember = guild.GetUser(member.Id.Value); + + entity.AddOrUpdateThreadMember(member, guildMember); + } + } + } + break; + case "THREAD_MEMBER_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (THREAD_MEMBER_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var thread = (SocketThreadChannel)State.GetChannel(data.Id.Value); + + if (thread == null) + { + await UnknownChannelAsync(type, data.Id.Value); + return; + } + + thread.AddOrUpdateThreadMember(data, thread.Guild.CurrentUser); + } + + break; + case "THREAD_MEMBERS_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (THREAD_MEMBERS_UPDATE)").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var thread = (SocketThreadChannel)guild.GetChannel(data.Id); + + if (thread == null) + { + await UnknownChannelAsync(type, data.Id); + return; + } + + IReadOnlyCollection leftUsers = null; + IReadOnlyCollection joinUsers = null; + + + if (data.RemovedMemberIds.IsSpecified) + { + leftUsers = thread.RemoveUsers(data.RemovedMemberIds.Value); + } + + if (data.AddedMembers.IsSpecified) + { + List newThreadMembers = new List(); + foreach (var threadMember in data.AddedMembers.Value) + { + SocketGuildUser guildMember; + + guildMember = guild.GetUser(threadMember.UserId.Value); + + if (guildMember == null) + { + await UnknownGuildUserAsync("THREAD_MEMBERS_UPDATE", threadMember.UserId.Value, guild.Id); + } + else + newThreadMembers.Add(thread.AddOrUpdateThreadMember(threadMember, guildMember)); + } + + if (newThreadMembers.Any()) + joinUsers = newThreadMembers.ToImmutableArray(); + } + + if (leftUsers != null) + { + foreach (var threadUser in leftUsers) + { + await TimedInvokeAsync(_threadMemberLeft, nameof(ThreadMemberLeft), threadUser).ConfigureAwait(false); + } + } + + if (joinUsers != null) + { + foreach (var threadUser in joinUsers) + { + await TimedInvokeAsync(_threadMemberJoined, nameof(ThreadMemberJoined), threadUser).ConfigureAwait(false); + } + } + } + + break; + #endregion + + #region Stage Channels + case "STAGE_INSTANCE_CREATE" or "STAGE_INSTANCE_UPDATE" or "STAGE_INSTANCE_DELETE": + { + await _gatewayLogger.DebugAsync($"Received Dispatch ({type})").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var stageChannel = guild.GetStageChannel(data.ChannelId); + + if (stageChannel == null) + { + await UnknownChannelAsync(type, data.ChannelId).ConfigureAwait(false); + return; + } + + SocketStageChannel before = type == "STAGE_INSTANCE_UPDATE" ? stageChannel.Clone() : null; + + stageChannel.Update(data, type == "STAGE_INSTANCE_CREATE"); + + switch (type) + { + case "STAGE_INSTANCE_CREATE": + await TimedInvokeAsync(_stageStarted, nameof(StageStarted), stageChannel).ConfigureAwait(false); + return; + case "STAGE_INSTANCE_DELETE": + await TimedInvokeAsync(_stageEnded, nameof(StageEnded), stageChannel).ConfigureAwait(false); + return; + case "STAGE_INSTANCE_UPDATE": + await TimedInvokeAsync(_stageUpdated, nameof(StageUpdated), before, stageChannel).ConfigureAwait(false); + return; + } + } + break; + #endregion + + #region Guild Scheduled Events + case "GUILD_SCHEDULED_EVENT_CREATE": + { + await _gatewayLogger.DebugAsync($"Received Dispatch ({type})").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var newEvent = guild.AddOrUpdateEvent(data); + + await TimedInvokeAsync(_guildScheduledEventCreated, nameof(GuildScheduledEventCreated), newEvent).ConfigureAwait(false); + } + break; + case "GUILD_SCHEDULED_EVENT_UPDATE": + { + await _gatewayLogger.DebugAsync($"Received Dispatch ({type})").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var before = guild.GetEvent(data.Id)?.Clone(); + + var beforeCacheable = new Cacheable(before, data.Id, before != null, () => Task.FromResult((SocketGuildEvent)null)); + + var after = guild.AddOrUpdateEvent(data); + + if ((before != null ? before.Status != GuildScheduledEventStatus.Completed : true) && data.Status == GuildScheduledEventStatus.Completed) + { + await TimedInvokeAsync(_guildScheduledEventCompleted, nameof(GuildScheduledEventCompleted), after).ConfigureAwait(false); + } + else if ((before != null ? before.Status != GuildScheduledEventStatus.Active : false) && data.Status == GuildScheduledEventStatus.Active) + { + await TimedInvokeAsync(_guildScheduledEventStarted, nameof(GuildScheduledEventStarted), after).ConfigureAwait(false); + } + else + await TimedInvokeAsync(_guildScheduledEventUpdated, nameof(GuildScheduledEventUpdated), beforeCacheable, after).ConfigureAwait(false); + } + break; + case "GUILD_SCHEDULED_EVENT_DELETE": + { + await _gatewayLogger.DebugAsync($"Received Dispatch ({type})").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var guildEvent = guild.RemoveEvent(data.Id) ?? SocketGuildEvent.Create(this, guild, data); + + await TimedInvokeAsync(_guildScheduledEventCancelled, nameof(GuildScheduledEventCancelled), guildEvent).ConfigureAwait(false); + } + break; + case "GUILD_SCHEDULED_EVENT_USER_ADD" or "GUILD_SCHEDULED_EVENT_USER_REMOVE": + { + await _gatewayLogger.DebugAsync($"Received Dispatch ({type})").ConfigureAwait(false); + + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + if (guild == null) + { + await UnknownGuildAsync(type, data.GuildId).ConfigureAwait(false); + return; + } + + var guildEvent = guild.GetEvent(data.EventId); + + if (guildEvent == null) + { + await UnknownGuildEventAsync(type, data.EventId, data.GuildId).ConfigureAwait(false); + return; + } + + var user = (SocketUser)guild.GetUser(data.UserId) ?? State.GetUser(data.UserId); + + var cacheableUser = new Cacheable(user, data.UserId, user != null, () => Rest.GetUserAsync(data.UserId)); + + switch (type) + { + case "GUILD_SCHEDULED_EVENT_USER_ADD": + await TimedInvokeAsync(_guildScheduledEventUserAdd, nameof(GuildScheduledEventUserAdd), cacheableUser, guildEvent).ConfigureAwait(false); + break; + case "GUILD_SCHEDULED_EVENT_USER_REMOVE": + await TimedInvokeAsync(_guildScheduledEventUserRemove, nameof(GuildScheduledEventUserRemove), cacheableUser, guildEvent).ConfigureAwait(false); + break; + } + } + break; + + #endregion + + #region Webhooks + + case "WEBHOOKS_UPDATE": + { + var data = (payload as JToken).ToObject(_serializer); + type = "WEBHOOKS_UPDATE"; + await _gatewayLogger.DebugAsync("Received Dispatch (WEBHOOKS_UPDATE)").ConfigureAwait(false); + + var guild = State.GetGuild(data.GuildId); + var channel = State.GetChannel(data.ChannelId); + + await TimedInvokeAsync(_webhooksUpdated, nameof(WebhooksUpdated), guild, channel); + } + break; + + #endregion + + #region Audit Logs + + case "GUILD_AUDIT_LOG_ENTRY_CREATE": + { + var data = (payload as JToken).ToObject(_serializer); + type = "GUILD_AUDIT_LOG_ENTRY_CREATE"; + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_AUDIT_LOG_ENTRY_CREATE)").ConfigureAwait(false); + + var guild = State.GetGuild(data.GuildId); + var auditLog = SocketAuditLogEntry.Create(this, data); + guild.AddAuditLog(auditLog); + + await TimedInvokeAsync(_auditLogCreated, nameof(AuditLogCreated), auditLog, guild); + } + break; + #endregion + + #region Auto Moderation + + case "AUTO_MODERATION_RULE_CREATE": + { + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + var rule = guild.AddOrUpdateAutoModRule(data); + + await TimedInvokeAsync(_autoModRuleCreated, nameof(AutoModRuleCreated), rule); + } + break; + + case "AUTO_MODERATION_RULE_UPDATE": + { + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + var cachedRule = guild.GetAutoModRule(data.Id); + var cacheableBefore = new Cacheable(cachedRule?.Clone(), + data.Id, + cachedRule is not null, + async () => await guild.GetAutoModRuleAsync(data.Id)); + + await TimedInvokeAsync(_autoModRuleUpdated, nameof(AutoModRuleUpdated), cacheableBefore, guild.AddOrUpdateAutoModRule(data)); + } + break; + + case "AUTO_MODERATION_RULE_DELETE": + { + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + + var rule = guild.RemoveAutoModRule(data); + + await TimedInvokeAsync(_autoModRuleDeleted, nameof(AutoModRuleDeleted), rule); + } + break; + + case "AUTO_MODERATION_ACTION_EXECUTION": + { + var data = (payload as JToken).ToObject(_serializer); + + var guild = State.GetGuild(data.GuildId); + var action = new AutoModRuleAction(data.Action.Type, + data.Action.Metadata.IsSpecified + ? data.Action.Metadata.Value.ChannelId.IsSpecified + ? data.Action.Metadata.Value.ChannelId.Value + : null + : null, + data.Action.Metadata.IsSpecified + ? data.Action.Metadata.Value.DurationSeconds.IsSpecified + ? data.Action.Metadata.Value.DurationSeconds.Value + : null + : null, + data.Action.Metadata.IsSpecified + ? data.Action.Metadata.Value.CustomMessage.IsSpecified + ? data.Action.Metadata.Value.CustomMessage.Value + : null + : null); + + + var member = guild.GetUser(data.UserId); + + var cacheableUser = new Cacheable(member, + data.UserId, + member is not null, + async () => + { + var model = await ApiClient.GetGuildMemberAsync(data.GuildId, data.UserId); + return guild.AddOrUpdateUser(model); + } + ); + + ISocketMessageChannel channel = null; + if (data.ChannelId.IsSpecified) + channel = GetChannel(data.ChannelId.Value) as ISocketMessageChannel; + + var cacheableChannel = new Cacheable(channel, + data.ChannelId.GetValueOrDefault(0), + channel != null, + async () => + { + if (data.ChannelId.IsSpecified) + return await GetChannelAsync(data.ChannelId.Value).ConfigureAwait(false) as ISocketMessageChannel; + return null; + }); + + + IUserMessage cachedMsg = null; + if (data.MessageId.IsSpecified) + cachedMsg = channel?.GetCachedMessage(data.MessageId.GetValueOrDefault(0)) as IUserMessage; + + var cacheableMessage = new Cacheable(cachedMsg, + data.MessageId.GetValueOrDefault(0), + cachedMsg is not null, + async () => + { + if (data.MessageId.IsSpecified) + return (await channel!.GetMessageAsync(data.MessageId.Value).ConfigureAwait(false)) as IUserMessage; + return null; + }); + + var cachedRule = guild.GetAutoModRule(data.RuleId); + + var cacheableRule = new Cacheable(cachedRule, + data.RuleId, + cachedRule is not null, + async () => await guild.GetAutoModRuleAsync(data.RuleId)); + + var eventData = new AutoModActionExecutedData( + cacheableRule, + data.TriggerType, + cacheableUser, + cacheableChannel, + data.MessageId.IsSpecified ? cacheableMessage : null, + data.AlertSystemMessageId.GetValueOrDefault(0), + data.Content, + data.MatchedContent.IsSpecified + ? data.MatchedContent.Value + : null, + data.MatchedKeyword.IsSpecified + ? data.MatchedKeyword.Value + : null); + + await TimedInvokeAsync(_autoModActionExecuted, nameof(AutoModActionExecuted), guild, action, eventData); + } + break; + + #endregion + + #region App Subscriptions + + case "ENTITLEMENT_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (ENTITLEMENT_CREATE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var entitlement = SocketEntitlement.Create(this, data); + State.AddEntitlement(data.Id, entitlement); + + await TimedInvokeAsync(_entitlementCreated, nameof(EntitlementCreated), entitlement); + } + break; + + case "ENTITLEMENT_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (ENTITLEMENT_UPDATE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var entitlement = State.GetEntitlement(data.Id); + + var cacheableBefore = new Cacheable(entitlement?.Clone(), data.Id, + entitlement is not null, () => null); + + if (entitlement is null) + { + entitlement = SocketEntitlement.Create(this, data); + State.AddEntitlement(data.Id, entitlement); + } + else + { + entitlement.Update(data); + } + + await TimedInvokeAsync(_entitlementUpdated, nameof(EntitlementUpdated), cacheableBefore, entitlement); + } + break; + + case "ENTITLEMENT_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (ENTITLEMENT_DELETE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var entitlement = State.RemoveEntitlement(data.Id); + + if (entitlement is null) + entitlement = SocketEntitlement.Create(this, data); + else + entitlement.Update(data); + + var cacheableEntitlement = new Cacheable(entitlement, data.Id, + entitlement is not null, () => null); + + await TimedInvokeAsync(_entitlementDeleted, nameof(EntitlementDeleted), cacheableEntitlement); + } + break; + + #endregion + + #region Ignored (User only) + case "CHANNEL_PINS_ACK": + await _gatewayLogger.DebugAsync("Ignored Dispatch (CHANNEL_PINS_ACK)").ConfigureAwait(false); + break; + case "CHANNEL_PINS_UPDATE": + await _gatewayLogger.DebugAsync("Ignored Dispatch (CHANNEL_PINS_UPDATE)").ConfigureAwait(false); + break; + case "GUILD_INTEGRATIONS_UPDATE": + await _gatewayLogger.DebugAsync("Ignored Dispatch (GUILD_INTEGRATIONS_UPDATE)").ConfigureAwait(false); + break; + case "MESSAGE_ACK": + await _gatewayLogger.DebugAsync("Ignored Dispatch (MESSAGE_ACK)").ConfigureAwait(false); + break; + case "PRESENCES_REPLACE": + await _gatewayLogger.DebugAsync("Ignored Dispatch (PRESENCES_REPLACE)").ConfigureAwait(false); + break; + case "USER_SETTINGS_UPDATE": + await _gatewayLogger.DebugAsync("Ignored Dispatch (USER_SETTINGS_UPDATE)").ConfigureAwait(false); + break; + #endregion + + #region Others + default: + if (!SuppressUnknownDispatchWarnings) + await _gatewayLogger.WarningAsync($"Unknown Dispatch ({type})").ConfigureAwait(false); + break; + #endregion + } + break; + default: + await _gatewayLogger.WarningAsync($"Unknown OpCode ({opCode})").ConfigureAwait(false); + break; + } + } + catch (Exception ex) + { + await _gatewayLogger.ErrorAsync($"Error handling {opCode}{(type != null ? $" ({type})" : "")}", ex).ConfigureAwait(false); + } + } + #endregion + + private async Task RunHeartbeatAsync(int intervalMillis, CancellationToken cancelToken) + { + int delayInterval = (int)(intervalMillis * DiscordConfig.HeartbeatIntervalFactor); + + try + { + await _gatewayLogger.DebugAsync("Heartbeat Started").ConfigureAwait(false); + while (!cancelToken.IsCancellationRequested) + { + int now = Environment.TickCount; + + //Did server respond to our last heartbeat, or are we still receiving messages (long load?) + if (_heartbeatTimes.Count != 0 && (now - _lastMessageTime) > intervalMillis) + { + if (ConnectionState == ConnectionState.Connected && (_guildDownloadTask?.IsCompleted ?? true)) + { + _connection.Error(new GatewayReconnectException("Server missed last heartbeat")); + return; + } + } + + _heartbeatTimes.Enqueue(now); + try + { + await ApiClient.SendHeartbeatAsync(_lastSeq).ConfigureAwait(false); + } + catch (Exception ex) + { + await _gatewayLogger.WarningAsync("Heartbeat Errored", ex).ConfigureAwait(false); + } + + int delay = Math.Max(0, delayInterval - Latency); + await Task.Delay(delay, cancelToken).ConfigureAwait(false); + } + await _gatewayLogger.DebugAsync("Heartbeat Stopped").ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await _gatewayLogger.DebugAsync("Heartbeat Stopped").ConfigureAwait(false); + } + catch (Exception ex) + { + await _gatewayLogger.ErrorAsync("Heartbeat Errored", ex).ConfigureAwait(false); + } + } + /*public async Task WaitForGuildsAsync() + { + var downloadTask = _guildDownloadTask; + if (downloadTask != null) + await _guildDownloadTask.ConfigureAwait(false); + }*/ + private async Task WaitForGuildsAsync(CancellationToken cancelToken, Logger logger) + { + //Wait for GUILD_AVAILABLEs + try + { + await logger.DebugAsync("GuildDownloader Started").ConfigureAwait(false); + while ((_unavailableGuildCount != 0) && (Environment.TickCount - _lastGuildAvailableTime < BaseConfig.MaxWaitBetweenGuildAvailablesBeforeReady)) + await Task.Delay(500, cancelToken).ConfigureAwait(false); + await logger.DebugAsync("GuildDownloader Stopped").ConfigureAwait(false); + } + catch (OperationCanceledException) + { + await logger.DebugAsync("GuildDownloader Stopped").ConfigureAwait(false); + } + catch (Exception ex) + { + await logger.ErrorAsync("GuildDownloader Errored", ex).ConfigureAwait(false); + } + } + + private Task SyncGuildsAsync() + { + var guildIds = Guilds.Where(x => !x.IsSynced).Select(x => x.Id).ToImmutableArray(); + if (guildIds.Length > 0) + return ApiClient.SendGuildSyncAsync(guildIds); + + return Task.CompletedTask; + } + + internal SocketGuild AddGuild(ExtendedGuild model, ClientState state) + { + var guild = SocketGuild.Create(this, state, model); + state.AddGuild(guild); + if (model.Large) + _largeGuilds.Enqueue(model.Id); + return guild; + } + internal SocketGuild RemoveGuild(ulong id) + => State.RemoveGuild(id); + + /// Unexpected channel type is created. + internal ISocketPrivateChannel AddPrivateChannel(API.Channel model, ClientState state) + { + var channel = SocketChannel.CreatePrivate(this, state, model); + state.AddChannel(channel as SocketChannel); + return channel; + } + internal SocketDMChannel CreateDMChannel(ulong channelId, API.User model, ClientState state) + { + return SocketDMChannel.Create(this, state, channelId, model); + } + internal SocketDMChannel CreateDMChannel(ulong channelId, SocketUser user, ClientState state) + { + return new SocketDMChannel(this, channelId, user); + } + internal ISocketPrivateChannel RemovePrivateChannel(ulong id) + { + var channel = State.RemoveChannel(id) as ISocketPrivateChannel; + if (channel != null) + { + foreach (var recipient in channel.Recipients) + recipient.GlobalUser.RemoveRef(this); + } + return channel; + } + internal void RemoveDMChannels() + { + var channels = State.DMChannels; + State.PurgeDMChannels(); + foreach (var channel in channels) + channel.Recipient.GlobalUser.RemoveRef(this); + } + + internal void EnsureGatewayIntent(GatewayIntents intents) + { + if (!_gatewayIntents.HasFlag(intents)) + { + var vals = Enum.GetValues(typeof(GatewayIntents)).Cast(); + + var missingValues = vals.Where(x => intents.HasFlag(x) && !_gatewayIntents.HasFlag(x)); + + throw new InvalidOperationException($"Missing required gateway intent{(missingValues.Count() > 1 ? "s" : "")} {string.Join(", ", missingValues.Select(x => x.ToString()))} in order to execute this operation."); + } + } + + internal bool HasGatewayIntent(GatewayIntents intents) + => _gatewayIntents.HasFlag(intents); + + private Task GuildAvailableAsync(SocketGuild guild) + { + if (!guild.IsConnected) + { + guild.IsConnected = true; + return TimedInvokeAsync(_guildAvailableEvent, nameof(GuildAvailable), guild); + } + + return Task.CompletedTask; + } + + private Task GuildUnavailableAsync(SocketGuild guild) + { + if (guild.IsConnected) + { + guild.IsConnected = false; + return TimedInvokeAsync(_guildUnavailableEvent, nameof(GuildUnavailable), guild); + } + + return Task.CompletedTask; + } + + private Task TimedInvokeAsync(AsyncEvent> eventHandler, string name) + { + if (eventHandler.HasSubscribers) + { + if (HandlerTimeout.HasValue) + return TimeoutWrap(name, eventHandler.InvokeAsync); + else + return eventHandler.InvokeAsync(); + } + + return Task.CompletedTask; + } + + private Task TimedInvokeAsync(AsyncEvent> eventHandler, string name, T arg) + { + if (eventHandler.HasSubscribers) + { + if (HandlerTimeout.HasValue) + return TimeoutWrap(name, () => eventHandler.InvokeAsync(arg)); + else + return eventHandler.InvokeAsync(arg); + } + + return Task.CompletedTask; + } + + private Task TimedInvokeAsync(AsyncEvent> eventHandler, string name, T1 arg1, T2 arg2) + { + if (eventHandler.HasSubscribers) + { + if (HandlerTimeout.HasValue) + return TimeoutWrap(name, () => eventHandler.InvokeAsync(arg1, arg2)); + else + return eventHandler.InvokeAsync(arg1, arg2); + } + + return Task.CompletedTask; + } + + private Task TimedInvokeAsync(AsyncEvent> eventHandler, string name, T1 arg1, T2 arg2, T3 arg3) + { + if (eventHandler.HasSubscribers) + { + if (HandlerTimeout.HasValue) + return TimeoutWrap(name, () => eventHandler.InvokeAsync(arg1, arg2, arg3)); + else + return eventHandler.InvokeAsync(arg1, arg2, arg3); + } + + return Task.CompletedTask; + } + + private Task TimedInvokeAsync(AsyncEvent> eventHandler, string name, T1 arg1, T2 arg2, T3 arg3, T4 arg4) + { + if (eventHandler.HasSubscribers) + { + if (HandlerTimeout.HasValue) + return TimeoutWrap(name, () => eventHandler.InvokeAsync(arg1, arg2, arg3, arg4)); + else + return eventHandler.InvokeAsync(arg1, arg2, arg3, arg4); + } + + return Task.CompletedTask; + } + + private Task TimedInvokeAsync(AsyncEvent> eventHandler, string name, T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5) + { + if (eventHandler.HasSubscribers) + { + if (HandlerTimeout.HasValue) + return TimeoutWrap(name, () => eventHandler.InvokeAsync(arg1, arg2, arg3, arg4, arg5)); + else + return eventHandler.InvokeAsync(arg1, arg2, arg3, arg4, arg5); + } + + return Task.CompletedTask; + } + + private async Task TimeoutWrap(string name, Func action) + { + try + { + var timeoutTask = Task.Delay(HandlerTimeout.Value); + var handlersTask = action(); + if (await Task.WhenAny(timeoutTask, handlersTask).ConfigureAwait(false) == timeoutTask) + { + await _gatewayLogger.WarningAsync($"A {name} handler is blocking the gateway task.").ConfigureAwait(false); + } + await handlersTask.ConfigureAwait(false); //Ensure the handler completes + } + catch (Exception ex) + { + await _gatewayLogger.WarningAsync($"A {name} handler has thrown an unhandled exception.", ex).ConfigureAwait(false); + } + } + + private Task UnknownGlobalUserAsync(string evnt, ulong userId) + { + string details = $"{evnt} User={userId}"; + return _gatewayLogger.WarningAsync($"Unknown User ({details})."); + } + + private Task UnknownChannelUserAsync(string evnt, ulong userId, ulong channelId) + { + string details = $"{evnt} User={userId} Channel={channelId}"; + return _gatewayLogger.WarningAsync($"Unknown User ({details})."); + } + + private Task UnknownGuildUserAsync(string evnt, ulong userId, ulong guildId) + { + string details = $"{evnt} User={userId} Guild={guildId}"; + return _gatewayLogger.WarningAsync($"Unknown User ({details})."); + } + + private Task IncompleteGuildUserAsync(string evnt, ulong userId, ulong guildId) + { + string details = $"{evnt} User={userId} Guild={guildId}"; + return _gatewayLogger.DebugAsync($"User has not been downloaded ({details})."); + } + + private Task UnknownChannelAsync(string evnt, ulong channelId) + { + string details = $"{evnt} Channel={channelId}"; + return _gatewayLogger.WarningAsync($"Unknown Channel ({details})."); + } + + private Task UnknownChannelAsync(string evnt, ulong channelId, ulong guildId) + { + if (guildId == 0) + { + return UnknownChannelAsync(evnt, channelId); + } + string details = $"{evnt} Channel={channelId} Guild={guildId}"; + return _gatewayLogger.WarningAsync($"Unknown Channel ({details})."); + } + + private Task UnknownRoleAsync(string evnt, ulong roleId, ulong guildId) + { + string details = $"{evnt} Role={roleId} Guild={guildId}"; + return _gatewayLogger.WarningAsync($"Unknown Role ({details})."); + } + + private Task UnknownGuildAsync(string evnt, ulong guildId) + { + string details = $"{evnt} Guild={guildId}"; + return _gatewayLogger.WarningAsync($"Unknown Guild ({details})."); + } + + private Task UnknownGuildEventAsync(string evnt, ulong eventId, ulong guildId) + { + string details = $"{evnt} Event={eventId} Guild={guildId}"; + return _gatewayLogger.WarningAsync($"Unknown Guild Event ({details})."); + } + + private Task UnsyncedGuildAsync(string evnt, ulong guildId) + { + string details = $"{evnt} Guild={guildId}"; + return _gatewayLogger.DebugAsync($"Unsynced Guild ({details})."); + } + + internal int GetAudioId() => _nextAudioId++; + + #region IDiscordClient + + async Task IDiscordClient.CreateTestEntitlementAsync(ulong skuId, ulong ownerId, SubscriptionOwnerType ownerType, RequestOptions options) + => await CreateTestEntitlementAsync(skuId, ownerId, ownerType, options).ConfigureAwait(false); + /// + async Task IDiscordClient.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => mode == CacheMode.AllowDownload ? await GetChannelAsync(id, options).ConfigureAwait(false) : GetChannel(id); + /// + Task> IDiscordClient.GetPrivateChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(PrivateChannels); + /// + Task> IDiscordClient.GetDMChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(DMChannels); + /// + Task> IDiscordClient.GetGroupChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(GroupChannels); + + /// + async Task> IDiscordClient.GetConnectionsAsync(RequestOptions options) + => await GetConnectionsAsync().ConfigureAwait(false); + + /// + async Task IDiscordClient.GetInviteAsync(string inviteId, RequestOptions options) + => await GetInviteAsync(inviteId, options).ConfigureAwait(false); + + /// + Task IDiscordClient.GetGuildAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetGuild(id)); + /// + Task> IDiscordClient.GetGuildsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(Guilds); + /// + async Task IDiscordClient.CreateGuildAsync(string name, IVoiceRegion region, Stream jpegIcon, RequestOptions options) + => await CreateGuildAsync(name, region, jpegIcon).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + var user = GetUser(id); + if (user is not null || mode == CacheMode.CacheOnly) + return user; + + return await Rest.GetUserAsync(id, options).ConfigureAwait(false); + } + + /// + Task IDiscordClient.GetUserAsync(string username, string discriminator, RequestOptions options) + => Task.FromResult(GetUser(username, discriminator)); + + /// + async Task> IDiscordClient.GetVoiceRegionsAsync(RequestOptions options) + => await GetVoiceRegionsAsync(options).ConfigureAwait(false); + /// + async Task IDiscordClient.GetVoiceRegionAsync(string id, RequestOptions options) + => await GetVoiceRegionAsync(id, options).ConfigureAwait(false); + + /// + async Task IDiscordClient.GetGlobalApplicationCommandAsync(ulong id, RequestOptions options) + => await GetGlobalApplicationCommandAsync(id, options); + /// + async Task> IDiscordClient.GetGlobalApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) + => await GetGlobalApplicationCommandsAsync(withLocalizations, locale, options); + /// + async Task IDiscordClient.CreateGlobalApplicationCommand(ApplicationCommandProperties properties, RequestOptions options) + => await CreateGlobalApplicationCommandAsync(properties, options).ConfigureAwait(false); + /// + async Task> IDiscordClient.BulkOverwriteGlobalApplicationCommand(ApplicationCommandProperties[] properties, RequestOptions options) + => await BulkOverwriteGlobalApplicationCommandsAsync(properties, options); + + /// + Task IDiscordClient.StartAsync() + => StartAsync(); + + /// + Task IDiscordClient.StopAsync() + => StopAsync(); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/DiscordSocketConfig.cs b/src/Discord.Net.WebSocket/DiscordSocketConfig.cs new file mode 100644 index 0000000..21f21a2 --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordSocketConfig.cs @@ -0,0 +1,213 @@ +using Discord.Net.Udp; +using Discord.Net.WebSockets; +using Discord.Rest; + +namespace Discord.WebSocket +{ + /// + /// Represents a configuration class for . + /// + /// + /// This configuration, based on , helps determine several key configurations the + /// socket client depend on. For instance, shards and connection timeout. + /// + /// + /// The following config enables the message cache and configures the client to always download user upon guild + /// availability. + /// + /// var config = new DiscordSocketConfig + /// { + /// AlwaysDownloadUsers = true, + /// MessageCacheSize = 100 + /// }; + /// var client = new DiscordSocketClient(config); + /// + /// + public class DiscordSocketConfig : DiscordRestConfig + { + /// + /// Returns the encoding gateway should use. + /// + public const string GatewayEncoding = "json"; + + /// + /// Gets or sets the WebSocket host to connect to. If , the client will use the + /// /gateway endpoint. + /// + public string GatewayHost { get; set; } = null; + + /// + /// Gets or sets the time, in milliseconds, to wait for a connection to complete before aborting. + /// + public int ConnectionTimeout { get; set; } = 30000; + + /// + /// Gets or sets the ID for this shard. Must be less than . + /// + public int? ShardId { get; set; } = null; + + /// + /// Gets or sets the total number of shards for this application. + /// + /// + /// If this is left in a sharded client the bot will get the recommended shard + /// count from discord and use that. + /// + public int? TotalShards { get; set; } = null; + + /// + /// Gets or sets whether or not the client should download the default stickers on startup. + /// + /// + /// When this is set to default stickers aren't present and cannot be resolved by the client. + /// This will make all default stickers have the type of . + /// + public bool AlwaysDownloadDefaultStickers { get; set; } = false; + + /// + /// Gets or sets whether or not the client should automatically resolve the stickers sent on a message. + /// + /// + /// Note if a sticker isn't cached the client will preform a rest request to resolve it. This + /// may be very rest heavy depending on your bots size, it isn't recommended to use this with large scale bots as you + /// can get ratelimited easily. + /// + public bool AlwaysResolveStickers { get; set; } = false; + + /// + /// Gets or sets the number of messages per channel that should be kept in cache. Setting this to zero + /// disables the message cache entirely. + /// + public int MessageCacheSize { get; set; } = 0; + + /// + /// Gets or sets the number of audit logs per guild that should be kept in cache. Setting this to zero + /// disables the audit log cache entirely. + /// + public int AuditLogCacheSize { get; set; } = 0; + + /// + /// Gets or sets the max number of users a guild may have for offline users to be included in the READY + /// packet. The maximum value allowed is 250. + /// + public int LargeThreshold { get; set; } = 250; + + /// + /// Gets or sets the provider used to generate new WebSocket connections. + /// + public WebSocketProvider WebSocketProvider { get; set; } + + /// + /// Gets or sets the provider used to generate new UDP sockets. + /// + public UdpSocketProvider UdpSocketProvider { get; set; } + + /// + /// Gets or sets whether or not all users should be downloaded as guilds come available. + /// + /// + /// + /// By default, the Discord gateway will only send offline members if a guild has less than a certain number + /// of members (determined by in this library). This behavior is why + /// sometimes a user may be missing from the WebSocket cache for collections such as + /// . + /// + /// + /// This property ensures that whenever a guild becomes available (determined by + /// ), incomplete user chunks will be + /// downloaded to the WebSocket cache. + /// + /// + /// For more information, please see + /// Request Guild Members + /// on the official Discord API documentation. + /// + /// + /// Please note that it can be difficult to fill the cache completely on large guilds depending on the + /// traffic. If you are using the command system, the default user TypeReader may fail to find the user + /// due to this issue. This may be resolved at v3 of the library. Until then, you may want to consider + /// overriding the TypeReader and use + /// + /// or + /// as a backup. + /// + /// + public bool AlwaysDownloadUsers { get; set; } = false; + + /// + /// Gets or sets the timeout for event handlers, in milliseconds, after which a warning will be logged. + /// Setting this property to disables this check. + /// + public int? HandlerTimeout { get; set; } = 3000; + + /// + /// Gets or sets the maximum identify concurrency. + /// + /// + /// This information is provided by Discord. + /// It is only used when using a and auto-sharding is disabled. + /// + public int IdentifyMaxConcurrency { get; set; } = 1; + + /// + /// Gets or sets the maximum wait time in milliseconds between GUILD_AVAILABLE events before firing READY. + /// If zero, READY will fire as soon as it is received and all guilds will be unavailable. + /// + /// + /// This property is measured in milliseconds; negative values will throw an exception. + /// If a guild is not received before READY, it will be unavailable. + /// + /// + /// A representing the maximum wait time in milliseconds between GUILD_AVAILABLE events + /// before firing READY. + /// + /// Value must be at least 0. + public int MaxWaitBetweenGuildAvailablesBeforeReady + { + get + { + return maxWaitForGuildAvailable; + } + + set + { + Preconditions.AtLeast(value, 0, nameof(MaxWaitBetweenGuildAvailablesBeforeReady)); + maxWaitForGuildAvailable = value; + } + } + + private int maxWaitForGuildAvailable = 10000; + + /// + /// Gets or sets gateway intents to limit what events are sent from Discord. + /// The default is . + /// + /// + /// For more information, please see + /// GatewayIntents + /// on the official Discord API documentation. + /// + public GatewayIntents GatewayIntents { get; set; } = GatewayIntents.AllUnprivileged; + + /// + /// Gets or sets whether or not to log warnings related to guild intents and events. + /// + public bool LogGatewayIntentWarnings { get; set; } = true; + + /// + /// Gets or sets whether or not Unknown Dispatch event messages should be logged. + /// + public bool SuppressUnknownDispatchWarnings { get; set; } = true; + + /// + /// Initializes a new instance of the class with the default configuration. + /// + public DiscordSocketConfig() + { + WebSocketProvider = DefaultWebSocketProvider.Instance; + UdpSocketProvider = DefaultUdpSocketProvider.Instance; + } + + internal DiscordSocketConfig Clone() => MemberwiseClone() as DiscordSocketConfig; + } +} diff --git a/src/Discord.Net.WebSocket/DiscordSocketRestClient.cs b/src/Discord.Net.WebSocket/DiscordSocketRestClient.cs new file mode 100644 index 0000000..0fc870d --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordSocketRestClient.cs @@ -0,0 +1,20 @@ +using Discord.Rest; +using System; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + public class DiscordSocketRestClient : DiscordRestClient + { + internal DiscordSocketRestClient(DiscordRestConfig config, API.DiscordRestApiClient api) : base(config, api) { } + + public new Task LoginAsync(TokenType tokenType, string token, bool validateToken = true) + => throw new NotSupportedException("The Socket REST wrapper cannot be used to log in or out."); + internal override Task LoginInternalAsync(TokenType tokenType, string token, bool validateToken) + => throw new NotSupportedException("The Socket REST wrapper cannot be used to log in or out."); + public new Task LogoutAsync() + => throw new NotSupportedException("The Socket REST wrapper cannot be used to log in or out."); + internal override Task LogoutInternalAsync() + => throw new NotSupportedException("The Socket REST wrapper cannot be used to log in or out."); + } +} diff --git a/src/Discord.Net.WebSocket/DiscordVoiceApiClient.cs b/src/Discord.Net.WebSocket/DiscordVoiceApiClient.cs new file mode 100644 index 0000000..cc810d4 --- /dev/null +++ b/src/Discord.Net.WebSocket/DiscordVoiceApiClient.cs @@ -0,0 +1,293 @@ +using Discord.API; +using Discord.API.Voice; +using Discord.Net.Converters; +using Discord.Net.Udp; +using Discord.Net.WebSockets; +using Newtonsoft.Json; +using System; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Audio +{ + internal class DiscordVoiceAPIClient : IDisposable + { + #region DiscordVoiceAPIClient + public const int MaxBitrate = 128 * 1024; + public const string Mode = "xsalsa20_poly1305"; + + public event Func SentRequest { add { _sentRequestEvent.Add(value); } remove { _sentRequestEvent.Remove(value); } } + private readonly AsyncEvent> _sentRequestEvent = new AsyncEvent>(); + public event Func SentGatewayMessage { add { _sentGatewayMessageEvent.Add(value); } remove { _sentGatewayMessageEvent.Remove(value); } } + private readonly AsyncEvent> _sentGatewayMessageEvent = new AsyncEvent>(); + public event Func SentDiscovery { add { _sentDiscoveryEvent.Add(value); } remove { _sentDiscoveryEvent.Remove(value); } } + private readonly AsyncEvent> _sentDiscoveryEvent = new AsyncEvent>(); + public event Func SentData { add { _sentDataEvent.Add(value); } remove { _sentDataEvent.Remove(value); } } + private readonly AsyncEvent> _sentDataEvent = new AsyncEvent>(); + + public event Func ReceivedEvent { add { _receivedEvent.Add(value); } remove { _receivedEvent.Remove(value); } } + private readonly AsyncEvent> _receivedEvent = new AsyncEvent>(); + public event Func ReceivedPacket { add { _receivedPacketEvent.Add(value); } remove { _receivedPacketEvent.Remove(value); } } + private readonly AsyncEvent> _receivedPacketEvent = new AsyncEvent>(); + public event Func Disconnected { add { _disconnectedEvent.Add(value); } remove { _disconnectedEvent.Remove(value); } } + private readonly AsyncEvent> _disconnectedEvent = new AsyncEvent>(); + + private readonly JsonSerializer _serializer; + private readonly SemaphoreSlim _connectionLock; + private readonly IUdpSocket _udp; + private CancellationTokenSource _connectCancelToken; + private bool _isDisposed; + private ulong _nextKeepalive; + + public ulong GuildId { get; } + internal IWebSocketClient WebSocketClient { get; } + public ConnectionState ConnectionState { get; private set; } + + public ushort UdpPort => _udp.Port; + + internal DiscordVoiceAPIClient(ulong guildId, WebSocketProvider webSocketProvider, UdpSocketProvider udpSocketProvider, JsonSerializer serializer = null) + { + GuildId = guildId; + _connectionLock = new SemaphoreSlim(1, 1); + _udp = udpSocketProvider(); + _udp.ReceivedDatagram += (data, index, count) => + { + if (index != 0 || count != data.Length) + { + var newData = new byte[count]; + Buffer.BlockCopy(data, index, newData, 0, count); + data = newData; + } + return _receivedPacketEvent.InvokeAsync(data); + }; + + WebSocketClient = webSocketProvider(); + //_gatewayClient.SetHeader("user-agent", DiscordConfig.UserAgent); //(Causes issues in .Net 4.6+) + WebSocketClient.BinaryMessage += async (data, index, count) => + { + using (var compressed = new MemoryStream(data, index + 2, count - 2)) + using (var decompressed = new MemoryStream()) + { + using (var zlib = new DeflateStream(compressed, CompressionMode.Decompress)) + zlib.CopyTo(decompressed); + decompressed.Position = 0; + using (var reader = new StreamReader(decompressed)) + { + var msg = JsonConvert.DeserializeObject(reader.ReadToEnd()); + await _receivedEvent.InvokeAsync((VoiceOpCode)msg.Operation, msg.Payload).ConfigureAwait(false); + } + } + }; + WebSocketClient.TextMessage += text => + { + var msg = JsonConvert.DeserializeObject(text); + return _receivedEvent.InvokeAsync((VoiceOpCode)msg.Operation, msg.Payload); + }; + WebSocketClient.Closed += async ex => + { + await DisconnectAsync().ConfigureAwait(false); + await _disconnectedEvent.InvokeAsync(ex).ConfigureAwait(false); + }; + + _serializer = serializer ?? new JsonSerializer { ContractResolver = new DiscordContractResolver() }; + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _connectCancelToken?.Dispose(); + _udp?.Dispose(); + WebSocketClient?.Dispose(); + _connectionLock?.Dispose(); + } + _isDisposed = true; + } + } + public void Dispose() => Dispose(true); + + public async Task SendAsync(VoiceOpCode opCode, object payload, RequestOptions options = null) + { + byte[] bytes = null; + payload = new SocketFrame { Operation = (int)opCode, Payload = payload }; + if (payload != null) + bytes = Encoding.UTF8.GetBytes(SerializeJson(payload)); + await WebSocketClient.SendAsync(bytes, 0, bytes.Length, true).ConfigureAwait(false); + await _sentGatewayMessageEvent.InvokeAsync(opCode).ConfigureAwait(false); + } + public async Task SendAsync(byte[] data, int offset, int bytes) + { + await _udp.SendAsync(data, offset, bytes).ConfigureAwait(false); + await _sentDataEvent.InvokeAsync(bytes).ConfigureAwait(false); + } + #endregion + + #region WebSocket + public Task SendHeartbeatAsync(RequestOptions options = null) + => SendAsync(VoiceOpCode.Heartbeat, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), options: options); + + public Task SendIdentityAsync(ulong userId, string sessionId, string token) + { + return SendAsync(VoiceOpCode.Identify, new IdentifyParams + { + GuildId = GuildId, + UserId = userId, + SessionId = sessionId, + Token = token + }); + } + + public Task SendSelectProtocol(string externalIp) + { + return SendAsync(VoiceOpCode.SelectProtocol, new SelectProtocolParams + { + Protocol = "udp", + Data = new UdpProtocolInfo + { + Address = externalIp, + Port = UdpPort, + Mode = Mode + } + }); + } + + public Task SendSetSpeaking(bool value) + { + return SendAsync(VoiceOpCode.Speaking, new SpeakingParams + { + IsSpeaking = value, + Delay = 0 + }); + } + + public Task SendResume(string token, string sessionId) + { + return SendAsync(VoiceOpCode.Resume, new ResumeParams + { + ServerId = GuildId, + SessionId = sessionId, + Token = token + }); + } + + public async Task ConnectAsync(string url) + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(url).ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + + private async Task ConnectInternalAsync(string url) + { + ConnectionState = ConnectionState.Connecting; + try + { + _connectCancelToken?.Dispose(); + _connectCancelToken = new CancellationTokenSource(); + var cancelToken = _connectCancelToken.Token; + + WebSocketClient.SetCancelToken(cancelToken); + await WebSocketClient.ConnectAsync(url).ConfigureAwait(false); + + _udp.SetCancelToken(cancelToken); + await _udp.StartAsync().ConfigureAwait(false); + + ConnectionState = ConnectionState.Connected; + } + catch + { + await DisconnectInternalAsync().ConfigureAwait(false); + throw; + } + } + + public async Task DisconnectAsync() + { + await _connectionLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync().ConfigureAwait(false); + } + finally { _connectionLock.Release(); } + } + private async Task DisconnectInternalAsync() + { + if (ConnectionState == ConnectionState.Disconnected) + return; + ConnectionState = ConnectionState.Disconnecting; + + try + { _connectCancelToken?.Cancel(false); } + catch { } + + //Wait for tasks to complete + await _udp.StopAsync().ConfigureAwait(false); + await WebSocketClient.DisconnectAsync().ConfigureAwait(false); + + ConnectionState = ConnectionState.Disconnected; + } + #endregion + + #region Udp + public async Task SendDiscoveryAsync(uint ssrc) + { + var packet = new byte[74]; + packet[1] = 1; + packet[3] = 70; + packet[4] = (byte)(ssrc >> 24); + packet[5] = (byte)(ssrc >> 16); + packet[6] = (byte)(ssrc >> 8); + packet[7] = (byte)(ssrc >> 0); + await SendAsync(packet, 0, 74).ConfigureAwait(false); + await _sentDiscoveryEvent.InvokeAsync().ConfigureAwait(false); + } + public async Task SendKeepaliveAsync() + { + var value = _nextKeepalive++; + var packet = new byte[8]; + packet[0] = (byte)(value >> 0); + packet[1] = (byte)(value >> 8); + packet[2] = (byte)(value >> 16); + packet[3] = (byte)(value >> 24); + packet[4] = (byte)(value >> 32); + packet[5] = (byte)(value >> 40); + packet[6] = (byte)(value >> 48); + packet[7] = (byte)(value >> 56); + await SendAsync(packet, 0, 8).ConfigureAwait(false); + return value; + } + + public void SetUdpEndpoint(string ip, int port) + { + _udp.SetDestination(ip, port); + } + #endregion + + #region Helpers + private static double ToMilliseconds(Stopwatch stopwatch) => Math.Round((double)stopwatch.ElapsedTicks / (double)Stopwatch.Frequency * 1000.0, 2); + private string SerializeJson(object value) + { + var sb = new StringBuilder(256); + using (TextWriter text = new StringWriter(sb, CultureInfo.InvariantCulture)) + using (JsonWriter writer = new JsonTextWriter(text)) + _serializer.Serialize(writer, value); + return sb.ToString(); + } + private T DeserializeJson(Stream jsonStream) + { + using (TextReader text = new StreamReader(jsonStream)) + using (JsonReader reader = new JsonTextReader(text)) + return _serializer.Deserialize(reader); + } + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AppSubscriptions/SocketEntitlement.cs b/src/Discord.Net.WebSocket/Entities/AppSubscriptions/SocketEntitlement.cs new file mode 100644 index 0000000..0bfafe8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AppSubscriptions/SocketEntitlement.cs @@ -0,0 +1,91 @@ +using Discord.Rest; +using System; + +using Model = Discord.API.Entitlement; + +namespace Discord.WebSocket; + +public class SocketEntitlement : SocketEntity, IEntitlement +{ + /// + public DateTimeOffset CreatedAt { get; private set; } + + /// + public ulong SkuId { get; private set; } + + /// + public Cacheable? User { get; private set; } + + /// + public Cacheable? Guild { get; private set; } + + /// + public ulong ApplicationId { get; private set; } + + /// + public EntitlementType Type { get; private set; } + + /// + public bool IsConsumed { get; private set; } + + /// + public DateTimeOffset? StartsAt { get; private set; } + + /// + public DateTimeOffset? EndsAt { get; private set; } + + internal SocketEntitlement(DiscordSocketClient discord, ulong id) : base(discord, id) + { + } + + internal static SocketEntitlement Create(DiscordSocketClient discord, Model model) + { + var entity = new SocketEntitlement(discord, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + SkuId = model.SkuId; + + if (model.UserId.IsSpecified) + { + var user = Discord.GetUser(model.UserId.Value); + + User = new Cacheable(user, model.UserId.Value, user is not null, async () + => await Discord.Rest.GetUserAsync(model.UserId.Value)); + } + + if (model.GuildId.IsSpecified) + { + var guild = Discord.GetGuild(model.GuildId.Value); + + Guild = new Cacheable(guild, model.GuildId.Value, guild is not null, async () + => await Discord.Rest.GetGuildAsync(model.GuildId.Value)); + } + + ApplicationId = model.ApplicationId; + Type = model.Type; + IsConsumed = model.IsConsumed; + StartsAt = model.StartsAt.IsSpecified + ? model.StartsAt.Value + : null; + EndsAt = model.EndsAt.IsSpecified + ? model.EndsAt.Value + : null; + } + + internal SocketEntitlement Clone() => MemberwiseClone() as SocketEntitlement; + + #region IEntitlement + + /// + ulong? IEntitlement.GuildId => Guild?.Id; + + /// + ulong? IEntitlement.UserId => User?.Id; + + #endregion + +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/AuditLogCache.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/AuditLogCache.cs new file mode 100644 index 0000000..3fd6eee --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/AuditLogCache.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.WebSocket; + +internal class AuditLogCache +{ + private readonly ConcurrentDictionary _entries; + private readonly ConcurrentQueue _orderedEntries; + + private readonly int _size; + + public IReadOnlyCollection AuditLogs => _entries.ToReadOnlyCollection(); + + public AuditLogCache(DiscordSocketClient client) + { + _size = client.AuditLogCacheSize; + + _entries = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(_size * 1.05)); + _orderedEntries = new ConcurrentQueue(); + } + + public void Add(SocketAuditLogEntry entry) + { + if (_entries.TryAdd(entry.Id, entry)) + { + _orderedEntries.Enqueue(entry.Id); + + while (_orderedEntries.Count > _size && _orderedEntries.TryDequeue(out var entryId)) + _entries.TryRemove(entryId, out _); + } + } + + public SocketAuditLogEntry Remove(ulong id) + { + _entries.TryRemove(id, out var entry); + return entry; + } + + public SocketAuditLogEntry Get(ulong id) + => _entries.TryGetValue(id, out var result) ? result : null; + + /// is less than 0. + public IReadOnlyCollection GetMany(ulong? fromEntryId, Direction dir, int limit = DiscordConfig.MaxAuditLogEntriesPerBatch, ActionType ? action = null) + { + if (limit < 0) + throw new ArgumentOutOfRangeException(nameof(limit)); + if (limit == 0) + return ImmutableArray.Empty; + + IEnumerable cachedEntriesIds; + if (fromEntryId == null) + cachedEntriesIds = _orderedEntries; + else if (dir == Direction.Before) + cachedEntriesIds = _orderedEntries.Where(x => x < fromEntryId.Value); + else if (dir == Direction.After) + cachedEntriesIds = _orderedEntries.Where(x => x > fromEntryId.Value); + else //Direction.Around + { + if (!_entries.TryGetValue(fromEntryId.Value, out var entry)) + return ImmutableArray.Empty; + var around = limit / 2; + var before = GetMany(fromEntryId, Direction.Before, around, action); + var after = GetMany(fromEntryId, Direction.After, around, action).Reverse(); + + return after.Concat(new [] { entry }).Concat(before).ToImmutableArray(); + } + + if (dir == Direction.Before) + cachedEntriesIds = cachedEntriesIds.Reverse(); + if (dir == Direction.Around) + limit = limit / 2 + 1; + + return cachedEntriesIds + .Select(x => _entries.TryGetValue(x, out var entry) ? entry : null) + .Where(x => x != null && (action is null || x.Action == action)) + .Take(limit) + .ToImmutableArray(); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModBlockedMessageAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModBlockedMessageAuditLogData.cs new file mode 100644 index 0000000..35b7a82 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModBlockedMessageAuditLogData.cs @@ -0,0 +1,37 @@ +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to message getting blocked by automod. +/// +public class SocketAutoModBlockedMessageAuditLogData : ISocketAuditLogData +{ + internal SocketAutoModBlockedMessageAuditLogData(ulong channelId, string autoModRuleName, AutoModTriggerType autoModRuleTriggerType) + { + ChannelId = channelId; + AutoModRuleName = autoModRuleName; + AutoModRuleTriggerType = autoModRuleTriggerType; + } + + internal static SocketAutoModBlockedMessageAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new(entry.Options.ChannelId!.Value, entry.Options.AutoModRuleName, + entry.Options.AutoModRuleTriggerType!.Value); + } + + /// + /// Gets the channel the message was sent in. + /// + public ulong ChannelId { get; set; } + + /// + /// Gets the name of the auto moderation rule that got triggered. + /// + public string AutoModRuleName { get; set; } + + /// + /// Gets the trigger type of the auto moderation rule that got triggered. + /// + public AutoModTriggerType AutoModRuleTriggerType { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModFlaggedMessageAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModFlaggedMessageAuditLogData.cs new file mode 100644 index 0000000..4840843 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModFlaggedMessageAuditLogData.cs @@ -0,0 +1,37 @@ +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to message getting flagged by automod. +/// +public class SocketAutoModFlaggedMessageAuditLogData : ISocketAuditLogData +{ + internal SocketAutoModFlaggedMessageAuditLogData(ulong? channelId, string autoModRuleName, AutoModTriggerType autoModRuleTriggerType) + { + ChannelId = channelId ?? 0; + AutoModRuleName = autoModRuleName; + AutoModRuleTriggerType = autoModRuleTriggerType; + } + + internal static SocketAutoModFlaggedMessageAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new(entry.Options.ChannelId, entry.Options.AutoModRuleName, + entry.Options.AutoModRuleTriggerType!.Value); + } + + /// + /// Gets the channel the message was sent in. Will be 0 if a user profile was flagged. + /// + public ulong ChannelId { get; set; } + + /// + /// Gets the name of the auto moderation rule that got triggered. + /// + public string AutoModRuleName { get; set; } + + /// + /// Gets the trigger type of the auto moderation rule that got triggered. + /// + public AutoModTriggerType AutoModRuleTriggerType { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleCreatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleCreatedAuditLogData.cs new file mode 100644 index 0000000..df556f4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleCreatedAuditLogData.cs @@ -0,0 +1,30 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an auto moderation rule creation. +/// +public class SocketAutoModRuleCreatedAuditLogData : ISocketAuditLogData +{ + private SocketAutoModRuleCreatedAuditLogData(SocketAutoModRuleInfo data) + { + Data = data; + } + + internal static SocketAutoModRuleCreatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketAutoModRuleCreatedAuditLogData(new (data)); + } + + /// + /// Gets the auto moderation rule information after the changes. + /// + public SocketAutoModRuleInfo Data { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleDeletedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleDeletedAuditLogData.cs new file mode 100644 index 0000000..40b6b7a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleDeletedAuditLogData.cs @@ -0,0 +1,30 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an auto moderation rule removal. +/// +public class SocketAutoModRuleDeletedAuditLogData : ISocketAuditLogData +{ + private SocketAutoModRuleDeletedAuditLogData(SocketAutoModRuleInfo data) + { + Data = data; + } + + internal static SocketAutoModRuleDeletedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketAutoModRuleDeletedAuditLogData(new (data)); + } + + /// + /// Gets the auto moderation rule information before the changes. + /// + public SocketAutoModRuleInfo Data { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleInfo.cs new file mode 100644 index 0000000..6f7b7c8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleInfo.cs @@ -0,0 +1,113 @@ +using Discord.API.AuditLogs; + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.WebSocket; + +/// +/// Represents information for an auto moderation rule. +/// +public class SocketAutoModRuleInfo +{ + internal SocketAutoModRuleInfo(AutoModRuleInfoAuditLogModel model) + { + Actions = model.Actions?.Select(x => new AutoModRuleAction( + x.Type, + x.Metadata.GetValueOrDefault()?.ChannelId.ToNullable(), + x.Metadata.GetValueOrDefault()?.DurationSeconds.ToNullable(), + x.Metadata.IsSpecified + ? x.Metadata.Value.CustomMessage.IsSpecified + ? x.Metadata.Value.CustomMessage.Value + : null + : null + )).ToImmutableArray(); + KeywordFilter = model.TriggerMetadata?.KeywordFilter.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + Presets = model.TriggerMetadata?.Presets.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + RegexPatterns = model.TriggerMetadata?.RegexPatterns.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + AllowList = model.TriggerMetadata?.AllowList.GetValueOrDefault(Array.Empty())?.ToImmutableArray(); + MentionTotalLimit = model.TriggerMetadata?.MentionLimit.IsSpecified ?? false + ? model.TriggerMetadata?.MentionLimit.Value + : null; + Name = model.Name; + Enabled = model.Enabled; + ExemptRoles = model.ExemptRoles?.ToImmutableArray(); + ExemptChannels = model.ExemptChannels?.ToImmutableArray(); + TriggerType = model.TriggerType; + EventType = model.EventType; + } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public string Name { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public AutoModEventType? EventType { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public AutoModTriggerType? TriggerType { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public bool? Enabled { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection ExemptRoles { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection ExemptChannels { get; set; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection KeywordFilter { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection RegexPatterns { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection AllowList { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection Presets { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public int? MentionTotalLimit { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + public IReadOnlyCollection Actions { get; private set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleUpdatedAuditLogData .cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleUpdatedAuditLogData .cs new file mode 100644 index 0000000..ca724d7 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModRuleUpdatedAuditLogData .cs @@ -0,0 +1,36 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an auto moderation rule update. +/// +public class AutoModRuleUpdatedAuditLogData : ISocketAuditLogData +{ + private AutoModRuleUpdatedAuditLogData(SocketAutoModRuleInfo before, SocketAutoModRuleInfo after) + { + Before = before; + After = after; + } + + internal static AutoModRuleUpdatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new AutoModRuleUpdatedAuditLogData(new(before), new(after)); + } + + /// + /// Gets the auto moderation rule information before the changes. + /// + public SocketAutoModRuleInfo Before { get; } + + /// + /// Gets the auto moderation rule information after the changes. + /// + public SocketAutoModRuleInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModTimeoutUserAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModTimeoutUserAuditLogData.cs new file mode 100644 index 0000000..2205000 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketAutoModTimeoutUserAuditLogData.cs @@ -0,0 +1,37 @@ +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to user getting in timeout by automod. +/// +public class SocketAutoModTimeoutUserAuditLogData : ISocketAuditLogData +{ + internal SocketAutoModTimeoutUserAuditLogData(ulong channelId, string autoModRuleName, AutoModTriggerType autoModRuleTriggerType) + { + ChannelId = channelId; + AutoModRuleName = autoModRuleName; + AutoModRuleTriggerType = autoModRuleTriggerType; + } + + internal static SocketAutoModTimeoutUserAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new(entry.Options.ChannelId!.Value, entry.Options.AutoModRuleName, + entry.Options.AutoModRuleTriggerType!.Value); + } + + /// + /// Gets the channel the message was sent in. + /// + public ulong ChannelId { get; set; } + + /// + /// Gets the name of the auto moderation rule that got triggered. + /// + public string AutoModRuleName { get; set; } + + /// + /// Gets the trigger type of the auto moderation rule that got triggered. + /// + public AutoModTriggerType AutoModRuleTriggerType { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketBanAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketBanAuditLogData.cs new file mode 100644 index 0000000..699cca5 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketBanAuditLogData.cs @@ -0,0 +1,43 @@ +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a ban. +/// +public class SocketBanAuditLogData : ISocketAuditLogData +{ + private SocketBanAuditLogData(Cacheable user) + { + Target = user; + } + + internal static SocketBanAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var cachedUser = discord.GetUser(entry.TargetId!.Value); + var cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + + return new SocketBanAuditLogData(cacheableUser); + } + + /// + /// Gets the user that was banned. + /// + /// + /// Download method may return if the user is a 'Deleted User#....' + /// because Discord does send user data for deleted users. + /// + /// + /// A cacheable user object representing the banned user. + /// + public Cacheable Target { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketBotAddAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketBotAddAuditLogData.cs new file mode 100644 index 0000000..35f14f0 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketBotAddAuditLogData.cs @@ -0,0 +1,42 @@ +using Discord.Rest; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a adding a bot to a guild. +/// +public class SocketBotAddAuditLogData : ISocketAuditLogData +{ + private SocketBotAddAuditLogData(Cacheable bot) + { + Target = bot; + } + + internal static SocketBotAddAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var cachedUser = discord.GetUser(entry.TargetId!.Value); + var cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + return new SocketBotAddAuditLogData(cacheableUser); + } + + /// + /// Gets the bot that was added. + /// + /// + /// Will be if the bot is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A cacheable user object representing the bot. + /// + public Cacheable Target { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelCreateAuditLogData.cs new file mode 100644 index 0000000..f5c86bb --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelCreateAuditLogData.cs @@ -0,0 +1,165 @@ +using Discord.Rest; +using Discord.API.AuditLogs; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a channel creation. +/// +public class SocketChannelCreateAuditLogData : ISocketAuditLogData +{ + private SocketChannelCreateAuditLogData(ChannelInfoAuditLogModel model, EntryModel entry) + { + ChannelId = entry.TargetId!.Value; + ChannelName = model.Name; + ChannelType = model.Type!.Value; + SlowModeInterval = model.RateLimitPerUser; + IsNsfw = model.IsNsfw; + Bitrate = model.Bitrate; + Topic = model.Topic; + AutoArchiveDuration = model.AutoArchiveDuration; + DefaultSlowModeInterval = model.DefaultThreadRateLimitPerUser; + DefaultAutoArchiveDuration = model.DefaultArchiveDuration; + + AvailableTags = model.AvailableTags?.Select(x => new ForumTag(x.Id, + x.Name, + x.EmojiId.GetValueOrDefault(null), + x.EmojiName.GetValueOrDefault(null), + x.Moderated)).ToImmutableArray(); + + + if (model.DefaultEmoji is not null) + { + if (model.DefaultEmoji.EmojiId.HasValue && model.DefaultEmoji.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultEmoji.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultEmoji.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultEmoji.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + else + DefaultReactionEmoji = null; + + VideoQualityMode = model.VideoQualityMode; + RtcRegion = model.Region; + Flags = model.Flags; + UserLimit = model.UserLimit; + } + + internal static SocketChannelCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketChannelCreateAuditLogData(data, entry); + } + + /// + /// Gets the snowflake ID of the created channel. + /// + /// + /// A representing the snowflake identifier for the created channel. + /// + public ulong ChannelId { get; } + + /// + /// Gets the name of the created channel. + /// + /// + /// A string containing the name of the created channel. + /// + public string ChannelName { get; } + + /// + /// Gets the type of the created channel. + /// + /// + /// The type of channel that was created. + /// + public ChannelType ChannelType { get; } + + /// + /// Gets the current slow-mode delay of the created channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the value that indicates whether the created channel is NSFW. + /// + /// + /// if the created channel has the NSFW flag enabled; otherwise . + /// if this is not mentioned in this entry. + /// + public bool? IsNsfw { get; } + + /// + /// Gets the bit-rate that the clients in the created voice channel are requested to use. + /// + /// + /// An representing the bit-rate (bps) that the created voice channel defines and requests the + /// client(s) to use. + /// if this is not mentioned in this entry. + /// + public int? Bitrate { get; } + + /// + /// Gets the thread archive duration that was set in the created channel. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// Gets the default thread archive duration that was set in the created channel. + /// + public ThreadArchiveDuration? DefaultAutoArchiveDuration { get; } + + /// + /// Gets the default slow mode interval that will be set in child threads in the channel. + /// + public int? DefaultSlowModeInterval { get; } + + /// + /// Gets the topic that was set in the created channel. + /// + public string Topic { get; } + + /// + /// Gets tags available in the created forum channel. + /// + public IReadOnlyCollection AvailableTags { get; } + + /// + /// Gets the default reaction added to posts in the created forum channel. + /// + public IEmote DefaultReactionEmoji { get; } + + /// + /// Gets the user limit configured in the created voice channel. + /// + public int? UserLimit { get; } + + /// + /// Gets the video quality mode configured in the created voice channel. + /// + public VideoQualityMode? VideoQualityMode { get; } + + /// + /// Gets the region configured in the created voice channel. + /// + public string RtcRegion { get; } + + /// + /// Gets channel flags configured for the created channel. + /// + public ChannelFlags? Flags { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelDeleteAuditLogData.cs new file mode 100644 index 0000000..48e4612 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelDeleteAuditLogData.cs @@ -0,0 +1,182 @@ +using Discord.Rest; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using Model = Discord.API.AuditLogs.ChannelInfoAuditLogModel; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a channel deletion. +/// +public class SocketChannelDeleteAuditLogData : ISocketAuditLogData +{ + private SocketChannelDeleteAuditLogData(Model model, EntryModel entry) + { + ChannelId = entry.TargetId!.Value; + ChannelType = model.Type; + ChannelName = model.Name; + + Topic = model.Topic; + IsNsfw = model.IsNsfw; + Bitrate = model.Bitrate; + DefaultArchiveDuration = model.DefaultArchiveDuration; + SlowModeInterval = model.RateLimitPerUser; + + ForumTags = model.AvailableTags?.Select( + x => new ForumTag(x.Id, + x.Name, + x.EmojiId.GetValueOrDefault(null), + x.EmojiName.GetValueOrDefault(null), + x.Moderated)).ToImmutableArray(); + + if (model.DefaultEmoji is not null) + { + if (model.DefaultEmoji.EmojiId.HasValue && model.DefaultEmoji.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultEmoji.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultEmoji.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultEmoji.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + else + DefaultReactionEmoji = null; + AutoArchiveDuration = model.AutoArchiveDuration; + DefaultSlowModeInterval = model.DefaultThreadRateLimitPerUser; + + VideoQualityMode = model.VideoQualityMode; + RtcRegion = model.Region; + Flags = model.Flags; + UserLimit = model.UserLimit; + + Overwrites = model.Overwrites?.Select(x + => new Overwrite(x.TargetId, + x.TargetType, + new OverwritePermissions(x.Allow, x.Deny))).ToImmutableArray(); + } + + internal static SocketChannelDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketChannelDeleteAuditLogData(data, entry); + } + + /// + /// Gets the snowflake ID of the deleted channel. + /// + /// + /// A representing the snowflake identifier for the deleted channel. + /// + public ulong ChannelId { get; } + + /// + /// Gets the name of the deleted channel. + /// + /// + /// A string containing the name of the deleted channel. + /// + public string ChannelName { get; } + + /// + /// Gets the type of the deleted channel. + /// + /// + /// The type of channel that was deleted. + /// + public ChannelType? ChannelType { get; } + + /// + /// Gets the slow-mode delay of the deleted channel. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the value that indicates whether the deleted channel was NSFW. + /// + /// + /// if this channel had the NSFW flag enabled; otherwise . + /// if this is not mentioned in this entry. + /// + public bool? IsNsfw { get; } + + /// + /// Gets the bit-rate of this channel if applicable. + /// + /// + /// An representing the bit-rate set of the voice channel. + /// if this is not mentioned in this entry. + /// + public int? Bitrate { get; } + + /// + /// Gets a collection of permission overwrites that was assigned to the deleted channel. + /// + /// + /// A collection of permission . + /// + public IReadOnlyCollection Overwrites { get; } + + /// + /// Gets the user limit configured in the created voice channel. + /// + public int? UserLimit { get; } + + /// + /// Gets the video quality mode configured in the created voice channel. + /// + public VideoQualityMode? VideoQualityMode { get; } + + /// + /// Gets the region configured in the created voice channel. + /// + public string RtcRegion { get; } + + /// + /// Gets channel flags configured for the created channel. + /// + public ChannelFlags? Flags { get; } + + /// + /// Gets the thread archive duration that was configured for the created channel. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// Gets the default slow mode interval that was configured for the channel. + /// + public int? DefaultSlowModeInterval { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public ThreadArchiveDuration? DefaultArchiveDuration { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public IReadOnlyCollection ForumTags { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public string Topic { get; } + + /// + /// + /// if the value was not specified in this entry.. + /// + public IEmote DefaultReactionEmoji { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelInfo.cs new file mode 100644 index 0000000..669c448 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelInfo.cs @@ -0,0 +1,142 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.AuditLogs.ChannelInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Represents information for a channel. +/// +public struct SocketChannelInfo +{ + internal SocketChannelInfo(Model model) + { + Name = model.Name; + Topic = model.Topic; + IsNsfw = model.IsNsfw; + Bitrate = model.Bitrate; + DefaultArchiveDuration = model.DefaultArchiveDuration; + ChannelType = model.Type; + SlowModeInterval = model.RateLimitPerUser; + + ForumTags = model.AvailableTags?.Select( + x => new ForumTag(x.Id, + x.Name, + x.EmojiId.GetValueOrDefault(null), + x.EmojiName.GetValueOrDefault(null), + x.Moderated)).ToImmutableArray(); + + if (model.DefaultEmoji is not null) + { + if (model.DefaultEmoji.EmojiId.HasValue && model.DefaultEmoji.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultEmoji.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultEmoji.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultEmoji.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + else + DefaultReactionEmoji = null; + AutoArchiveDuration = model.AutoArchiveDuration; + DefaultSlowModeInterval = model.DefaultThreadRateLimitPerUser; + + VideoQualityMode = model.VideoQualityMode; + RTCRegion = model.Region; + Flags = model.Flags; + UserLimit = model.UserLimit; + } + + /// + /// + /// if the value was not updated in this entry. + /// + public string Name { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string Topic { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public bool? IsNsfw { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public int? Bitrate { get; } + + /// + /// Gets the type of this channel. + /// + /// + /// The channel type of this channel; if not applicable. + /// + public ChannelType? ChannelType { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ThreadArchiveDuration? DefaultArchiveDuration { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public IReadOnlyCollection ForumTags { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public IEmote DefaultReactionEmoji { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public int? UserLimit { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public VideoQualityMode? VideoQualityMode { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string RTCRegion { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ChannelFlags? Flags { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public int? DefaultSlowModeInterval { get; } + +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelUpdateAuditLogData.cs new file mode 100644 index 0000000..4494e0a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketChannelUpdateAuditLogData.cs @@ -0,0 +1,53 @@ +using Discord.Rest; +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket +{ + /// + /// Contains a piece of audit log data related to a channel update. + /// + public class SocketChannelUpdateAuditLogData : ISocketAuditLogData + { + private SocketChannelUpdateAuditLogData(ulong id, SocketChannelInfo before, SocketChannelInfo after) + { + ChannelId = id; + Before = before; + After = after; + } + + internal static SocketChannelUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketChannelUpdateAuditLogData(entry.TargetId!.Value, new(before), new(after)); + } + + /// + /// Gets the snowflake ID of the updated channel. + /// + /// + /// A representing the snowflake identifier for the updated channel. + /// + public ulong ChannelId { get; } + + /// + /// Gets the channel information before the changes. + /// + /// + /// An information object containing the original channel information before the changes were made. + /// + public SocketChannelInfo Before { get; } + + /// + /// Gets the channel information after the changes. + /// + /// + /// An information object containing the channel information after the changes were made. + /// + public SocketChannelInfo After { get; } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketCommandPermissionUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketCommandPermissionUpdateAuditLogData.cs new file mode 100644 index 0000000..ec220c1 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketCommandPermissionUpdateAuditLogData.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Collections.Immutable; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an application command permission update. +/// +public class SocketCommandPermissionUpdateAuditLogData : ISocketAuditLogData +{ + internal SocketCommandPermissionUpdateAuditLogData(IReadOnlyCollection before, IReadOnlyCollection after, + ulong commandId, ulong appId) + { + Before = before; + After = after; + ApplicationId = appId; + CommandId = commandId; + } + + internal static SocketCommandPermissionUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var before = new List(); + var after = new List(); + + foreach (var change in changes) + { + var oldValue = change.OldValue?.ToObject(); + var newValue = change.NewValue?.ToObject(); + + if (oldValue is not null) + before.Add(new ApplicationCommandPermission(oldValue.Id, oldValue.Type, oldValue.Permission)); + + if (newValue is not null) + after.Add(new ApplicationCommandPermission(newValue.Id, newValue.Type, newValue.Permission)); + } + + return new(before.ToImmutableArray(), after.ToImmutableArray(), entry.TargetId!.Value, entry.Options.ApplicationId!.Value); + } + + /// + /// Gets the ID of the app whose permissions were targeted. + /// + public ulong ApplicationId { get; set; } + + /// + /// Gets the id of the application command which permissions were updated. + /// + public ulong CommandId { get; } + + /// + /// Gets values of the permissions before the change if available. + /// + public IReadOnlyCollection Before { get; } + + /// + /// Gets values of the permissions after the change if available. + /// + public IReadOnlyCollection After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteCreateAuditLogData.cs new file mode 100644 index 0000000..51d48ef --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteCreateAuditLogData.cs @@ -0,0 +1,41 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an emoji creation. +/// +public class SocketEmoteCreateAuditLogData : ISocketAuditLogData +{ + private SocketEmoteCreateAuditLogData(ulong id, string name) + { + EmoteId = id; + Name = name; + } + + internal static SocketEmoteCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var change = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + + var emoteName = change.NewValue?.ToObject(discord.ApiClient.Serializer); + return new SocketEmoteCreateAuditLogData(entry.TargetId!.Value, emoteName); + } + + /// + /// Gets the snowflake ID of the created emoji. + /// + /// + /// A representing the snowflake identifier for the created emoji. + /// + public ulong EmoteId { get; } + + /// + /// Gets the name of the created emoji. + /// + /// + /// A string containing the name of the created emoji. + /// + public string Name { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteDeleteAuditLogData.cs new file mode 100644 index 0000000..6354251 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteDeleteAuditLogData.cs @@ -0,0 +1,41 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an emoji deletion. +/// +public class SocketEmoteDeleteAuditLogData : ISocketAuditLogData +{ + private SocketEmoteDeleteAuditLogData(ulong id, string name) + { + EmoteId = id; + Name = name; + } + + internal static SocketEmoteDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var change = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + + var emoteName = change.OldValue?.ToObject(discord.ApiClient.Serializer); + + return new SocketEmoteDeleteAuditLogData(entry.TargetId!.Value, emoteName); + } + + /// + /// Gets the snowflake ID of the deleted emoji. + /// + /// + /// A representing the snowflake identifier for the deleted emoji. + /// + public ulong EmoteId { get; } + + /// + /// Gets the name of the deleted emoji. + /// + /// + /// A string containing the name of the deleted emoji. + /// + public string Name { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteUpdateAuditLogData.cs new file mode 100644 index 0000000..384d4a1 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketEmoteUpdateAuditLogData.cs @@ -0,0 +1,52 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an emoji update. +/// +public class SocketEmoteUpdateAuditLogData : ISocketAuditLogData +{ + private SocketEmoteUpdateAuditLogData(ulong id, string oldName, string newName) + { + EmoteId = id; + OldName = oldName; + NewName = newName; + } + + internal static SocketEmoteUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var change = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "name"); + + var newName = change.NewValue?.ToObject(discord.ApiClient.Serializer); + var oldName = change.OldValue?.ToObject(discord.ApiClient.Serializer); + + return new SocketEmoteUpdateAuditLogData(entry.TargetId!.Value, oldName, newName); + } + + /// + /// Gets the snowflake ID of the updated emoji. + /// + /// + /// A representing the snowflake identifier of the updated emoji. + /// + public ulong EmoteId { get; } + + /// + /// Gets the new name of the updated emoji. + /// + /// + /// A string containing the new name of the updated emoji. + /// + public string NewName { get; } + + /// + /// Gets the old name of the updated emoji. + /// + /// + /// A string containing the old name of the updated emoji. + /// + public string OldName { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketGuildInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketGuildInfo.cs new file mode 100644 index 0000000..ed5d1c4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketGuildInfo.cs @@ -0,0 +1,191 @@ +using Model = Discord.API.AuditLogs.GuildInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Represents information for a guild. +/// +public class SocketGuildInfo +{ + internal static SocketGuildInfo Create(Model model) + { + return new() + { + Name = model.Name, + AfkTimeout = model.AfkTimeout.GetValueOrDefault(), + IsEmbeddable = model.IsEmbeddable, + DefaultMessageNotifications = model.DefaultMessageNotifications, + MfaLevel = model.MfaLevel, + Description = model.Description, + PreferredLocale = model.PreferredLocale, + IconHash = model.IconHash, + OwnerId = model.OwnerId, + AfkChannelId = model.AfkChannelId, + ApplicationId = model.ApplicationId, + BannerId = model.Banner, + DiscoverySplashId = model.DiscoverySplash, + EmbedChannelId = model.EmbeddedChannelId, + ExplicitContentFilter = model.ExplicitContentFilterLevel, + IsBoostProgressBarEnabled = model.ProgressBarEnabled, + NsfwLevel = model.NsfwLevel, + PublicUpdatesChannelId = model.PublicUpdatesChannelId, + RegionId = model.RegionId, + RulesChannelId = model.RulesChannelId, + SplashId = model.Splash, + SystemChannelFlags = model.SystemChannelFlags, + SystemChannelId = model.SystemChannelId, + VanityURLCode = model.VanityUrl, + VerificationLevel = model.VerificationLevel + }; + } + + /// + /// + /// if the value was not updated in this entry. + /// + public string Name { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public int? AfkTimeout { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public bool? IsEmbeddable { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public DefaultMessageNotifications? DefaultMessageNotifications { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public MfaLevel? MfaLevel { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public VerificationLevel? VerificationLevel { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ExplicitContentFilterLevel? ExplicitContentFilter { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string IconHash { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string SplashId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string DiscoverySplashId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? AfkChannelId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? EmbedChannelId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? SystemChannelId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? RulesChannelId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? PublicUpdatesChannelId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? OwnerId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public ulong? ApplicationId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string RegionId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string BannerId { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string VanityURLCode { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public SystemChannelMessageDeny? SystemChannelFlags { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string Description { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string PreferredLocale { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public NsfwLevel? NsfwLevel { get; private set; } + + /// + /// + /// if the value was not updated in this entry. + /// + public bool? IsBoostProgressBarEnabled { get; private set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketGuildUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketGuildUpdateAuditLogData.cs new file mode 100644 index 0000000..c912b67 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketGuildUpdateAuditLogData.cs @@ -0,0 +1,42 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; +using InfoModel = Discord.API.AuditLogs.GuildInfoAuditLogModel; + +namespace Discord.WebSocket +{ + /// + /// Contains a piece of audit log data related to a guild update. + /// + public class SocketGuildUpdateAuditLogData : ISocketAuditLogData + { + private SocketGuildUpdateAuditLogData(SocketGuildInfo before, SocketGuildInfo after) + { + Before = before; + After = after; + } + + internal static SocketGuildUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var info = Rest.AuditLogHelper.CreateAuditLogEntityInfo(entry.Changes, discord); + + var data = new SocketGuildUpdateAuditLogData(SocketGuildInfo.Create(info.Item1), SocketGuildInfo.Create(info.Item2)); + return data; + } + + /// + /// Gets the guild information before the changes. + /// + /// + /// An information object containing the original guild information before the changes were made. + /// + public SocketGuildInfo Before { get; } + /// + /// Gets the guild information after the changes. + /// + /// + /// An information object containing the guild information after the changes were made. + /// + public SocketGuildInfo After { get; } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationCreatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationCreatedAuditLogData.cs new file mode 100644 index 0000000..1afb575 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationCreatedAuditLogData.cs @@ -0,0 +1,30 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an integration authorization. +/// +public class SocketIntegrationCreatedAuditLogData : ISocketAuditLogData +{ + internal SocketIntegrationCreatedAuditLogData(SocketIntegrationInfo info) + { + Data = info; + } + + internal static SocketIntegrationCreatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new(new SocketIntegrationInfo(data)); + } + + /// + /// Gets the integration information after the changes. + /// + public SocketIntegrationInfo Data { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationDeletedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationDeletedAuditLogData.cs new file mode 100644 index 0000000..eb78b8f --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationDeletedAuditLogData.cs @@ -0,0 +1,30 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an integration removal. +/// +public class SocketIntegrationDeletedAuditLogData : ISocketAuditLogData +{ + internal SocketIntegrationDeletedAuditLogData(SocketIntegrationInfo info) + { + Data = info; + } + + internal static SocketIntegrationDeletedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new(new SocketIntegrationInfo(data)); + } + + /// + /// Gets the integration information before the changes. + /// + public SocketIntegrationInfo Data { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationInfo.cs new file mode 100644 index 0000000..9f095d5 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationInfo.cs @@ -0,0 +1,69 @@ +using Discord.API.AuditLogs; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.WebSocket; + +/// +/// Represents information for an integration. +/// +public class SocketIntegrationInfo +{ + internal SocketIntegrationInfo(IntegrationInfoAuditLogModel model) + { + Name = model.Name; + Type = model.Type; + EnableEmojis = model.EnableEmojis; + Enabled = model.Enabled; + Scopes = model.Scopes?.ToImmutableArray(); + ExpireBehavior = model.ExpireBehavior; + ExpireGracePeriod = model.ExpireGracePeriod; + Syncing = model.Syncing; + RoleId = model.RoleId; + } + + /// + /// Gets the name of the integration. if the property was not mentioned in this audit log. + /// + public string Name { get; set; } + + /// + /// Gets the type of the integration. if the property was not mentioned in this audit log. + /// + public string Type { get; set; } + + /// + /// Gets if the integration is enabled. if the property was not mentioned in this audit log. + /// + public bool? Enabled { get; set; } + + /// + /// Gets if syncing is enabled for this integration. if the property was not mentioned in this audit log. + /// + public bool? Syncing { get; set; } + + /// + /// Gets the id of the role that this integration uses for subscribers. if the property was not mentioned in this audit log. + /// + public ulong? RoleId { get; set; } + + /// + /// Gets whether emoticons should be synced for this integration. if the property was not mentioned in this audit log. + /// + public bool? EnableEmojis { get; set; } + + /// + /// Gets the behavior of expiring subscribers. if the property was not mentioned in this audit log. + /// + public IntegrationExpireBehavior? ExpireBehavior { get; set; } + + /// + /// Gets the grace period (in days) before expiring subscribers. if the property was not mentioned in this audit log. + /// + public int? ExpireGracePeriod { get; set; } + + /// + /// Gets the scopes the application has been authorized for. if the property was not mentioned in this audit log. + /// + public IReadOnlyCollection Scopes { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationUpdatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationUpdatedAuditLogData.cs new file mode 100644 index 0000000..e6cbac5 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketIntegrationUpdatedAuditLogData.cs @@ -0,0 +1,36 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an integration update. +/// +public class SocketIntegrationUpdatedAuditLogData : ISocketAuditLogData +{ + internal SocketIntegrationUpdatedAuditLogData(SocketIntegrationInfo before, SocketIntegrationInfo after) + { + Before = before; + After = after; + } + + internal static SocketIntegrationUpdatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new(new SocketIntegrationInfo(before), new SocketIntegrationInfo(after)); + } + + /// + /// Gets the integration information before the changes. + /// + public SocketIntegrationInfo Before { get; } + + /// + /// Gets the integration information after the changes. + /// + public SocketIntegrationInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteCreateAuditLogData.cs new file mode 100644 index 0000000..a66db3e --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteCreateAuditLogData.cs @@ -0,0 +1,110 @@ +using Discord.API.AuditLogs; +using Discord.Rest; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an invite creation. +/// +public class SocketInviteCreateAuditLogData : ISocketAuditLogData +{ + private SocketInviteCreateAuditLogData(InviteInfoAuditLogModel model, Cacheable? inviter) + { + MaxAge = model.MaxAge!.Value; + Code = model.Code; + Temporary = model.Temporary!.Value; + ChannelId = model.ChannelId!.Value; + Uses = model.Uses!.Value; + MaxUses = model.MaxUses!.Value; + + Creator = inviter; + } + + internal static SocketInviteCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + Cacheable? cacheableUser = null; + + if (data.InviterId is not null) + { + var cachedUser = discord.GetUser(data.InviterId.Value); + cacheableUser = new Cacheable( + cachedUser, + data.InviterId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(data.InviterId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + } + + return new SocketInviteCreateAuditLogData(data, cacheableUser); + } + + /// + /// Gets the time (in seconds) until the invite expires. + /// + /// + /// An representing the time in seconds until this invite expires. + /// + public int MaxAge { get; } + + /// + /// Gets the unique identifier for this invite. + /// + /// + /// A string containing the invite code (e.g. FTqNnyS). + /// + public string Code { get; } + + /// + /// Gets a value that determines whether the invite is a temporary one. + /// + /// + /// if users accepting this invite will be removed from the guild when they log off; otherwise + /// . + /// + public bool Temporary { get; } + + /// + /// Gets the user that created this invite if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user that created this invite or . + /// + public Cacheable? Creator { get; } + + /// + /// Gets the ID of the channel this invite is linked to. + /// + /// + /// A representing the channel snowflake identifier that the invite points to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the number of times this invite has been used. + /// + /// + /// An representing the number of times this invite was used. + /// + public int Uses { get; } + + /// + /// Gets the max number of uses this invite may have. + /// + /// + /// An representing the number of uses this invite may be accepted until it is removed + /// from the guild; if none is set. + /// + public int MaxUses { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteDeleteAuditLogData.cs new file mode 100644 index 0000000..1f260aa --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteDeleteAuditLogData.cs @@ -0,0 +1,109 @@ +using Discord.API.AuditLogs; +using Discord.Rest; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an invite removal. +/// +public class SocketInviteDeleteAuditLogData : ISocketAuditLogData +{ + private SocketInviteDeleteAuditLogData(InviteInfoAuditLogModel model, Cacheable? inviter) + { + MaxAge = model.MaxAge!.Value; + Code = model.Code; + Temporary = model.Temporary!.Value; + Creator = inviter; + ChannelId = model.ChannelId!.Value; + Uses = model.Uses!.Value; + MaxUses = model.MaxUses!.Value; + } + + internal static SocketInviteDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + Cacheable? cacheableUser = null; + + if (data.InviterId != null) + { + var cachedUser = discord.GetUser(data.InviterId.Value); + cacheableUser = new Cacheable( + cachedUser, + data.InviterId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(data.InviterId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + } + + return new SocketInviteDeleteAuditLogData(data, cacheableUser); + } + + /// + /// Gets the time (in seconds) until the invite expires. + /// + /// + /// An representing the time in seconds until this invite expires. + /// + public int MaxAge { get; } + + /// + /// Gets the unique identifier for this invite. + /// + /// + /// A string containing the invite code (e.g. FTqNnyS). + /// + public string Code { get; } + + /// + /// Gets a value that indicates whether the invite is a temporary one. + /// + /// + /// if users accepting this invite will be removed from the guild when they log off; otherwise + /// . + /// + public bool Temporary { get; } + + /// + /// Gets the user that created this invite if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user that created this invite or . + /// + public Cacheable? Creator { get; } + + /// + /// Gets the ID of the channel this invite is linked to. + /// + /// + /// A representing the channel snowflake identifier that the invite points to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the number of times this invite has been used. + /// + /// + /// An representing the number of times this invite has been used. + /// + public int Uses { get; } + + /// + /// Gets the max number of uses this invite may have. + /// + /// + /// An representing the number of uses this invite may be accepted until it is removed + /// from the guild; if none is set. + /// + public int MaxUses { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteInfo.cs new file mode 100644 index 0000000..82570d5 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteInfo.cs @@ -0,0 +1,68 @@ +using Model = Discord.API.AuditLogs.InviteInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Represents information for an invite. +/// +public struct SocketInviteInfo +{ + internal SocketInviteInfo(Model model) + { + MaxAge = model.MaxAge; + Code = model.Code; + Temporary = model.Temporary; + ChannelId = model.ChannelId; + MaxUses = model.MaxUses; + CreatorId = model.InviterId; + } + + /// + /// Gets the time (in seconds) until the invite expires. + /// + /// + /// An representing the time in seconds until this invite expires; if this + /// invite never expires or not specified. + /// + public int? MaxAge { get; } + + /// + /// Gets the unique identifier for this invite. + /// + /// + /// A string containing the invite code (e.g. FTqNnyS). + /// + public string Code { get; } + + /// + /// Gets a value that indicates whether the invite is a temporary one. + /// + /// + /// if users accepting this invite will be removed from the guild when they log off, + /// if not; if not specified. + /// + public bool? Temporary { get; } + + /// + /// Gets the ID of the channel this invite is linked to. + /// + /// + /// A representing the channel snowflake identifier that the invite points to; + /// if not specified. + /// + public ulong? ChannelId { get; } + + /// + /// Gets the max number of uses this invite may have. + /// + /// + /// An representing the number of uses this invite may be accepted until it is removed + /// from the guild; if none is specified. + /// + public int? MaxUses { get; } + + /// + /// Gets the id of the user created this invite. + /// + public ulong? CreatorId { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteUpdateAuditLogData.cs new file mode 100644 index 0000000..a413407 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketInviteUpdateAuditLogData.cs @@ -0,0 +1,42 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data relating to an invite update. +/// +public class SocketInviteUpdateAuditLogData : ISocketAuditLogData +{ + private SocketInviteUpdateAuditLogData(SocketInviteInfo before, SocketInviteInfo after) + { + Before = before; + After = after; + } + + internal static SocketInviteUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketInviteUpdateAuditLogData(new(before), new(after)); + } + + /// + /// Gets the invite information before the changes. + /// + /// + /// An information object containing the original invite information before the changes were made. + /// + public SocketInviteInfo Before { get; } + + /// + /// Gets the invite information after the changes. + /// + /// + /// An information object containing the invite information after the changes were made. + /// + public SocketInviteInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketKickAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketKickAuditLogData.cs new file mode 100644 index 0000000..09263ea --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketKickAuditLogData.cs @@ -0,0 +1,49 @@ +using Discord.Rest; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a kick. +/// +public class SocketKickAuditLogData : ISocketAuditLogData +{ + private SocketKickAuditLogData(Cacheable user, string integrationType) + { + Target = user; + IntegrationType = integrationType; + } + + internal static SocketKickAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var cachedUser = discord.GetUser(entry.TargetId!.Value); + var cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId!.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + return new SocketKickAuditLogData(cacheableUser, entry.Options?.IntegrationType); + } + + /// + /// Gets the user that was kicked. + /// + /// + /// Download method may return if the user is a 'Deleted User#....' + /// because Discord does send user data for deleted users. + /// + /// + /// A cacheable user object representing the kicked user. + /// + public Cacheable Target { get; } + + /// + /// Gets the type of integration which performed the action. if the action was performed by a user. + /// + public string IntegrationType { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberDisconnectAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberDisconnectAuditLogData.cs new file mode 100644 index 0000000..42f1df4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberDisconnectAuditLogData.cs @@ -0,0 +1,27 @@ +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to disconnecting members from voice channels. +/// +public class SocketMemberDisconnectAuditLogData : ISocketAuditLogData +{ + private SocketMemberDisconnectAuditLogData(int count) + { + MemberCount = count; + } + + internal static SocketMemberDisconnectAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new SocketMemberDisconnectAuditLogData(entry.Options.Count!.Value); + } + + /// + /// Gets the number of members that were disconnected. + /// + /// + /// An representing the number of members that were disconnected from a voice channel. + /// + public int MemberCount { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberMoveAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberMoveAuditLogData.cs new file mode 100644 index 0000000..f158b0f --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberMoveAuditLogData.cs @@ -0,0 +1,36 @@ +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to moving members between voice channels. +/// +public class SocketMemberMoveAuditLogData : ISocketAuditLogData +{ + private SocketMemberMoveAuditLogData(ulong channelId, int count) + { + ChannelId = channelId; + MemberCount = count; + } + + internal static SocketMemberMoveAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new SocketMemberMoveAuditLogData(entry.Options.ChannelId!.Value, entry.Options.Count!.Value); + } + + /// + /// Gets the ID of the channel that the members were moved to. + /// + /// + /// A representing the snowflake identifier for the channel that the members were moved to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the number of members that were moved. + /// + /// + /// An representing the number of members that were moved to another voice channel. + /// + public int MemberCount { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberRoleAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberRoleAuditLogData.cs new file mode 100644 index 0000000..84b64a9 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberRoleAuditLogData.cs @@ -0,0 +1,64 @@ +using Discord.Rest; +using System.Collections.Generic; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a change in a guild member's roles. +/// +public class SocketMemberRoleAuditLogData : ISocketAuditLogData +{ + private SocketMemberRoleAuditLogData(IReadOnlyCollection roles, Cacheable target, string integrationType) + { + Roles = roles; + Target = target; + IntegrationType = integrationType; + } + + internal static SocketMemberRoleAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var roleInfos = changes.SelectMany(x => x.NewValue.ToObject(discord.ApiClient.Serializer), + (model, role) => new { model.ChangedProperty, Role = role }) + .Select(x => new SocketMemberRoleEditInfo(x.Role.Name, x.Role.Id, x.ChangedProperty == "$add", x.ChangedProperty == "$remove")) + .ToList(); + + var cachedUser = discord.GetUser(entry.TargetId!.Value); + var cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + + return new SocketMemberRoleAuditLogData(roleInfos.ToReadOnlyCollection(), cacheableUser, entry.Options?.IntegrationType); + } + + /// + /// Gets a collection of role changes that were performed on the member. + /// + /// + /// A read-only collection of , containing the roles that were changed on + /// the member. + /// + public IReadOnlyCollection Roles { get; } + + /// + /// Gets the user that the roles changes were performed on. + /// + /// + /// A cacheable user object representing the user that the role changes were performed on. + /// + public Cacheable Target { get; } + + /// + /// Gets the type of integration which performed the action. if the action was performed by a user. + /// + public string IntegrationType { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberRoleEditInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberRoleEditInfo.cs new file mode 100644 index 0000000..bc22bfe --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberRoleEditInfo.cs @@ -0,0 +1,44 @@ +namespace Discord.WebSocket; + +/// +/// An information object representing a change in one of a guild member's roles. +/// +public struct SocketMemberRoleEditInfo +{ + internal SocketMemberRoleEditInfo(string name, ulong roleId, bool added, bool removed) + { + Name = name; + RoleId = roleId; + Added = added; + Removed = removed; + } + + /// + /// Gets the name of the role that was changed. + /// + /// + /// A string containing the name of the role that was changed. + /// + public string Name { get; } + /// + /// Gets the ID of the role that was changed. + /// + /// + /// A representing the snowflake identifier of the role that was changed. + /// + public ulong RoleId { get; } + /// + /// Gets a value that indicates whether the role was added to the user. + /// + /// + /// if the role was added to the user; otherwise . + /// + public bool Added { get; } + /// + /// Gets a value indicating that the user role has been removed. + /// + /// + /// true if the role has been removed from the user; otherwise false. + /// + public bool Removed { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberUpdateAuditLogData.cs new file mode 100644 index 0000000..93c0263 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMemberUpdateAuditLogData.cs @@ -0,0 +1,65 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a change in a guild member. +/// +public class SocketMemberUpdateAuditLogData : ISocketAuditLogData +{ + private SocketMemberUpdateAuditLogData(Cacheable target, MemberInfo before, MemberInfo after) + { + Target = target; + Before = before; + After = after; + } + + internal static SocketMemberUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + var cachedUser = discord.GetUser(entry.TargetId!.Value); + var cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + + return new SocketMemberUpdateAuditLogData(cacheableUser, new MemberInfo(before), new MemberInfo(after)); + } + + /// + /// Gets the user that the changes were performed on. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user who the changes were performed on. + /// + public Cacheable Target { get; } + + /// + /// Gets the member information before the changes. + /// + /// + /// An information object containing the original member information before the changes were made. + /// + public MemberInfo Before { get; } + + /// + /// Gets the member information after the changes. + /// + /// + /// An information object containing the member information after the changes were made. + /// + public MemberInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageBulkDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageBulkDeleteAuditLogData.cs new file mode 100644 index 0000000..be292c2 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageBulkDeleteAuditLogData.cs @@ -0,0 +1,37 @@ +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to message deletion(s). +/// +public class SocketMessageBulkDeleteAuditLogData : ISocketAuditLogData +{ + private SocketMessageBulkDeleteAuditLogData(ulong channelId, int count) + { + ChannelId = channelId; + MessageCount = count; + } + + internal static SocketMessageBulkDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new SocketMessageBulkDeleteAuditLogData(entry.TargetId!.Value, entry.Options.Count!.Value); + } + + /// + /// Gets the ID of the channel that the messages were deleted from. + /// + /// + /// A representing the snowflake identifier for the channel that the messages were + /// deleted from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the number of messages that were deleted. + /// + /// + /// An representing the number of messages that were deleted from the channel. + /// + public int MessageCount { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageDeleteAuditLogData.cs new file mode 100644 index 0000000..706e240 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageDeleteAuditLogData.cs @@ -0,0 +1,62 @@ +using Discord.Rest; +using System; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to message deletion(s). +/// +public class SocketMessageDeleteAuditLogData : ISocketAuditLogData +{ + private SocketMessageDeleteAuditLogData(ulong channelId, int count, Cacheable user) + { + ChannelId = channelId; + MessageCount = count; + Target = user; + } + + internal static SocketMessageDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var cachedUser = discord.GetUser(entry.TargetId!.Value); + var cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + + return new SocketMessageDeleteAuditLogData(entry.Options.ChannelId!.Value, entry.Options.Count!.Value, cacheableUser); + } + + /// + /// Gets the number of messages that were deleted. + /// + /// + /// An representing the number of messages that were deleted from the channel. + /// + public int MessageCount { get; } + + /// + /// Gets the ID of the channel that the messages were deleted from. + /// + /// + /// A representing the snowflake identifier for the channel that the messages were + /// deleted from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the user of the messages that were deleted. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user that created the deleted messages. + /// + public Cacheable Target { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessagePinAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessagePinAuditLogData.cs new file mode 100644 index 0000000..723fb8e --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessagePinAuditLogData.cs @@ -0,0 +1,65 @@ +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a pinned message. +/// +public class SocketMessagePinAuditLogData : ISocketAuditLogData +{ + private SocketMessagePinAuditLogData(ulong messageId, ulong channelId, Cacheable? user) + { + MessageId = messageId; + ChannelId = channelId; + Target = user; + } + + internal static SocketMessagePinAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + Cacheable? cacheableUser = null; + + if (entry.TargetId.HasValue) + { + var cachedUser = discord.GetUser(entry.TargetId.Value); + cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + } + + return new SocketMessagePinAuditLogData(entry.Options.MessageId!.Value, entry.Options.ChannelId!.Value, cacheableUser); + } + + /// + /// Gets the ID of the messages that was pinned. + /// + /// + /// A representing the snowflake identifier for the messages that was pinned. + /// + public ulong MessageId { get; } + + /// + /// Gets the ID of the channel that the message was pinned from. + /// + /// + /// A representing the snowflake identifier for the channel that the message was pinned from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the user of the message that was pinned if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user that created the pinned message or . + /// + public Cacheable? Target { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageUnpinAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageUnpinAuditLogData.cs new file mode 100644 index 0000000..088152a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketMessageUnpinAuditLogData.cs @@ -0,0 +1,64 @@ +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an unpinned message. +/// +public class SocketMessageUnpinAuditLogData : ISocketAuditLogData +{ + private SocketMessageUnpinAuditLogData(ulong messageId, ulong channelId, Cacheable? user) + { + MessageId = messageId; + ChannelId = channelId; + Target = user; + } + + internal static SocketMessageUnpinAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + Cacheable? cacheableUser = null; + if (entry.TargetId.HasValue) + { + var cachedUser = discord.GetUser(entry.TargetId.Value); + cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + } + + return new SocketMessageUnpinAuditLogData(entry.Options.MessageId!.Value, entry.Options.ChannelId!.Value, cacheableUser); + } + + /// + /// Gets the ID of the messages that was unpinned. + /// + /// + /// A representing the snowflake identifier for the messages that was unpinned. + /// + public ulong MessageId { get; } + + /// + /// Gets the ID of the channel that the message was unpinned from. + /// + /// + /// A representing the snowflake identifier for the channel that the message was unpinned from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the user of the message that was unpinned if available. + /// + /// + /// Will be if the user is a 'Deleted User#....' because Discord does send user data for deleted users. + /// + /// + /// A user object representing the user that created the unpinned message or . + /// + public Cacheable? Target { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingInfo.cs new file mode 100644 index 0000000..6ecd222 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingInfo.cs @@ -0,0 +1,35 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.WebSocket; + +public class SocketOnboardingInfo +{ + internal SocketOnboardingInfo(OnboardingAuditLogModel model, DiscordSocketClient discord) + { + Prompts = model.Prompts?.Select(x => new RestGuildOnboardingPrompt(discord, x.Id, x)).ToImmutableArray(); + DefaultChannelIds = model.DefaultChannelIds; + IsEnabled = model.Enabled; + } + + /// + /// + /// if this property is not mentioned in this entry. + /// + IReadOnlyCollection Prompts { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + IReadOnlyCollection DefaultChannelIds { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsEnabled { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptCreatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptCreatedAuditLogData.cs new file mode 100644 index 0000000..7cf7517 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptCreatedAuditLogData.cs @@ -0,0 +1,30 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an onboarding prompt creation. +/// +public class SocketOnboardingPromptCreatedAuditLogData : ISocketAuditLogData +{ + internal SocketOnboardingPromptCreatedAuditLogData(SocketOnboardingPromptInfo data) + { + Data = data; + } + + internal static SocketOnboardingPromptCreatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketOnboardingPromptCreatedAuditLogData(new(data, discord)); + } + + /// + /// Gets the onboarding prompt information after the changes. + /// + SocketOnboardingPromptInfo Data { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptInfo.cs new file mode 100644 index 0000000..b5e2478 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptInfo.cs @@ -0,0 +1,56 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.WebSocket; + +public class SocketOnboardingPromptInfo +{ + internal SocketOnboardingPromptInfo(OnboardingPromptAuditLogModel model, DiscordSocketClient discord) + { + Title = model.Title; + IsSingleSelect = model.IsSingleSelect; + IsRequired = model.IsRequired; + IsInOnboarding = model.IsInOnboarding; + Type = model.Type; + Options = model.Options?.Select(x => new RestGuildOnboardingPromptOption(discord, x.Id, x)).ToImmutableArray(); + } + + /// + /// + /// if this property is not mentioned in this entry. + /// + string Title { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsSingleSelect { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsRequired { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + bool? IsInOnboarding { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + GuildOnboardingPromptType? Type { get; } + + /// + /// + /// if this property is not mentioned in this entry. + /// + IReadOnlyCollection Options { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptUpdatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptUpdatedAuditLogData.cs new file mode 100644 index 0000000..ad168d4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingPromptUpdatedAuditLogData.cs @@ -0,0 +1,38 @@ +using Discord.Rest; +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + + +/// +/// Contains a piece of audit log data related to an onboarding prompt update. +/// +public class SocketOnboardingPromptUpdatedAuditLogData : ISocketAuditLogData +{ + internal SocketOnboardingPromptUpdatedAuditLogData(SocketOnboardingPromptInfo before, SocketOnboardingPromptInfo after) + { + Before = before; + After = after; + } + + internal static SocketOnboardingPromptUpdatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketOnboardingPromptUpdatedAuditLogData(new(before, discord), new(after, discord)); + } + + /// + /// Gets the onboarding prompt information after the changes. + /// + SocketOnboardingPromptInfo After { get; set; } + + /// + /// Gets the onboarding prompt information before the changes. + /// + SocketOnboardingPromptInfo Before { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingUpdatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingUpdatedAuditLogData.cs new file mode 100644 index 0000000..1ee9499 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOnboardingUpdatedAuditLogData.cs @@ -0,0 +1,37 @@ +namespace Discord.WebSocket; + +using Discord.Rest; +using Discord.API.AuditLogs; + +using EntryModel = Discord.API.AuditLogEntry; + +/// +/// Contains a piece of audit log data related to a guild update. +/// +public class SocketOnboardingUpdatedAuditLogData : ISocketAuditLogData +{ + internal SocketOnboardingUpdatedAuditLogData(SocketOnboardingInfo before, SocketOnboardingInfo after) + { + Before = before; + After = after; + } + + internal static SocketOnboardingUpdatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketOnboardingUpdatedAuditLogData(new(before, discord), new(after, discord)); + } + + /// + /// Gets the onboarding information after the changes. + /// + SocketOnboardingInfo After { get; set; } + + /// + /// Gets the onboarding information before the changes. + /// + SocketOnboardingInfo Before { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteCreateAuditLogData.cs new file mode 100644 index 0000000..79f9b8c --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteCreateAuditLogData.cs @@ -0,0 +1,52 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data for a permissions overwrite creation. +/// +public class SocketOverwriteCreateAuditLogData : ISocketAuditLogData +{ + private SocketOverwriteCreateAuditLogData(ulong channelId, Overwrite overwrite) + { + ChannelId = channelId; + Overwrite = overwrite; + } + + internal static SocketOverwriteCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var denyModel = changes.FirstOrDefault(x => x.ChangedProperty == "deny"); + var allowModel = changes.FirstOrDefault(x => x.ChangedProperty == "allow"); + + var deny = denyModel.NewValue.ToObject(discord.ApiClient.Serializer); + var allow = allowModel.NewValue.ToObject(discord.ApiClient.Serializer); + + var permissions = new OverwritePermissions(allow, deny); + + var id = entry.Options.OverwriteTargetId.Value; + var type = entry.Options.OverwriteType; + + return new SocketOverwriteCreateAuditLogData(entry.TargetId.Value, new Overwrite(id, type, permissions)); + } + + /// + /// Gets the ID of the channel that the overwrite was created from. + /// + /// + /// A representing the snowflake identifier for the channel that the overwrite was + /// created from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the permission overwrite object that was created. + /// + /// + /// An object representing the overwrite that was created. + /// + public Overwrite Overwrite { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteDeleteAuditLogData.cs new file mode 100644 index 0000000..6065200 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteDeleteAuditLogData.cs @@ -0,0 +1,51 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to the deletion of a permission overwrite. +/// +public class SocketOverwriteDeleteAuditLogData : ISocketAuditLogData +{ + private SocketOverwriteDeleteAuditLogData(ulong channelId, Overwrite deletedOverwrite) + { + ChannelId = channelId; + Overwrite = deletedOverwrite; + } + + internal static SocketOverwriteDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var denyModel = changes.FirstOrDefault(x => x.ChangedProperty == "deny"); + var allowModel = changes.FirstOrDefault(x => x.ChangedProperty == "allow"); + + var deny = denyModel.OldValue.ToObject(discord.ApiClient.Serializer); + var allow = allowModel.OldValue.ToObject(discord.ApiClient.Serializer); + + var permissions = new OverwritePermissions(allow, deny); + + var id = entry.Options.OverwriteTargetId.Value; + var type = entry.Options.OverwriteType; + + return new SocketOverwriteDeleteAuditLogData(entry.TargetId!.Value, new Overwrite(id, type, permissions)); + } + + /// + /// Gets the ID of the channel that the overwrite was deleted from. + /// + /// + /// A representing the snowflake identifier for the channel that the overwrite was + /// deleted from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the permission overwrite object that was deleted. + /// + /// + /// An object representing the overwrite that was deleted. + /// + public Overwrite Overwrite { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteUpdateAuditLogData.cs new file mode 100644 index 0000000..fd21a97 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketOverwriteUpdateAuditLogData.cs @@ -0,0 +1,83 @@ +using System.Linq; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to the update of a permission overwrite. +/// +public class SocketOverwriteUpdateAuditLogData : ISocketAuditLogData +{ + private SocketOverwriteUpdateAuditLogData(ulong channelId, OverwritePermissions before, OverwritePermissions after, ulong targetId, PermissionTarget targetType) + { + ChannelId = channelId; + OldPermissions = before; + NewPermissions = after; + OverwriteTargetId = targetId; + OverwriteType = targetType; + } + + internal static SocketOverwriteUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var denyModel = changes.FirstOrDefault(x => x.ChangedProperty == "deny"); + var allowModel = changes.FirstOrDefault(x => x.ChangedProperty == "allow"); + + var beforeAllow = allowModel?.OldValue?.ToObject(discord.ApiClient.Serializer); + var afterAllow = allowModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + var beforeDeny = denyModel?.OldValue?.ToObject(discord.ApiClient.Serializer); + var afterDeny = denyModel?.NewValue?.ToObject(discord.ApiClient.Serializer); + + var beforePermissions = new OverwritePermissions(beforeAllow ?? 0, beforeDeny ?? 0); + var afterPermissions = new OverwritePermissions(afterAllow ?? 0, afterDeny ?? 0); + + var type = entry.Options.OverwriteType; + + return new SocketOverwriteUpdateAuditLogData(entry.TargetId.Value, beforePermissions, afterPermissions, entry.Options.OverwriteTargetId.Value, type); + } + + /// + /// Gets the ID of the channel that the overwrite was updated from. + /// + /// + /// A representing the snowflake identifier for the channel that the overwrite was + /// updated from. + /// + public ulong ChannelId { get; } + + /// + /// Gets the overwrite permissions before the changes. + /// + /// + /// An overwrite permissions object representing the overwrite permissions that the overwrite had before + /// the changes were made. + /// + public OverwritePermissions OldPermissions { get; } + + /// + /// Gets the overwrite permissions after the changes. + /// + /// + /// An overwrite permissions object representing the overwrite permissions that the overwrite had after the + /// changes. + /// + public OverwritePermissions NewPermissions { get; } + + /// + /// Gets the ID of the overwrite that was updated. + /// + /// + /// A representing the snowflake identifier of the overwrite that was updated. + /// + public ulong OverwriteTargetId { get; } + + /// + /// Gets the target of the updated permission overwrite. + /// + /// + /// The target of the updated permission overwrite. + /// + public PermissionTarget OverwriteType { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketPruneAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketPruneAuditLogData.cs new file mode 100644 index 0000000..5da0a97 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketPruneAuditLogData.cs @@ -0,0 +1,39 @@ +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a guild prune. +/// +public class SocketPruneAuditLogData : ISocketAuditLogData +{ + private SocketPruneAuditLogData(int pruneDays, int membersRemoved) + { + PruneDays = pruneDays; + MembersRemoved = membersRemoved; + } + + internal static SocketPruneAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new SocketPruneAuditLogData(entry.Options.PruneDeleteMemberDays!.Value, entry.Options.PruneMembersRemoved!.Value); + } + + /// + /// Gets the threshold for a guild member to not be kicked. + /// + /// + /// An representing the amount of days that a member must have been seen in the server, + /// to avoid being kicked. (i.e. If a user has not been seen for more than , they will be + /// kicked from the server) + /// + public int PruneDays { get; } + + /// + /// Gets the number of members that were kicked during the purge. + /// + /// + /// An representing the number of members that were removed from this guild for having + /// not been seen within . + /// + public int MembersRemoved { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleCreateAuditLogData.cs new file mode 100644 index 0000000..ea600bb --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleCreateAuditLogData.cs @@ -0,0 +1,44 @@ +using Discord.Rest; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLogs.RoleInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a role creation. +/// +public class SocketRoleCreateAuditLogData : ISocketAuditLogData +{ + private SocketRoleCreateAuditLogData(ulong id, SocketRoleEditInfo props) + { + RoleId = id; + Properties = props; + } + + internal static SocketRoleCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketRoleCreateAuditLogData(entry.TargetId!.Value, + new SocketRoleEditInfo(data)); + } + + /// + /// Gets the ID of the role that was created. + /// + /// + /// A representing the snowflake identifier to the role that was created. + /// + public ulong RoleId { get; } + + /// + /// Gets the role information that was created. + /// + /// + /// An information object representing the properties of the role that was created. + /// + public SocketRoleEditInfo Properties { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleDeleteAuditLogData.cs new file mode 100644 index 0000000..dc1cd92 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleDeleteAuditLogData.cs @@ -0,0 +1,43 @@ +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLogs.RoleInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data relating to a role deletion. +/// +public class SocketRoleDeleteAuditLogData : ISocketAuditLogData +{ + private SocketRoleDeleteAuditLogData(ulong id, SocketRoleEditInfo props) + { + RoleId = id; + Properties = props; + } + + internal static SocketRoleDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketRoleDeleteAuditLogData(entry.TargetId!.Value, + new SocketRoleEditInfo(data)); + } + + /// + /// Gets the ID of the role that was deleted. + /// + /// + /// A representing the snowflake identifier to the role that was deleted. + /// + public ulong RoleId { get; } + + /// + /// Gets the role information that was deleted. + /// + /// + /// An information object representing the properties of the role that was deleted. + /// + public SocketRoleEditInfo Properties { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleEditInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleEditInfo.cs new file mode 100644 index 0000000..fbf6ba3 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleEditInfo.cs @@ -0,0 +1,79 @@ +using Model = Discord.API.AuditLogs.RoleInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Represents information for a role edit. +/// +public struct SocketRoleEditInfo +{ + internal SocketRoleEditInfo(Model model) + { + if (model.Color is not null) + Color = new Color(model.Color.Value); + else + Color = null; + + Mentionable = model.IsMentionable; + Hoist = model.Hoist; + Name = model.Name; + + if(model.Permissions is not null) + Permissions = new GuildPermissions(model.Permissions.Value); + else + Permissions = null; + + IconId = model.IconHash; + } + + /// + /// Gets the color of this role. + /// + /// + /// A color object representing the color assigned to this role; if this role does not have a + /// color. + /// + public Color? Color { get; } + + /// + /// Gets a value that indicates whether this role is mentionable. + /// + /// + /// if other members can mention this role in a text channel; otherwise ; + /// if this is not mentioned in this entry. + /// + public bool? Mentionable { get; } + + /// + /// Gets a value that indicates whether this role is hoisted (i.e. its members will appear in a separate + /// section on the user list). + /// + /// + /// if this role's members will appear in a separate section in the user list; otherwise + /// ; if this is not mentioned in this entry. + /// + public bool? Hoist { get; } + + /// + /// Gets the name of this role. + /// + /// + /// A string containing the name of this role. + /// + public string Name { get; } + + /// + /// Gets the permissions assigned to this role. + /// + /// + /// A guild permissions object representing the permissions that have been assigned to this role; + /// if no permissions have been assigned. + /// + public GuildPermissions? Permissions { get; } + + /// + /// + /// if the value was not updated in this entry. + /// + public string IconId { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleUpdateAuditLogData.cs new file mode 100644 index 0000000..e0845a1 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketRoleUpdateAuditLogData.cs @@ -0,0 +1,51 @@ +using Discord.Rest; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLogs.RoleInfoAuditLogModel; + + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a role update. +/// +public class SocketRoleUpdateAuditLogData : ISocketAuditLogData +{ + private SocketRoleUpdateAuditLogData(ulong id, SocketRoleEditInfo oldProps, SocketRoleEditInfo newProps) + { + RoleId = id; + Before = oldProps; + After = newProps; + } + + internal static SocketRoleUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketRoleUpdateAuditLogData(entry.TargetId!.Value, new(before), new(after)); + } + + /// + /// Gets the ID of the role that was changed. + /// + /// + /// A representing the snowflake identifier of the role that was changed. + /// + public ulong RoleId { get; } + /// + /// Gets the role information before the changes. + /// + /// + /// A role information object containing the role information before the changes were made. + /// + public SocketRoleEditInfo Before { get; } + /// + /// Gets the role information after the changes. + /// + /// + /// A role information object containing the role information after the changes were made. + /// + public SocketRoleEditInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventCreateAuditLogData.cs new file mode 100644 index 0000000..8530c2f --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventCreateAuditLogData.cs @@ -0,0 +1,88 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using System; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a scheduled event creation. +/// +public class SocketScheduledEventCreateAuditLogData : ISocketAuditLogData +{ + private SocketScheduledEventCreateAuditLogData(ulong id, ScheduledEventInfoAuditLogModel model) + { + Id = id; + ChannelId = model.ChannelId; + Name = model.Name; + Description = model.Description; + ScheduledStartTime = model.StartTime; + ScheduledEndTime = model.EndTime; + PrivacyLevel = model.PrivacyLevel!.Value; + Status = model.EventStatus!.Value; + EntityType = model.EventType!.Value; + EntityId = model.EntityId; + Location = model.Location; + Image = model.Image; + } + + internal static SocketScheduledEventCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketScheduledEventCreateAuditLogData(entry.TargetId!.Value, data); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + /// + /// Gets name of the event. + /// + public string Name { get; } + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset? ScheduledStartTime { get; } + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel PrivacyLevel { get; } + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus Status { get; } + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType EntityType { get; } + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventDeleteAuditLogData.cs new file mode 100644 index 0000000..cc9bffd --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventDeleteAuditLogData.cs @@ -0,0 +1,97 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using System; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a scheduled event deletion. +/// +public class SocketScheduledEventDeleteAuditLogData : ISocketAuditLogData +{ + private SocketScheduledEventDeleteAuditLogData(ulong id, ScheduledEventInfoAuditLogModel model) + { + Id = id; + ChannelId = model.ChannelId; + Name = model.Name; + Description = model.Description; + ScheduledStartTime = model.StartTime; + ScheduledEndTime = model.EndTime; + PrivacyLevel = model.PrivacyLevel; + Status = model.EventStatus; + EntityType = model.EventType; + EntityId = model.EntityId; + Location = model.Location; + Image = model.Image; + } + + internal static SocketScheduledEventDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketScheduledEventDeleteAuditLogData(entry.TargetId!.Value, data); + } + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + + /// + /// Gets name of the event. + /// + public string Name { get; } + + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset? ScheduledStartTime { get; } + + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel? PrivacyLevel { get; } + + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus? Status { get; } + + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType? EntityType { get; } + + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventInfo.cs new file mode 100644 index 0000000..1655268 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventInfo.cs @@ -0,0 +1,80 @@ +using Discord.API.AuditLogs; +using System; + +namespace Discord.WebSocket; + +/// +/// Represents information for a scheduled event. +/// +public class SocketScheduledEventInfo +{ + /// + /// Gets the snowflake id of the channel the event is associated with. + /// + public ulong? ChannelId { get; } + + /// + /// Gets name of the event. + /// + public string Name { get; } + + /// + /// Gets the description of the event. null if none is set. + /// + public string Description { get; } + + /// + /// Gets the time the event was scheduled for. + /// + public DateTimeOffset? ScheduledStartTime { get; } + + /// + /// Gets the time the event was scheduled to end. + /// + public DateTimeOffset? ScheduledEndTime { get; } + + /// + /// Gets the privacy level of the event. + /// + public GuildScheduledEventPrivacyLevel? PrivacyLevel { get; } + + /// + /// Gets the status of the event. + /// + public GuildScheduledEventStatus? Status { get; } + + /// + /// Gets the type of the entity associated with the event (stage / void / external). + /// + public GuildScheduledEventType? EntityType { get; } + + /// + /// Gets the snowflake id of the entity associated with the event (stage / void / external). + /// + public ulong? EntityId { get; } + + /// + /// Gets the metadata for the entity associated with the event. + /// + public string Location { get; } + + /// + /// Gets the image hash of the image that was attached to the event. Null if not set. + /// + public string Image { get; } + + internal SocketScheduledEventInfo(ScheduledEventInfoAuditLogModel model) + { + ChannelId = model.ChannelId; + Name = model.Name; + Description = model.Description; + ScheduledStartTime = model.StartTime; + ScheduledEndTime = model.EndTime; + PrivacyLevel = model.PrivacyLevel; + Status = model.EventStatus; + EntityType = model.EventType; + EntityId = model.EntityId; + Location = model.Location; + Image = model.Image; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventUpdateAuditLogData.cs new file mode 100644 index 0000000..c1bbb07 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketScheduledEventUpdateAuditLogData.cs @@ -0,0 +1,44 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a scheduled event updates. +/// +public class SocketScheduledEventUpdateAuditLogData : ISocketAuditLogData +{ + private SocketScheduledEventUpdateAuditLogData(ulong id, SocketScheduledEventInfo before, SocketScheduledEventInfo after) + { + Id = id; + Before = before; + After = after; + } + + internal static SocketScheduledEventUpdateAuditLogData Create(BaseDiscordClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketScheduledEventUpdateAuditLogData(entry.TargetId!.Value, new(before), new(after)); + } + + // Doc Note: Corresponds to the *current* data + + /// + /// Gets the snowflake id of the event. + /// + public ulong Id { get; } + + /// + /// Gets the state before the change. + /// + public SocketScheduledEventInfo Before { get; } + + /// + /// Gets the state after the change. + /// + public SocketScheduledEventInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInfo.cs new file mode 100644 index 0000000..e6af05b --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInfo.cs @@ -0,0 +1,23 @@ +namespace Discord.WebSocket; + +/// +/// Represents information for a stage. +/// +public class SocketStageInfo +{ + /// + /// Gets the topic of the stage channel. + /// + public string Topic { get; } + + /// + /// Gets the privacy level of the stage channel. + /// + public StagePrivacyLevel? PrivacyLevel { get; } + + internal SocketStageInfo(StagePrivacyLevel? level, string topic) + { + Topic = topic; + PrivacyLevel = level; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceCreateAuditLogData.cs new file mode 100644 index 0000000..c4ff73d --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceCreateAuditLogData.cs @@ -0,0 +1,41 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a stage going live. +/// +public class SocketStageInstanceCreateAuditLogData : ISocketAuditLogData +{ + /// + /// Gets the topic of the stage channel. + /// + public string Topic { get; } + + /// + /// Gets the privacy level of the stage channel. + /// + public StagePrivacyLevel PrivacyLevel { get; } + + /// + /// Gets the Id of the stage channel. + /// + public ulong StageChannelId { get; } + + internal SocketStageInstanceCreateAuditLogData(string topic, StagePrivacyLevel privacyLevel, ulong channelId) + { + Topic = topic; + PrivacyLevel = privacyLevel; + StageChannelId = channelId; + } + + internal static SocketStageInstanceCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var topic = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "topic").NewValue.ToObject(discord.ApiClient.Serializer); + var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level").NewValue.ToObject(discord.ApiClient.Serializer); + var channelId = entry.Options.ChannelId; + + return new SocketStageInstanceCreateAuditLogData(topic, privacyLevel, channelId ?? 0); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceDeleteAuditLogData.cs new file mode 100644 index 0000000..5ff40db --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceDeleteAuditLogData.cs @@ -0,0 +1,41 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a stage instance deleted. +/// +public class SocketStageInstanceDeleteAuditLogData : ISocketAuditLogData +{ + /// + /// Gets the topic of the stage channel. + /// + public string Topic { get; } + + /// + /// Gets the privacy level of the stage channel. + /// + public StagePrivacyLevel PrivacyLevel { get; } + + /// + /// Gets the Id of the stage channel. + /// + public ulong StageChannelId { get; } + + internal SocketStageInstanceDeleteAuditLogData(string topic, StagePrivacyLevel privacyLevel, ulong channelId) + { + Topic = topic; + PrivacyLevel = privacyLevel; + StageChannelId = channelId; + } + + internal static SocketStageInstanceDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var topic = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "topic").OldValue.ToObject(discord.ApiClient.Serializer); + var privacyLevel = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy_level").OldValue.ToObject(discord.ApiClient.Serializer); + var channelId = entry.Options.ChannelId; + + return new SocketStageInstanceDeleteAuditLogData(topic, privacyLevel, channelId ?? 0); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceUpdatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceUpdatedAuditLogData.cs new file mode 100644 index 0000000..20e5d5e --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStageInstanceUpdatedAuditLogData.cs @@ -0,0 +1,47 @@ +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a stage instance update. +/// +public class SocketStageInstanceUpdatedAuditLogData : ISocketAuditLogData +{ + /// + /// Gets the Id of the stage channel. + /// + public ulong StageChannelId { get; } + + /// + /// Gets the stage information before the changes. + /// + public SocketStageInfo Before { get; } + + /// + /// Gets the stage information after the changes. + /// + public SocketStageInfo After { get; } + + internal SocketStageInstanceUpdatedAuditLogData(ulong channelId, SocketStageInfo before, SocketStageInfo after) + { + StageChannelId = channelId; + Before = before; + After = after; + } + + internal static SocketStageInstanceUpdatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var channelId = entry.Options.ChannelId.Value; + + var topic = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "topic"); + var privacy = entry.Changes.FirstOrDefault(x => x.ChangedProperty == "privacy"); + + var oldTopic = topic?.OldValue.ToObject(); + var newTopic = topic?.NewValue.ToObject(); + var oldPrivacy = privacy?.OldValue.ToObject(); + var newPrivacy = privacy?.NewValue.ToObject(); + + return new SocketStageInstanceUpdatedAuditLogData(channelId, new SocketStageInfo(oldPrivacy, oldTopic), new SocketStageInfo(newPrivacy, newTopic)); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerCreatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerCreatedAuditLogData.cs new file mode 100644 index 0000000..14048b4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerCreatedAuditLogData.cs @@ -0,0 +1,29 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a sticker creation. +/// +public class SocketStickerCreatedAuditLogData : ISocketAuditLogData +{ + internal SocketStickerCreatedAuditLogData(SocketStickerInfo data) + { + Data = data; + } + + internal static SocketStickerCreatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketStickerCreatedAuditLogData(new(data)); + } + + /// + /// Gets the sticker information after the changes. + /// + public SocketStickerInfo Data { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerDeletedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerDeletedAuditLogData.cs new file mode 100644 index 0000000..191b72a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerDeletedAuditLogData.cs @@ -0,0 +1,29 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a sticker removal. +/// +public class SocketStickerDeletedAuditLogData : ISocketAuditLogData +{ + internal SocketStickerDeletedAuditLogData(SocketStickerInfo data) + { + Data = data; + } + + internal static SocketStickerDeletedAuditLogData Create(BaseDiscordClient discord, EntryModel entry) + { + var changes = entry.Changes; + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketStickerDeletedAuditLogData(new(data)); + } + + /// + /// Gets the sticker information before the changes. + /// + public SocketStickerInfo Data { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerInfo.cs new file mode 100644 index 0000000..e2a44b5 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerInfo.cs @@ -0,0 +1,31 @@ +using Discord.API.AuditLogs; + +namespace Discord.WebSocket; + +/// +/// Represents information for a guild. +/// +public class SocketStickerInfo +{ + internal SocketStickerInfo(StickerInfoAuditLogModel model) + { + Name = model.Name; + Tags = model.Tags; + Description = model.Description; + } + + /// + /// Gets the name of the sticker. if the value was not updated in this entry. + /// + public string Name { get; set; } + + /// + /// Gets tags of the sticker. if the value was not updated in this entry. + /// + public string Tags { get; set; } + + /// + /// Gets the description of the sticker. if the value was not updated in this entry. + /// + public string Description { get; set; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerUpdatedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerUpdatedAuditLogData.cs new file mode 100644 index 0000000..682e7e6 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketStickerUpdatedAuditLogData.cs @@ -0,0 +1,35 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a sticker update. +/// +public class SocketStickerUpdatedAuditLogData : ISocketAuditLogData +{ + internal SocketStickerUpdatedAuditLogData(SocketStickerInfo before, SocketStickerInfo after) + { + Before = before; + After = after; + } + + internal static SocketStickerUpdatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketStickerUpdatedAuditLogData(new(before), new (after)); + } + + /// + /// Gets the sticker information before the changes. + /// + public SocketStickerInfo Before { get; } + + /// + /// Gets the sticker information after the changes. + /// + public SocketStickerInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadCreateAuditLogData.cs new file mode 100644 index 0000000..d55a7a5 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadCreateAuditLogData.cs @@ -0,0 +1,109 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using System.Collections.Generic; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a thread creation. +/// +public class SocketThreadCreateAuditLogData : ISocketAuditLogData +{ + private SocketThreadCreateAuditLogData(ulong id, ThreadInfoAuditLogModel model) + { + ThreadId = id; + + ThreadName = model.Name; + IsArchived = model.IsArchived!.Value; + AutoArchiveDuration = model.ArchiveDuration!.Value; + IsLocked = model.IsLocked!.Value; + SlowModeInterval = model.SlowModeInterval; + AppliedTags = model.AppliedTags; + Flags = model.ChannelFlags; + ThreadType = model.Type; + } + + internal static SocketThreadCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketThreadCreateAuditLogData(entry.TargetId!.Value, data); + } + + /// + /// Gets the snowflake ID of the thread. + /// + /// + /// A representing the snowflake identifier for the thread. + /// + public ulong ThreadId { get; } + + /// + /// Gets the name of the thread. + /// + /// + /// A string containing the name of the thread. + /// + public string ThreadName { get; } + + /// + /// Gets the type of the thread. + /// + /// + /// The type of thread. + /// + public ThreadType ThreadType { get; } + + /// + /// Gets the value that indicates whether the thread is archived. + /// + /// + /// if this thread has the Archived flag enabled; otherwise . + /// + public bool IsArchived { get; } + + /// + /// Gets the auto archive duration of the thread. + /// + /// + /// The thread auto archive duration of the thread. + /// + public ThreadArchiveDuration AutoArchiveDuration { get; } + + /// + /// Gets the value that indicates whether the thread is locked. + /// + /// + /// if this thread has the Locked flag enabled; otherwise . + /// + public bool IsLocked { get; } + + /// + /// Gets the slow-mode delay of the thread. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the applied tags of this thread. + /// + /// + /// if the property was not updated. + /// + public IReadOnlyCollection AppliedTags { get; } + + /// + /// Gets the flags of the thread channel. + /// + /// + /// if the property was not updated. + /// + public ChannelFlags? Flags { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadDeleteAuditLogData.cs new file mode 100644 index 0000000..12bd374 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadDeleteAuditLogData.cs @@ -0,0 +1,111 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using System.Collections.Generic; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a thread deletion. +/// +public class SocketThreadDeleteAuditLogData : ISocketAuditLogData +{ + private SocketThreadDeleteAuditLogData(ulong id, ThreadInfoAuditLogModel model) + { + ThreadId = id; + + ThreadName = model.Name; + IsArchived = model.IsArchived!.Value; + AutoArchiveDuration = model.ArchiveDuration!.Value; + IsLocked = model.IsLocked!.Value; + SlowModeInterval = model.SlowModeInterval; + AppliedTags = model.AppliedTags; + Flags = model.ChannelFlags; + ThreadType = model.Type; + } + + internal static SocketThreadDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketThreadDeleteAuditLogData(entry.TargetId!.Value, data); + } + + /// + /// Gets the snowflake ID of the deleted thread. + /// + /// + /// A representing the snowflake identifier for the deleted thread. + /// + /// + public ulong ThreadId { get; } + + /// + /// Gets the name of the deleted thread. + /// + /// + /// A string containing the name of the deleted thread. + /// + /// + public string ThreadName { get; } + + /// + /// Gets the type of the deleted thread. + /// + /// + /// The type of thread that was deleted. + /// + public ThreadType ThreadType { get; } + + /// + /// Gets the value that indicates whether the deleted thread was archived. + /// + /// + /// if this thread had the Archived flag enabled; otherwise . + /// + public bool IsArchived { get; } + + /// + /// Gets the thread auto archive duration of the deleted thread. + /// + /// + /// The thread auto archive duration of the thread that was deleted. + /// + public ThreadArchiveDuration AutoArchiveDuration { get; } + + /// + /// Gets the value that indicates whether the deleted thread was locked. + /// + /// + /// if this thread had the Locked flag enabled; otherwise . + /// + public bool IsLocked { get; } + + /// + /// Gets the slow-mode delay of the deleted thread. + /// + /// + /// An representing the time in seconds required before the user can send another + /// message; 0 if disabled. + /// if this is not mentioned in this entry. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the applied tags of this thread. + /// + /// + /// if this is not mentioned in this entry. + /// + public IReadOnlyCollection AppliedTags { get; } + + /// + /// Gets the flags of the thread channel. + /// + /// + /// if this is not mentioned in this entry. + /// + public ChannelFlags? Flags { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadInfo.cs new file mode 100644 index 0000000..e202df3 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadInfo.cs @@ -0,0 +1,83 @@ +using Discord.API.AuditLogs; +using System.Collections.Generic; + +namespace Discord.WebSocket; + +/// +/// Represents information for a thread. +/// +public class SocketThreadInfo +{ + /// + /// Gets the name of the thread. + /// + public string Name { get; } + + /// + /// Gets the value that indicates whether the thread is archived. + /// + /// + /// if the property was not updated. + /// + public bool? IsArchived { get; } + + /// + /// Gets the auto archive duration of thread. + /// + /// + /// if the property was not updated. + /// + public ThreadArchiveDuration? AutoArchiveDuration { get; } + + /// + /// Gets the value that indicates whether the thread is locked. + /// + /// + /// if the property was not updated. + /// + public bool? IsLocked { get; } + + /// + /// Gets the slow-mode delay of the thread. + /// + /// + /// if the property was not updated. + /// + public int? SlowModeInterval { get; } + + /// + /// Gets the applied tags of this thread. + /// + /// + /// if the property was not updated. + /// + public IReadOnlyCollection AppliedTags { get; } + + /// + /// Gets the flags of the thread channel. + /// + /// + /// if the property was not updated. + /// + public ChannelFlags? Flags { get; } + + /// + /// Gets the type of the thread. + /// + /// + /// if the property was not updated. + /// + public ThreadType Type { get; } + + internal SocketThreadInfo(ThreadInfoAuditLogModel model) + { + Name = model.Name; + IsArchived = model.IsArchived; + AutoArchiveDuration = model.ArchiveDuration; + IsLocked = model.IsLocked; + SlowModeInterval = model.SlowModeInterval; + AppliedTags = model.AppliedTags; + Flags = model.ChannelFlags; + Type = model.Type; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadUpdateAuditLogData.cs new file mode 100644 index 0000000..5b5583b --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketThreadUpdateAuditLogData.cs @@ -0,0 +1,51 @@ +using Discord.API.AuditLogs; +using Discord.Rest; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a thread update. +/// +public class SocketThreadUpdateAuditLogData : ISocketAuditLogData +{ + private SocketThreadUpdateAuditLogData(ThreadType type, ThreadInfo before, ThreadInfo after) + { + ThreadType = type; + Before = before; + After = after; + } + + internal static SocketThreadUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketThreadUpdateAuditLogData(before.Type, new(before), new (after)); + } + + /// + /// Gets the type of the thread. + /// + /// + /// The type of thread. + /// + public ThreadType ThreadType { get; } + + /// + /// Gets the thread information before the changes. + /// + /// + /// A thread information object representing the thread before the changes were made. + /// + public ThreadInfo Before { get; } + + /// + /// Gets the thread information after the changes. + /// + /// + /// A thread information object representing the thread after the changes were made. + /// + public ThreadInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketUnbanAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketUnbanAuditLogData.cs new file mode 100644 index 0000000..c86845d --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketUnbanAuditLogData.cs @@ -0,0 +1,39 @@ +using Discord.Rest; +using System.Linq; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to an unban. +/// +public class SocketUnbanAuditLogData : ISocketAuditLogData +{ + private SocketUnbanAuditLogData(Cacheable user) + { + Target = user; + } + + internal static SocketUnbanAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var cachedUser = discord.GetUser(entry.TargetId!.Value); + var cacheableUser = new Cacheable( + cachedUser, + entry.TargetId.Value, + cachedUser is not null, + async () => + { + var user = await discord.ApiClient.GetUserAsync(entry.TargetId.Value); + return user is not null ? RestUser.Create(discord, user) : null; + }); + return new SocketUnbanAuditLogData(cacheableUser); + } + + /// + /// Gets the user that was unbanned. + /// + /// + /// A cacheable user object representing the user that was unbanned. + /// + public Cacheable Target { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketVoiceChannelStatusDeleteAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketVoiceChannelStatusDeleteAuditLogData.cs new file mode 100644 index 0000000..94a6984 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketVoiceChannelStatusDeleteAuditLogData.cs @@ -0,0 +1,25 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a voice channel status delete. +/// +public class SocketVoiceChannelStatusDeleteAuditLogData : ISocketAuditLogData +{ + private SocketVoiceChannelStatusDeleteAuditLogData(ulong channelId) + { + ChannelId = channelId; + } + + internal static SocketVoiceChannelStatusDeleteAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new (entry.TargetId!.Value); + } + + /// + /// Get the id of the channel status was removed in. + /// + public ulong ChannelId { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketVoiceChannelStatusUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketVoiceChannelStatusUpdateAuditLogData.cs new file mode 100644 index 0000000..dbc84d3 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketVoiceChannelStatusUpdateAuditLogData.cs @@ -0,0 +1,31 @@ +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLog; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a voice channel status update. +/// +public class SocketVoiceChannelStatusUpdatedAuditLogData : ISocketAuditLogData +{ + private SocketVoiceChannelStatusUpdatedAuditLogData(string status, ulong channelId) + { + Status = status; + ChannelId = channelId; + } + + internal static SocketVoiceChannelStatusUpdatedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + return new (entry.Options.Status, entry.TargetId!.Value); + } + + /// + /// Gets the status that was set in the voice channel. + /// + public string Status { get; } + + /// + /// Get the id of the channel status was updated in. + /// + public ulong ChannelId { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookCreateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookCreateAuditLogData.cs new file mode 100644 index 0000000..3e4c5f8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookCreateAuditLogData.cs @@ -0,0 +1,71 @@ +using Discord.Rest; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLogs.WebhookInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a webhook creation. +/// +public class SocketWebhookCreateAuditLogData : ISocketAuditLogData +{ + private SocketWebhookCreateAuditLogData(ulong webhookId, Model model) + { + WebhookId = webhookId; + Name = model.Name; + Type = model.Type!.Value; + ChannelId = model.ChannelId!.Value; + Avatar = model.AvatarHash; + } + + internal static SocketWebhookCreateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (_, data) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketWebhookCreateAuditLogData(entry.TargetId!.Value, data); + } + + /// + /// Gets the webhook id. + /// + /// + /// The webhook identifier. + /// + public ulong WebhookId { get; } + + /// + /// Gets the type of webhook that was created. + /// + /// + /// The type of webhook that was created. + /// + public WebhookType Type { get; } + + /// + /// Gets the name of the webhook. + /// + /// + /// A string containing the name of the webhook. + /// + public string Name { get; } + + /// + /// Gets the ID of the channel that the webhook could send to. + /// + /// + /// A representing the snowflake identifier of the channel that the webhook could send + /// to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the hash value of the webhook's avatar. + /// + /// + /// A string containing the hash of the webhook's avatar. + /// + public string Avatar { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookDeletedAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookDeletedAuditLogData.cs new file mode 100644 index 0000000..1def1eb --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookDeletedAuditLogData.cs @@ -0,0 +1,71 @@ +using Discord.Rest; + +using Model = Discord.API.AuditLogs.WebhookInfoAuditLogModel; +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a webhook deletion. +/// +public class SocketWebhookDeletedAuditLogData : ISocketAuditLogData +{ + private SocketWebhookDeletedAuditLogData(ulong id, Model model) + { + WebhookId = id; + ChannelId = model.ChannelId!.Value; + Name = model.Name; + Type = model.Type!.Value; + Avatar = model.AvatarHash; + } + + internal static SocketWebhookDeletedAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (data, _) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketWebhookDeletedAuditLogData(entry.TargetId!.Value,data); + } + + /// + /// Gets the ID of the webhook that was deleted. + /// + /// + /// A representing the snowflake identifier of the webhook that was deleted. + /// + public ulong WebhookId { get; } + + /// + /// Gets the ID of the channel that the webhook could send to. + /// + /// + /// A representing the snowflake identifier of the channel that the webhook could send + /// to. + /// + public ulong ChannelId { get; } + + /// + /// Gets the type of the webhook that was deleted. + /// + /// + /// The type of webhook that was deleted. + /// + public WebhookType Type { get; } + + /// + /// Gets the name of the webhook that was deleted. + /// + /// + /// A string containing the name of the webhook that was deleted. + /// + public string Name { get; } + + /// + /// Gets the hash value of the webhook's avatar. + /// + /// + /// A string containing the hash of the webhook's avatar. + /// + public string Avatar { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookInfo.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookInfo.cs new file mode 100644 index 0000000..1b00148 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookInfo.cs @@ -0,0 +1,39 @@ +using Model = Discord.API.AuditLogs.WebhookInfoAuditLogModel; + +namespace Discord.WebSocket; + +/// +/// Represents information for a webhook. +/// +public struct SocketWebhookInfo +{ + internal SocketWebhookInfo(Model model) + { + Name = model.Name; + ChannelId = model.ChannelId; + Avatar = model.AvatarHash; + } + + /// + /// Gets the name of this webhook. + /// + /// + /// A string containing the name of this webhook. + /// + public string Name { get; } + /// + /// Gets the ID of the channel that this webhook sends to. + /// + /// + /// A representing the snowflake identifier of the channel that this webhook can send + /// to. + /// + public ulong? ChannelId { get; } + /// + /// Gets the hash value of this webhook's avatar. + /// + /// + /// A string containing the hash of this webhook's avatar. + /// + public string Avatar { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookUpdateAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookUpdateAuditLogData.cs new file mode 100644 index 0000000..4bcba25 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/DataTypes/SocketWebhookUpdateAuditLogData.cs @@ -0,0 +1,44 @@ +using Discord.Rest; + +using EntryModel = Discord.API.AuditLogEntry; +using Model = Discord.API.AuditLogs.WebhookInfoAuditLogModel; + + +namespace Discord.WebSocket; + +/// +/// Contains a piece of audit log data related to a webhook update. +/// +public class SocketWebhookUpdateAuditLogData : ISocketAuditLogData +{ + private SocketWebhookUpdateAuditLogData(SocketWebhookInfo before, SocketWebhookInfo after) + { + Before = before; + After = after; + } + + internal static SocketWebhookUpdateAuditLogData Create(DiscordSocketClient discord, EntryModel entry) + { + var changes = entry.Changes; + + var (before, after) = AuditLogHelper.CreateAuditLogEntityInfo(changes, discord); + + return new SocketWebhookUpdateAuditLogData(new(before), new(after)); + } + + /// + /// Gets the webhook information before the changes. + /// + /// + /// A webhook information object representing the webhook before the changes were made. + /// + public SocketWebhookInfo Before { get; } + + /// + /// Gets the webhook information after the changes. + /// + /// + /// A webhook information object representing the webhook after the changes were made. + /// + public SocketWebhookInfo After { get; } +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/ISocketAuditLogData.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/ISocketAuditLogData.cs new file mode 100644 index 0000000..4703fbb --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/ISocketAuditLogData.cs @@ -0,0 +1,9 @@ +namespace Discord.WebSocket; + +/// +/// Represents data applied to a . +/// +public interface ISocketAuditLogData : IAuditLogData +{ + +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/SocketAuditLogEntry.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/SocketAuditLogEntry.cs new file mode 100644 index 0000000..edb5be6 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/SocketAuditLogEntry.cs @@ -0,0 +1,55 @@ +using Discord.Rest; +using System; +using System.Linq; + +using Model = Discord.API.AuditLog; +using EntryModel = Discord.API.Gateway.AuditLogCreatedEvent; + +namespace Discord.WebSocket; + +/// +/// Represents a Socket-based audit log entry. +/// +public class SocketAuditLogEntry : SocketEntity, IAuditLogEntry +{ + private SocketAuditLogEntry(DiscordSocketClient discord, EntryModel model) + : base(discord, model.Id) + { + Action = model.Action; + Data = SocketAuditLogHelper.CreateData(discord, model); + Reason = model.Reason; + + var guild = discord.State.GetGuild(model.GuildId); + User = guild?.GetUser(model.UserId ?? 0); + } + + internal static SocketAuditLogEntry Create(DiscordSocketClient discord, EntryModel model) + { + return new SocketAuditLogEntry(discord, model); + } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public ActionType Action { get; } + + /// + public ISocketAuditLogData Data { get; } + + /// + public SocketUser User { get; private set; } + + /// + public string Reason { get; } + + #region IAuditLogEntry + + /// + IUser IAuditLogEntry.User => User; + + /// + IAuditLogData IAuditLogEntry.Data => Data; + + #endregion +} diff --git a/src/Discord.Net.WebSocket/Entities/AuditLogs/SocketAuditLogHelper.cs b/src/Discord.Net.WebSocket/Entities/AuditLogs/SocketAuditLogHelper.cs new file mode 100644 index 0000000..867f9d0 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/AuditLogs/SocketAuditLogHelper.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; + +using EntryModel = Discord.API.AuditLogEntry; + +namespace Discord.WebSocket; + +internal static class SocketAuditLogHelper +{ + private static readonly Dictionary> CreateMapping + = new () + { + [ActionType.GuildUpdated] = SocketGuildUpdateAuditLogData.Create, + + [ActionType.ChannelCreated] = SocketChannelCreateAuditLogData.Create, + [ActionType.ChannelUpdated] = SocketChannelUpdateAuditLogData.Create, + [ActionType.ChannelDeleted] = SocketChannelDeleteAuditLogData.Create, + + [ActionType.OverwriteCreated] = SocketOverwriteCreateAuditLogData.Create, + [ActionType.OverwriteUpdated] = SocketOverwriteUpdateAuditLogData.Create, + [ActionType.OverwriteDeleted] = SocketOverwriteDeleteAuditLogData.Create, + + [ActionType.Kick] = SocketKickAuditLogData.Create, + [ActionType.Prune] = SocketPruneAuditLogData.Create, + [ActionType.Ban] = SocketBanAuditLogData.Create, + [ActionType.Unban] = SocketUnbanAuditLogData.Create, + [ActionType.MemberUpdated] = SocketMemberUpdateAuditLogData.Create, + [ActionType.MemberRoleUpdated] = SocketMemberRoleAuditLogData.Create, + [ActionType.MemberMoved] = SocketMemberMoveAuditLogData.Create, + [ActionType.MemberDisconnected] = SocketMemberDisconnectAuditLogData.Create, + [ActionType.BotAdded] = SocketBotAddAuditLogData.Create, + + [ActionType.RoleCreated] = SocketRoleCreateAuditLogData.Create, + [ActionType.RoleUpdated] = SocketRoleUpdateAuditLogData.Create, + [ActionType.RoleDeleted] = SocketRoleDeleteAuditLogData.Create, + + [ActionType.InviteCreated] = SocketInviteCreateAuditLogData.Create, + [ActionType.InviteUpdated] = SocketInviteUpdateAuditLogData.Create, + [ActionType.InviteDeleted] = SocketInviteDeleteAuditLogData.Create, + + [ActionType.WebhookCreated] = SocketWebhookCreateAuditLogData.Create, + [ActionType.WebhookUpdated] = SocketWebhookUpdateAuditLogData.Create, + [ActionType.WebhookDeleted] = SocketWebhookDeletedAuditLogData.Create, + + [ActionType.EmojiCreated] = SocketEmoteCreateAuditLogData.Create, + [ActionType.EmojiUpdated] = SocketEmoteUpdateAuditLogData.Create, + [ActionType.EmojiDeleted] = SocketEmoteDeleteAuditLogData.Create, + + [ActionType.MessageDeleted] = SocketMessageDeleteAuditLogData.Create, + [ActionType.MessageBulkDeleted] = SocketMessageBulkDeleteAuditLogData.Create, + [ActionType.MessagePinned] = SocketMessagePinAuditLogData.Create, + [ActionType.MessageUnpinned] = SocketMessageUnpinAuditLogData.Create, + + [ActionType.EventCreate] = SocketScheduledEventCreateAuditLogData.Create, + [ActionType.EventUpdate] = SocketScheduledEventUpdateAuditLogData.Create, + [ActionType.EventDelete] = SocketScheduledEventDeleteAuditLogData.Create, + + [ActionType.ThreadCreate] = SocketThreadCreateAuditLogData.Create, + [ActionType.ThreadUpdate] = SocketThreadUpdateAuditLogData.Create, + [ActionType.ThreadDelete] = SocketThreadDeleteAuditLogData.Create, + + [ActionType.ApplicationCommandPermissionUpdate] = SocketCommandPermissionUpdateAuditLogData.Create, + + [ActionType.IntegrationCreated] = SocketIntegrationCreatedAuditLogData.Create, + [ActionType.IntegrationUpdated] = SocketIntegrationUpdatedAuditLogData.Create, + [ActionType.IntegrationDeleted] = SocketIntegrationDeletedAuditLogData.Create, + + [ActionType.StageInstanceCreated] = SocketStageInstanceCreateAuditLogData.Create, + [ActionType.StageInstanceUpdated] = SocketStageInstanceUpdatedAuditLogData.Create, + [ActionType.StageInstanceDeleted] = SocketStageInstanceDeleteAuditLogData.Create, + + [ActionType.StickerCreated] = SocketStickerCreatedAuditLogData.Create, + [ActionType.StickerUpdated] = SocketStickerUpdatedAuditLogData.Create, + [ActionType.StickerDeleted] = SocketStickerDeletedAuditLogData.Create, + + [ActionType.AutoModerationRuleCreate] = SocketAutoModRuleCreatedAuditLogData.Create, + [ActionType.AutoModerationRuleUpdate] = AutoModRuleUpdatedAuditLogData.Create, + [ActionType.AutoModerationRuleDelete] = SocketAutoModRuleDeletedAuditLogData.Create, + + [ActionType.AutoModerationBlockMessage] = SocketAutoModBlockedMessageAuditLogData.Create, + [ActionType.AutoModerationFlagToChannel] = SocketAutoModFlaggedMessageAuditLogData.Create, + [ActionType.AutoModerationUserCommunicationDisabled] = SocketAutoModTimeoutUserAuditLogData.Create, + + [ActionType.OnboardingQuestionCreated] = SocketOnboardingPromptCreatedAuditLogData.Create, + [ActionType.OnboardingQuestionUpdated] = SocketOnboardingPromptUpdatedAuditLogData.Create, + [ActionType.OnboardingUpdated] = SocketOnboardingUpdatedAuditLogData.Create, + + [ActionType.VoiceChannelStatusUpdated] = SocketVoiceChannelStatusUpdatedAuditLogData.Create, + [ActionType.VoiceChannelStatusDeleted] = SocketVoiceChannelStatusDeleteAuditLogData.Create, + + }; + + public static ISocketAuditLogData CreateData(DiscordSocketClient discord, EntryModel entry) + { + if (CreateMapping.TryGetValue(entry.Action, out var func)) + return func(discord, entry); + + return null; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/ISocketAudioChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/ISocketAudioChannel.cs new file mode 100644 index 0000000..3cb978a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/ISocketAudioChannel.cs @@ -0,0 +1,9 @@ +namespace Discord.WebSocket +{ + /// + /// Represents a generic WebSocket-based audio channel. + /// + public interface ISocketAudioChannel : IAudioChannel + { + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/ISocketMessageChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/ISocketMessageChannel.cs new file mode 100644 index 0000000..5fbd98a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/ISocketMessageChannel.cs @@ -0,0 +1,153 @@ +using Discord.Rest; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Discord.WebSocket +{ + /// + /// Represents a generic WebSocket-based channel that can send and receive messages. + /// + public interface ISocketMessageChannel : IMessageChannel + { + /// + /// Gets all messages in this channel's cache. + /// + /// + /// A read-only collection of WebSocket-based messages. + /// + IReadOnlyCollection CachedMessages { get; } + + /// + new Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None); + + /// + new Task SendFilesAsync(IEnumerable attachments, string text = null, + bool isTTS = false, Embed embed = null, RequestOptions options = null, + AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, + MessageFlags flags = MessageFlags.None); + + /// + /// Gets a cached message from this channel. + /// + /// + /// + /// This method requires the use of cache, which is not enabled by default; if caching is not enabled, + /// this method will always return . Please refer to + /// for more details. + /// + /// + /// This method retrieves the message from the local WebSocket cache and does not send any additional + /// request to Discord. This message may be a message that has been deleted. + /// + /// + /// The snowflake identifier of the message. + /// + /// A WebSocket-based message object; if it does not exist in the cache or if caching is not + /// enabled. + /// + SocketMessage GetCachedMessage(ulong id); + /// + /// Gets the last N cached messages from this message channel. + /// + /// + /// + /// This method requires the use of cache, which is not enabled by default; if caching is not enabled, + /// this method will always return an empty collection. Please refer to + /// for more details. + /// + /// + /// This method retrieves the message(s) from the local WebSocket cache and does not send any additional + /// request to Discord. This read-only collection may include messages that have been deleted. The + /// maximum number of messages that can be retrieved from this method depends on the + /// set. + /// + /// + /// The number of messages to get. + /// + /// A read-only collection of WebSocket-based messages. + /// + IReadOnlyCollection GetCachedMessages(int limit = DiscordConfig.MaxMessagesPerBatch); + + /// + /// Gets the last N cached messages starting from a certain message in this message channel. + /// + /// + /// + /// This method requires the use of cache, which is not enabled by default; if caching is not enabled, + /// this method will always return an empty collection. Please refer to + /// for more details. + /// + /// + /// This method retrieves the message(s) from the local WebSocket cache and does not send any additional + /// request to Discord. This read-only collection may include messages that have been deleted. The + /// maximum number of messages that can be retrieved from this method depends on the + /// set. + /// + /// + /// The message ID to start the fetching from. + /// The direction of which the message should be gotten from. + /// The number of messages to get. + /// + /// A read-only collection of WebSocket-based messages. + /// + IReadOnlyCollection GetCachedMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch); + /// + /// Gets the last N cached messages starting from a certain message in this message channel. + /// + /// + /// + /// This method requires the use of cache, which is not enabled by default; if caching is not enabled, + /// this method will always return an empty collection. Please refer to + /// for more details. + /// + /// + /// This method retrieves the message(s) from the local WebSocket cache and does not send any additional + /// request to Discord. This read-only collection may include messages that have been deleted. The + /// maximum number of messages that can be retrieved from this method depends on the + /// set. + /// + /// + /// The message to start the fetching from. + /// The direction of which the message should be gotten from. + /// The number of messages to get. + /// + /// A read-only collection of WebSocket-based messages. + /// + IReadOnlyCollection GetCachedMessages(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch); + /// + /// Gets a read-only collection of pinned messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation for retrieving pinned messages in this channel. + /// The task result contains a read-only collection of messages found in the pinned messages. + /// + new Task> GetPinnedMessagesAsync(RequestOptions options = null); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/ISocketPrivateChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/ISocketPrivateChannel.cs new file mode 100644 index 0000000..08da223 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/ISocketPrivateChannel.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Discord.WebSocket +{ + /// + /// Represents a generic WebSocket-based channel that is private to select recipients. + /// + public interface ISocketPrivateChannel : IPrivateChannel + { + new IReadOnlyCollection Recipients { get; } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketCategoryChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketCategoryChannel.cs new file mode 100644 index 0000000..e0de685 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketCategoryChannel.cs @@ -0,0 +1,108 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based category channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketCategoryChannel : SocketGuildChannel, ICategoryChannel + { + #region SocketCategoryChannel + /// + public override IReadOnlyCollection Users + => Guild.Users.Where(x => Permissions.GetValue( + Permissions.ResolveChannel(Guild, x, this, Permissions.ResolveGuild(Guild, x)), + ChannelPermission.ViewChannel)).ToImmutableArray(); + + /// + /// Gets the child channels of this category. + /// + /// + /// A read-only collection of whose + /// matches the snowflake identifier of this category + /// channel. + /// + public IReadOnlyCollection Channels + => Guild.Channels.Where(x => x is INestedChannel nestedChannel && nestedChannel.CategoryId == Id).ToImmutableArray(); + + internal SocketCategoryChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id, guild) + { + } + internal new static SocketCategoryChannel Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketCategoryChannel(guild?.Discord, model.Id, guild); + entity.Update(state, model); + return entity; + } + #endregion + + #region Users + /// + public override SocketGuildUser GetUser(ulong id) + { + var user = Guild.GetUser(id); + if (user != null) + { + var guildPerms = Permissions.ResolveGuild(Guild, user); + var channelPerms = Permissions.ResolveChannel(Guild, user, this, guildPerms); + if (Permissions.GetValue(channelPerms, ChannelPermission.ViewChannel)) + return user; + } + return null; + } + + private string DebuggerDisplay => $"{Name} ({Id}, Category)"; + internal new SocketCategoryChannel Clone() => MemberwiseClone() as SocketCategoryChannel; + #endregion + + #region IGuildChannel + + /// + IAsyncEnumerable> IGuildChannel.GetUsersAsync(CacheMode mode, + RequestOptions options) + { + return mode == CacheMode.AllowDownload + ? ChannelHelper.GetUsersAsync(this, Guild, Discord, null, null, options) + : ImmutableArray.Create>(Users).ToAsyncEnumerable(); + } + /// + async Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + var user = GetUser(id); + if (user is not null || mode == CacheMode.CacheOnly) + return user; + + return await ChannelHelper.GetUserAsync(this, Guild, Discord, id, options).ConfigureAwait(false); + } + #endregion + + #region IChannel + + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + { + return mode == CacheMode.AllowDownload + ? ChannelHelper.GetUsersAsync(this, Guild, Discord, null, null, options) + : ImmutableArray.Create>(Users).ToAsyncEnumerable(); + } + /// + async Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + var user = GetUser(id); + if (user is not null || mode == CacheMode.CacheOnly) + return user; + + return await ChannelHelper.GetUserAsync(this, Guild, Discord, id, options).ConfigureAwait(false); + } + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketChannel.cs new file mode 100644 index 0000000..c30b3d2 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketChannel.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public abstract class SocketChannel : SocketEntity, IChannel + { + #region SocketChannel + /// + /// Gets when the channel is created. + /// + public virtual DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + /// Gets a collection of users from the WebSocket cache. + /// + public IReadOnlyCollection Users => GetUsersInternal(); + + internal SocketChannel(DiscordSocketClient discord, ulong id) + : base(discord, id) + { + } + + /// Unexpected channel type is created. + internal static ISocketPrivateChannel CreatePrivate(DiscordSocketClient discord, ClientState state, Model model) + { + return model.Type switch + { + ChannelType.DM => SocketDMChannel.Create(discord, state, model), + ChannelType.Group => SocketGroupChannel.Create(discord, state, model), + _ => throw new InvalidOperationException($"Unexpected channel type: {model.Type}"), + }; + } + internal abstract void Update(ClientState state, Model model); + #endregion + + #region User + /// + /// Gets a generic user from this channel. + /// + /// The snowflake identifier of the user. + /// + /// A generic WebSocket-based user associated with the snowflake identifier. + /// + public SocketUser GetUser(ulong id) => GetUserInternal(id); + internal abstract SocketUser GetUserInternal(ulong id); + internal abstract IReadOnlyCollection GetUsersInternal(); + + private string DebuggerDisplay => $"Unknown ({Id}, Channel)"; + internal SocketChannel Clone() => MemberwiseClone() as SocketChannel; + #endregion + + #region IChannel + /// + string IChannel.Name => null; + + /// + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(null); //Overridden + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => AsyncEnumerable.Empty>(); //Overridden + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketChannelHelper.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketChannelHelper.cs new file mode 100644 index 0000000..032afb6 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketChannelHelper.cs @@ -0,0 +1,117 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.WebSocket +{ + internal static class SocketChannelHelper + { + public static IAsyncEnumerable> GetMessagesAsync(ISocketMessageChannel channel, DiscordSocketClient discord, MessageCache messages, + ulong? fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) + { + if (dir == Direction.After && fromMessageId == null) + return AsyncEnumerable.Empty>(); + + var cachedMessages = GetCachedMessages(channel, discord, messages, fromMessageId, dir, limit); + var result = ImmutableArray.Create(cachedMessages).ToAsyncEnumerable>(); + + if (dir == Direction.Before) + { + limit -= cachedMessages.Count; + if (mode == CacheMode.CacheOnly || limit <= 0) + return result; + + //Download remaining messages + ulong? minId = cachedMessages.Count > 0 ? cachedMessages.Min(x => x.Id) : fromMessageId; + var downloadedMessages = ChannelHelper.GetMessagesAsync(channel, discord, minId, dir, limit, options); + if (cachedMessages.Count != 0) + return result.Concat(downloadedMessages); + else + return downloadedMessages; + } + else if (dir == Direction.After) + { + if (mode == CacheMode.CacheOnly) + return result; + + bool ignoreCache = false; + + // We can find two cases: + // 1. fromMessageId is not null and corresponds to a message that is not in the cache, + // so we have to make a request and ignore the cache + if (fromMessageId.HasValue && messages?.Get(fromMessageId.Value) == null) + { + ignoreCache = true; + } + // 2. fromMessageId is null or already in the cache, so we start from the cache + else if (cachedMessages.Count > 0) + { + fromMessageId = cachedMessages.Max(x => x.Id); + limit -= cachedMessages.Count; + + if (limit <= 0) + return result; + } + + //Download remaining messages + var downloadedMessages = ChannelHelper.GetMessagesAsync(channel, discord, fromMessageId, dir, limit, options); + if (!ignoreCache && cachedMessages.Count != 0) + return result.Concat(downloadedMessages); + else + return downloadedMessages; + } + else //Direction.Around + { + if (mode == CacheMode.CacheOnly || limit <= cachedMessages.Count) + return result; + + //Cache isn't useful here since Discord will send them anyways + return ChannelHelper.GetMessagesAsync(channel, discord, fromMessageId, dir, limit, options); + } + } + public static IReadOnlyCollection GetCachedMessages(ISocketMessageChannel channel, DiscordSocketClient discord, MessageCache messages, + ulong? fromMessageId, Direction dir, int limit) + { + if (messages != null) //Cache enabled + return messages.GetMany(fromMessageId, dir, limit); + else + return ImmutableArray.Create(); + } + /// Unexpected type. + public static void AddMessage(ISocketMessageChannel channel, DiscordSocketClient discord, + SocketMessage msg) + { + switch (channel) + { + case SocketDMChannel dmChannel: + dmChannel.AddMessage(msg); + break; + case SocketGroupChannel groupChannel: + groupChannel.AddMessage(msg); + break; + case SocketThreadChannel threadChannel: + threadChannel.AddMessage(msg); + break; + case SocketTextChannel textChannel: + textChannel.AddMessage(msg); + break; + default: + throw new NotSupportedException($"Unexpected {nameof(ISocketMessageChannel)} type."); + } + } + /// Unexpected type. + public static SocketMessage RemoveMessage(ISocketMessageChannel channel, DiscordSocketClient discord, + ulong id) + { + return channel switch + { + SocketDMChannel dmChannel => dmChannel.RemoveMessage(id), + SocketGroupChannel groupChannel => groupChannel.RemoveMessage(id), + SocketTextChannel textChannel => textChannel.RemoveMessage(id), + _ => throw new NotSupportedException($"Unexpected {nameof(ISocketMessageChannel)} type."), + }; + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs new file mode 100644 index 0000000..a5dc205 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketDMChannel.cs @@ -0,0 +1,328 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based direct-message channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketDMChannel : SocketChannel, IDMChannel, ISocketPrivateChannel, ISocketMessageChannel + { + #region SocketDMChannel + /// + /// Gets the recipient of the channel. + /// + public SocketUser Recipient { get; } + + /// + public IReadOnlyCollection CachedMessages => ImmutableArray.Create(); + + /// + /// Gets a collection that is the current logged-in user and the recipient. + /// + public new IReadOnlyCollection Users => ImmutableArray.Create(Discord.CurrentUser, Recipient); + + internal SocketDMChannel(DiscordSocketClient discord, ulong id, SocketUser recipient) + : base(discord, id) + { + Recipient = recipient; + } + internal static SocketDMChannel Create(DiscordSocketClient discord, ClientState state, Model model) + { + var entity = new SocketDMChannel(discord, model.Id, discord.GetOrCreateTemporaryUser(state, model.Recipients.Value[0])); + entity.Update(state, model); + return entity; + } + internal override void Update(ClientState state, Model model) + { + Recipient.Update(state, model.Recipients.Value[0]); + } + internal static SocketDMChannel Create(DiscordSocketClient discord, ClientState state, ulong channelId, API.User recipient) + { + var entity = new SocketDMChannel(discord, channelId, discord.GetOrCreateTemporaryUser(state, recipient)); + entity.Update(state, recipient); + return entity; + } + internal void Update(ClientState state, API.User recipient) + { + Recipient.Update(state, recipient); + } + + /// + public Task CloseAsync(RequestOptions options = null) + => ChannelHelper.DeleteAsync(this, Discord, options); + #endregion + + #region Messages + /// + public SocketMessage GetCachedMessage(ulong id) + => null; + /// + /// Gets the message associated with the given . + /// + /// TThe ID of the message. + /// The options to be used when sending the request. + /// + /// The message gotten from either the cache or the download, or if none is found. + /// + public async Task GetMessageAsync(ulong id, RequestOptions options = null) + { + return await ChannelHelper.GetMessageAsync(this, Discord, id, options).ConfigureAwait(false); + } + + /// + /// Gets the last N messages from this message channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, null, Direction.Before, limit, options); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The ID of the starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessageId, dir, limit, options); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => ChannelHelper.GetMessagesAsync(this, Discord, fromMessage.Id, dir, limit, options); + /// + public IReadOnlyCollection GetCachedMessages(int limit = DiscordConfig.MaxMessagesPerBatch) + => ImmutableArray.Create(); + /// + public IReadOnlyCollection GetCachedMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + => ImmutableArray.Create(); + /// + public IReadOnlyCollection GetCachedMessages(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + => ImmutableArray.Create(); + /// + public Task> GetPinnedMessagesAsync(RequestOptions options = null) + => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// The only valid are and . + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, attachment, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFilesAsync(this, Discord, attachments, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, embeds, flags); + + /// + public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); + /// + public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); + + /// + public async Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + => await ChannelHelper.ModifyMessageAsync(this, messageId, func, Discord, options).ConfigureAwait(false); + + /// + public Task TriggerTypingAsync(RequestOptions options = null) + => ChannelHelper.TriggerTypingAsync(this, Discord, options); + /// + public IDisposable EnterTypingState(RequestOptions options = null) + => ChannelHelper.EnterTypingState(this, Discord, options); + + internal void AddMessage(SocketMessage msg) + { + } + internal SocketMessage RemoveMessage(ulong id) + { + return null; + } + #endregion + + #region Users + /// + /// Gets a user in this channel from the provided . + /// + /// The snowflake identifier of the user. + /// + /// A object that is a recipient of this channel; otherwise . + /// + public new SocketUser GetUser(ulong id) + { + if (id == Recipient.Id) + return Recipient; + else if (id == Discord.CurrentUser.Id) + return Discord.CurrentUser; + else + return null; + } + + /// + /// Returns the recipient user. + /// + public override string ToString() => $"@{Recipient}"; + private string DebuggerDisplay => $"@{Recipient} ({Id}, DM)"; + internal new SocketDMChannel Clone() => MemberwiseClone() as SocketDMChannel; + #endregion + + #region SocketChannel + /// + internal override IReadOnlyCollection GetUsersInternal() => Users; + /// + internal override SocketUser GetUserInternal(ulong id) => GetUser(id); + #endregion + + #region IDMChannel + /// + IUser IDMChannel.Recipient => Recipient; + #endregion + + #region ISocketPrivateChannel + /// + IReadOnlyCollection ISocketPrivateChannel.Recipients => ImmutableArray.Create(Recipient); + #endregion + + #region IPrivateChannel + /// + IReadOnlyCollection IPrivateChannel.Recipients => ImmutableArray.Create(Recipient); + #endregion + + #region IMessageChannel + /// + Task IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessageAsync(id, options); + else + return Task.FromResult((IMessage)GetCachedMessage(id)); + } + + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) + => mode == CacheMode.CacheOnly ? null : GetMessagesAsync(limit, options); + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(ulong fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) + => mode == CacheMode.CacheOnly ? null : GetMessagesAsync(fromMessageId, dir, limit, options); + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(IMessage fromMessage, Direction dir, int limit, CacheMode mode, RequestOptions options) + => mode == CacheMode.CacheOnly ? null : GetMessagesAsync(fromMessage.Id, dir, limit, options); + /// + async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) + => await GetPinnedMessagesAsync(options).ConfigureAwait(false); + /// + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, + RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, + Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(FileAttachment attachment, string text, bool isTTS, + Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFilesAsync(IEnumerable attachments, string text, + bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, + AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, + ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + #endregion + + #region IChannel + /// + string IChannel.Name => $"@{Recipient}"; + + /// + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetUser(id)); + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => ImmutableArray.Create>(Users).ToAsyncEnumerable(); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketForumChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketForumChannel.cs new file mode 100644 index 0000000..ef696bd --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketForumChannel.cs @@ -0,0 +1,235 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a forum channel in a guild. + /// + public class SocketForumChannel : SocketGuildChannel, IForumChannel + { + /// + public bool IsNsfw { get; private set; } + + /// + public string Topic { get; private set; } + + /// + public ThreadArchiveDuration DefaultAutoArchiveDuration { get; private set; } + + /// + public IReadOnlyCollection Tags { get; private set; } + + /// + public int ThreadCreationInterval { get; private set; } + + /// + public int DefaultSlowModeInterval { get; private set; } + + /// + public string Mention => MentionUtils.MentionChannel(Id); + + /// + public ulong? CategoryId { get; private set; } + + /// + public IEmote DefaultReactionEmoji { get; private set; } + + /// + public ForumSortOrder? DefaultSortOrder { get; private set; } + + /// + public ForumLayout DefaultLayout { get; private set; } + + /// + /// Gets the parent (category) of this channel in the guild's channel list. + /// + /// + /// An representing the parent of this channel; if none is set. + /// + public ICategoryChannel Category + => CategoryId.HasValue ? Guild.GetChannel(CategoryId.Value) as ICategoryChannel : null; + + internal SocketForumChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) : base(discord, id, guild) { } + + internal new static SocketForumChannel Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketForumChannel(guild?.Discord, model.Id, guild); + entity.Update(state, model); + return entity; + } + + internal override void Update(ClientState state, Model model) + { + base.Update(state, model); + IsNsfw = model.Nsfw.GetValueOrDefault(false); + Topic = model.Topic.GetValueOrDefault(); + DefaultAutoArchiveDuration = model.AutoArchiveDuration.GetValueOrDefault(ThreadArchiveDuration.OneDay); + + if (model.ThreadRateLimitPerUser.IsSpecified) + DefaultSlowModeInterval = model.ThreadRateLimitPerUser.Value; + + if (model.SlowMode.IsSpecified) + ThreadCreationInterval = model.SlowMode.Value; + + DefaultSortOrder = model.DefaultSortOrder.GetValueOrDefault(); + + Tags = model.ForumTags.GetValueOrDefault(Array.Empty()).Select( + x => new ForumTag(x.Id, x.Name, x.EmojiId.GetValueOrDefault(null), x.EmojiName.GetValueOrDefault(), x.Moderated) + ).ToImmutableArray(); + + if (model.DefaultReactionEmoji.IsSpecified && model.DefaultReactionEmoji.Value is not null) + { + if (model.DefaultReactionEmoji.Value.EmojiId.HasValue && model.DefaultReactionEmoji.Value.EmojiId.Value != 0) + DefaultReactionEmoji = new Emote(model.DefaultReactionEmoji.Value.EmojiId.GetValueOrDefault(), null, false); + else if (model.DefaultReactionEmoji.Value.EmojiName.IsSpecified) + DefaultReactionEmoji = new Emoji(model.DefaultReactionEmoji.Value.EmojiName.Value); + else + DefaultReactionEmoji = null; + } + + CategoryId = model.CategoryId.GetValueOrDefault(); + + DefaultLayout = model.DefaultForumLayout.GetValueOrDefault(); + } + + /// + public virtual Task ModifyAsync(Action func, RequestOptions options = null) + => ForumHelper.ModifyAsync(this, Discord, func, options); + + /// + public Task CreatePostAsync(string title, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + => ThreadHelper.CreatePostAsync(this, Discord, title, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()); + + /// + public async Task CreatePostWithFileAsync(string title, string filePath, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, + ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + { + using var file = new FileAttachment(filePath, isSpoiler: isSpoiler); + return await ThreadHelper.CreatePostAsync(this, Discord, title, new FileAttachment[] { file }, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()).ConfigureAwait(false); + } + + /// + public async Task CreatePostWithFileAsync(string title, Stream stream, string filename, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, bool isSpoiler = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, + ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + { + using var file = new FileAttachment(stream, filename, isSpoiler: isSpoiler); + return await ThreadHelper.CreatePostAsync(this, Discord, title, new FileAttachment[] { file }, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()).ConfigureAwait(false); + } + + /// + public Task CreatePostWithFileAsync(string title, FileAttachment attachment, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + => ThreadHelper.CreatePostAsync(this, Discord, title, new FileAttachment[] { attachment }, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()); + + /// + public Task CreatePostWithFilesAsync(string title, IEnumerable attachments, ThreadArchiveDuration archiveDuration = ThreadArchiveDuration.OneDay, + int? slowmode = null, string text = null, Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None, ForumTag[] tags = null) + => ThreadHelper.CreatePostAsync(this, Discord, title, attachments, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags?.Select(tag => tag.Id).ToArray()); + + /// + public Task> GetActiveThreadsAsync(RequestOptions options = null) + => ThreadHelper.GetActiveThreadsAsync(Guild, Id, Discord, options); + + /// + public Task> GetJoinedPrivateArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null) + => ThreadHelper.GetJoinedPrivateArchivedThreadsAsync(this, Discord, limit, before, options); + + /// + public Task> GetPrivateArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null) + => ThreadHelper.GetPrivateArchivedThreadsAsync(this, Discord, limit, before, options); + + /// + public Task> GetPublicArchivedThreadsAsync(int? limit = null, DateTimeOffset? before = null, RequestOptions options = null) + => ThreadHelper.GetPublicArchivedThreadsAsync(this, Discord, limit, before, options); + + #region Webhooks + + /// + public Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + => ChannelHelper.CreateWebhookAsync(this, Discord, name, avatar, options); + + /// + public Task GetWebhookAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetWebhookAsync(this, Discord, id, options); + + /// + public Task> GetWebhooksAsync(RequestOptions options = null) + => ChannelHelper.GetWebhooksAsync(this, Discord, options); + #endregion + + #region IIntegrationChannel + + /// + async Task IIntegrationChannel.CreateWebhookAsync(string name, Stream avatar, RequestOptions options) + => await CreateWebhookAsync(name, avatar, options).ConfigureAwait(false); + /// + async Task IIntegrationChannel.GetWebhookAsync(ulong id, RequestOptions options) + => await GetWebhookAsync(id, options).ConfigureAwait(false); + /// + async Task> IIntegrationChannel.GetWebhooksAsync(RequestOptions options) + => await GetWebhooksAsync(options).ConfigureAwait(false); + /// + #endregion + + #region IForumChannel + async Task> IForumChannel.GetActiveThreadsAsync(RequestOptions options) + => await GetActiveThreadsAsync(options).ConfigureAwait(false); + async Task> IForumChannel.GetPublicArchivedThreadsAsync(int? limit, DateTimeOffset? before, RequestOptions options) + => await GetPublicArchivedThreadsAsync(limit, before, options).ConfigureAwait(false); + async Task> IForumChannel.GetPrivateArchivedThreadsAsync(int? limit, DateTimeOffset? before, RequestOptions options) + => await GetPrivateArchivedThreadsAsync(limit, before, options).ConfigureAwait(false); + async Task> IForumChannel.GetJoinedPrivateArchivedThreadsAsync(int? limit, DateTimeOffset? before, RequestOptions options) + => await GetJoinedPrivateArchivedThreadsAsync(limit, before, options).ConfigureAwait(false); + + async Task IForumChannel.CreatePostAsync(string title, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostAsync(title, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFileAsync(string title, string filePath, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFileAsync(title, filePath, archiveDuration, slowmode, text, embed, options, isSpoiler, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFileAsync(string title, Stream stream, string filename, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFileAsync(title, stream, filename, archiveDuration, slowmode, text, embed, options, isSpoiler, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFileAsync(string title, FileAttachment attachment, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFileAsync(title, attachment, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags).ConfigureAwait(false); + async Task IForumChannel.CreatePostWithFilesAsync(string title, IEnumerable attachments, ThreadArchiveDuration archiveDuration, int? slowmode, string text, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags, ForumTag[] tags) + => await CreatePostWithFilesAsync(title, attachments, archiveDuration, slowmode, text, embed, options, allowedMentions, components, stickers, embeds, flags, tags); + + #endregion + + #region INestedChannel + /// + public virtual async Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, options).ConfigureAwait(false); + public virtual async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options); + /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); + public virtual Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotImplementedException(); + /// + public virtual async Task> GetInvitesAsync(RequestOptions options = null) + => await ChannelHelper.GetInvitesAsync(this, Discord, options).ConfigureAwait(false); + + /// + Task INestedChannel.GetCategoryAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(Category); + + /// + public virtual Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs new file mode 100644 index 0000000..bc160e1 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketGroupChannel.cs @@ -0,0 +1,404 @@ +using Discord.Audio; +using Discord.Rest; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; +using UserModel = Discord.API.User; +using VoiceStateModel = Discord.API.VoiceState; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based private group channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketGroupChannel : SocketChannel, IGroupChannel, ISocketPrivateChannel, ISocketMessageChannel, ISocketAudioChannel + { + #region SocketGroupChannel + private readonly MessageCache _messages; + private readonly ConcurrentDictionary _voiceStates; + + private string _iconId; + private ConcurrentDictionary _users; + + /// + public string Name { get; private set; } + + /// + public string RTCRegion { get; private set; } + + /// + public IReadOnlyCollection CachedMessages => _messages?.Messages ?? ImmutableArray.Create(); + + /// + /// Returns a collection representing all of the users in the group. + /// + public new IReadOnlyCollection Users => _users.ToReadOnlyCollection(); + + /// + /// Returns a collection representing all users in the group, not including the client. + /// + public IReadOnlyCollection Recipients + => _users.Select(x => x.Value).Where(x => x.Id != Discord.CurrentUser.Id).ToReadOnlyCollection(() => _users.Count - 1); + + internal SocketGroupChannel(DiscordSocketClient discord, ulong id) + : base(discord, id) + { + if (Discord.MessageCacheSize > 0) + _messages = new MessageCache(Discord); + _voiceStates = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, 5); + _users = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, 5); + } + internal static SocketGroupChannel Create(DiscordSocketClient discord, ClientState state, Model model) + { + var entity = new SocketGroupChannel(discord, model.Id); + entity.Update(state, model); + return entity; + } + internal override void Update(ClientState state, Model model) + { + if (model.Name.IsSpecified) + Name = model.Name.Value; + if (model.Icon.IsSpecified) + _iconId = model.Icon.Value; + + if (model.Recipients.IsSpecified) + UpdateUsers(state, model.Recipients.Value); + + RTCRegion = model.RTCRegion.GetValueOrDefault(null); + } + private void UpdateUsers(ClientState state, UserModel[] models) + { + var users = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(models.Length * 1.05)); + for (int i = 0; i < models.Length; i++) + users[models[i].Id] = SocketGroupUser.Create(this, state, models[i]); + _users = users; + } + + /// + public Task LeaveAsync(RequestOptions options = null) + => ChannelHelper.DeleteAsync(this, Discord, options); + + /// Voice is not yet supported for group channels. + public Task ConnectAsync() + { + throw new NotSupportedException("Voice is not yet supported for group channels."); + } + #endregion + + #region Messages + /// + public SocketMessage GetCachedMessage(ulong id) + => _messages?.Get(id); + /// + /// Gets a message from this message channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The snowflake identifier of the message. + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the message. The task result contains + /// the retrieved message; if no message is found with the specified identifier. + /// + public async Task GetMessageAsync(ulong id, RequestOptions options = null) + { + IMessage msg = _messages?.Get(id); + if (msg == null) + msg = await ChannelHelper.GetMessageAsync(this, Discord, id, options).ConfigureAwait(false); + return msg; + } + + /// + /// Gets the last N messages from this message channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, null, Direction.Before, limit, CacheMode.AllowDownload, options); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The ID of the starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessageId, dir, limit, CacheMode.AllowDownload, options); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessage.Id, dir, limit, CacheMode.AllowDownload, options); + /// + public IReadOnlyCollection GetCachedMessages(int limit = DiscordConfig.MaxMessagesPerBatch) + => SocketChannelHelper.GetCachedMessages(this, Discord, _messages, null, Direction.Before, limit); + /// + public IReadOnlyCollection GetCachedMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + => SocketChannelHelper.GetCachedMessages(this, Discord, _messages, fromMessageId, dir, limit); + /// + public IReadOnlyCollection GetCachedMessages(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + => SocketChannelHelper.GetCachedMessages(this, Discord, _messages, fromMessage.Id, dir, limit); + /// + public Task> GetPinnedMessagesAsync(RequestOptions options = null) + => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// The only valid are and . + public Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, attachment, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are and . + public Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFilesAsync(this, Discord, attachments, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, embeds, flags); + + /// + public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); + /// + public Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); + + /// + public async Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + => await ChannelHelper.ModifyMessageAsync(this, messageId, func, Discord, options).ConfigureAwait(false); + + /// + public Task TriggerTypingAsync(RequestOptions options = null) + => ChannelHelper.TriggerTypingAsync(this, Discord, options); + /// + public IDisposable EnterTypingState(RequestOptions options = null) + => ChannelHelper.EnterTypingState(this, Discord, options); + + internal void AddMessage(SocketMessage msg) + => _messages?.Add(msg); + internal SocketMessage RemoveMessage(ulong id) + => _messages?.Remove(id); + #endregion + + #region Users + /// + /// Gets a user from this group. + /// + /// The snowflake identifier of the user. + /// + /// A WebSocket-based group user associated with the snowflake identifier. + /// + public new SocketGroupUser GetUser(ulong id) + { + if (_users.TryGetValue(id, out SocketGroupUser user)) + return user; + return null; + } + internal SocketGroupUser GetOrAddUser(UserModel model) + { + if (_users.TryGetValue(model.Id, out SocketGroupUser user)) + return user; + else + { + var privateUser = SocketGroupUser.Create(this, Discord.State, model); + privateUser.GlobalUser.AddRef(); + _users[privateUser.Id] = privateUser; + return privateUser; + } + } + internal SocketGroupUser RemoveUser(ulong id) + { + if (_users.TryRemove(id, out SocketGroupUser user)) + { + user.GlobalUser.RemoveRef(Discord); + return user; + } + return null; + } + #endregion + + #region Voice States + internal SocketVoiceState AddOrUpdateVoiceState(ClientState state, VoiceStateModel model) + { + var voiceChannel = state.GetChannel(model.ChannelId.Value) as SocketVoiceChannel; + var voiceState = SocketVoiceState.Create(voiceChannel, model); + _voiceStates[model.UserId] = voiceState; + return voiceState; + } + internal SocketVoiceState? GetVoiceState(ulong id) + { + if (_voiceStates.TryGetValue(id, out SocketVoiceState voiceState)) + return voiceState; + return null; + } + internal SocketVoiceState? RemoveVoiceState(ulong id) + { + if (_voiceStates.TryRemove(id, out SocketVoiceState voiceState)) + return voiceState; + return null; + } + + /// + /// Returns the name of the group. + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id}, Group)"; + internal new SocketGroupChannel Clone() => MemberwiseClone() as SocketGroupChannel; + #endregion + + #region SocketChannel + /// + internal override IReadOnlyCollection GetUsersInternal() => Users; + /// + internal override SocketUser GetUserInternal(ulong id) => GetUser(id); + #endregion + + #region ISocketPrivateChannel + /// + IReadOnlyCollection ISocketPrivateChannel.Recipients => Recipients; + #endregion + + #region IPrivateChannel + /// + IReadOnlyCollection IPrivateChannel.Recipients => Recipients; + #endregion + + #region IMessageChannel + /// + Task IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessageAsync(id, options); + else + return Task.FromResult((IMessage)GetCachedMessage(id)); + } + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, null, Direction.Before, limit, mode, options); + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(ulong fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessageId, dir, limit, mode, options); + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(IMessage fromMessage, Direction dir, int limit, CacheMode mode, RequestOptions options) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessage.Id, dir, limit, mode, options); + /// + async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) + => await GetPinnedMessagesAsync(options).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, + RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, + Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(FileAttachment attachment, string text, bool isTTS, + Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFilesAsync(IEnumerable attachments, string text, + bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, + AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, + ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + #endregion + + #region IAudioChannel + /// + /// Connecting to a group channel is not supported. + Task IAudioChannel.ConnectAsync(bool selfDeaf, bool selfMute, bool external, bool disconnect) { throw new NotSupportedException(); } + Task IAudioChannel.DisconnectAsync() { throw new NotSupportedException(); } + Task IAudioChannel.ModifyAsync(Action func, RequestOptions options) { throw new NotSupportedException(); } + #endregion + + #region IChannel + /// + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetUser(id)); + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => ImmutableArray.Create>(Users).ToAsyncEnumerable(); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs new file mode 100644 index 0000000..92dd93d --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs @@ -0,0 +1,238 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based guild channel. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketGuildChannel : SocketChannel, IGuildChannel + { + #region SocketGuildChannel + private ImmutableArray _overwrites; + + /// + /// Gets the guild associated with this channel. + /// + /// + /// A guild object that this channel belongs to. + /// + public SocketGuild Guild { get; } + /// + public string Name { get; private set; } + /// + public int Position { get; private set; } + + /// + public ChannelFlags Flags { get; private set; } + + /// + public virtual IReadOnlyCollection PermissionOverwrites => _overwrites; + /// + /// Gets a collection of users that are able to view the channel. + /// + /// + /// If this channel is a voice channel, use to retrieve a + /// collection of users who are currently connected to this channel. + /// + /// + /// A read-only collection of users that can access the channel (i.e. the users seen in the user list). + /// + public new virtual IReadOnlyCollection Users => ImmutableArray.Create(); + + internal SocketGuildChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id) + { + Guild = guild; + } + internal static SocketGuildChannel Create(SocketGuild guild, ClientState state, Model model) + { + return model.Type switch + { + ChannelType.News => SocketNewsChannel.Create(guild, state, model), + ChannelType.Text => SocketTextChannel.Create(guild, state, model), + ChannelType.Voice => SocketVoiceChannel.Create(guild, state, model), + ChannelType.Category => SocketCategoryChannel.Create(guild, state, model), + ChannelType.PrivateThread or ChannelType.PublicThread or ChannelType.NewsThread => SocketThreadChannel.Create(guild, state, model), + ChannelType.Stage => SocketStageChannel.Create(guild, state, model), + ChannelType.Forum => SocketForumChannel.Create(guild, state, model), + ChannelType.Media => SocketMediaChannel.Create(guild, state, model), + _ => new SocketGuildChannel(guild.Discord, model.Id, guild), + }; + } + /// + internal override void Update(ClientState state, Model model) + { + Name = model.Name.Value; + Position = model.Position.GetValueOrDefault(0); + + var overwrites = model.PermissionOverwrites.GetValueOrDefault(new API.Overwrite[0]); + var newOverwrites = ImmutableArray.CreateBuilder(overwrites.Length); + for (int i = 0; i < overwrites.Length; i++) + newOverwrites.Add(overwrites[i].ToEntity()); + _overwrites = newOverwrites.ToImmutable(); + + Flags = model.Flags.GetValueOrDefault(ChannelFlags.None); + } + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + => ChannelHelper.ModifyAsync(this, Discord, func, options); + /// + public Task DeleteAsync(RequestOptions options = null) + => ChannelHelper.DeleteAsync(this, Discord, options); + + /// + /// Gets the permission overwrite for a specific user. + /// + /// The user to get the overwrite from. + /// + /// An overwrite object for the targeted user; if none is set. + /// + public virtual OverwritePermissions? GetPermissionOverwrite(IUser user) + { + for (int i = 0; i < _overwrites.Length; i++) + { + if (_overwrites[i].TargetId == user.Id) + return _overwrites[i].Permissions; + } + return null; + } + /// + /// Gets the permission overwrite for a specific role. + /// + /// The role to get the overwrite from. + /// + /// An overwrite object for the targeted role; if none is set. + /// + public virtual OverwritePermissions? GetPermissionOverwrite(IRole role) + { + for (int i = 0; i < _overwrites.Length; i++) + { + if (_overwrites[i].TargetId == role.Id) + return _overwrites[i].Permissions; + } + return null; + } + + /// + /// Adds or updates the permission overwrite for the given user. + /// + /// The user to add the overwrite to. + /// The overwrite to add to the user. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous permission operation for adding the specified permissions to the channel. + /// + public virtual Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) + => ChannelHelper.AddPermissionOverwriteAsync(this, Discord, user, permissions, options); + + /// + /// Adds or updates the permission overwrite for the given role. + /// + /// The role to add the overwrite to. + /// The overwrite to add to the role. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous permission operation for adding the specified permissions to the channel. + /// + public virtual Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) + => ChannelHelper.AddPermissionOverwriteAsync(this, Discord, role, permissions, options); + + /// + /// Removes the permission overwrite for the given user, if one exists. + /// + /// The user to remove the overwrite from. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous operation for removing the specified permissions from the channel. + /// + public virtual Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) + => ChannelHelper.RemovePermissionOverwriteAsync(this, Discord, user, options); + + /// + /// Removes the permission overwrite for the given role, if one exists. + /// + /// The role to remove the overwrite from. + /// The options to be used when sending the request. + /// + /// A task representing the asynchronous operation for removing the specified permissions from the channel. + /// + public virtual Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) + => ChannelHelper.RemovePermissionOverwriteAsync(this, Discord, role, options); + + public new virtual SocketGuildUser GetUser(ulong id) => null; + + /// + /// Gets the name of the channel. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id}, Guild)"; + internal new SocketGuildChannel Clone() => MemberwiseClone() as SocketGuildChannel; + #endregion + + #region SocketChannel + /// + internal override IReadOnlyCollection GetUsersInternal() => Users; + /// + internal override SocketUser GetUserInternal(ulong id) => GetUser(id); + #endregion + + #region IGuildChannel + /// + IGuild IGuildChannel.Guild => Guild; + /// + ulong IGuildChannel.GuildId => Guild.Id; + + /// + OverwritePermissions? IGuildChannel.GetPermissionOverwrite(IRole role) + => GetPermissionOverwrite(role); + /// + OverwritePermissions? IGuildChannel.GetPermissionOverwrite(IUser user) + => GetPermissionOverwrite(user); + /// + Task IGuildChannel.AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options) + => AddPermissionOverwriteAsync(role, permissions, options); + + /// + Task IGuildChannel.AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options) + => AddPermissionOverwriteAsync(user, permissions, options); + + /// + Task IGuildChannel.RemovePermissionOverwriteAsync(IRole role, RequestOptions options) + => RemovePermissionOverwriteAsync(role, options); + + /// + Task IGuildChannel.RemovePermissionOverwriteAsync(IUser user, RequestOptions options) + => RemovePermissionOverwriteAsync(user, options); + + /// + IAsyncEnumerable> IGuildChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => ImmutableArray.Create>(Users).ToAsyncEnumerable(); //Overridden in Text/Voice + /// + Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetUser(id)); //Overridden in Text/Voice + #endregion + + #region IChannel + /// + string IChannel.Name => Name; + /// + IAsyncEnumerable> IChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => ImmutableArray.Create>(Users).ToAsyncEnumerable(); //Overridden in Text/Voice + /// + Task IChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetUser(id)); //Overridden in Text/Voice + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketMediaChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketMediaChannel.cs new file mode 100644 index 0000000..ca03f1d --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketMediaChannel.cs @@ -0,0 +1,24 @@ +using Model = Discord.API.Channel; + +namespace Discord.WebSocket; + +public class SocketMediaChannel : SocketForumChannel, IMediaChannel +{ + internal SocketMediaChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id, guild) + { + + } + + internal new static SocketMediaChannel Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketMediaChannel(guild?.Discord, model.Id, guild); + entity.Update(state, model); + return entity; + } + + internal override void Update(ClientState state, Model model) + { + base.Update(state, model); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketNewsChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketNewsChannel.cs new file mode 100644 index 0000000..ec3a0b9 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketNewsChannel.cs @@ -0,0 +1,51 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based news channel in a guild that has the same properties as a . + /// + /// + /// + /// The property is not supported for news channels. + /// + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketNewsChannel : SocketTextChannel, INewsChannel + { + internal SocketNewsChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id, guild) + { + } + internal new static SocketNewsChannel Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketNewsChannel(guild?.Discord, model.Id, guild); + entity.Update(state, model); + return entity; + } + /// + /// + /// + /// This property is not supported by this type. Attempting to use this property will result in a . + /// + /// + public override int SlowModeInterval + => throw new NotSupportedException("News channels do not support Slow Mode."); + + private string DebuggerDisplay => $"{Name} ({Id}, News)"; + + /// + public Task FollowAnnouncementChannelAsync(ITextChannel channel, RequestOptions options = null) + => FollowAnnouncementChannelAsync(channel.Id, options); + + /// + public Task FollowAnnouncementChannelAsync(ulong channelId, RequestOptions options = null) + => ChannelHelper.FollowAnnouncementChannelAsync(this, channelId, Discord, options); + + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketStageChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketStageChannel.cs new file mode 100644 index 0000000..4f200f0 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketStageChannel.cs @@ -0,0 +1,174 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; +using StageInstance = Discord.API.StageInstance; + +namespace Discord.WebSocket +{ + /// + /// Represents a stage channel received over the gateway. + /// + public class SocketStageChannel : SocketVoiceChannel, IStageChannel + { + /// + /// + /// This field is always true for stage channels. + /// + [Obsolete("This property is no longer used because Discord enabled text-in-stage for all channels.")] + public override bool IsTextInVoice + => true; + + /// + public StagePrivacyLevel? PrivacyLevel { get; private set; } + + /// + public bool? IsDiscoverableDisabled { get; private set; } + + /// + public bool IsLive { get; private set; } + + /// + /// Returns if the current user is a speaker within the stage, otherwise . + /// + public bool IsSpeaker + => !Guild.CurrentUser.IsSuppressed; + + /// + /// Gets a collection of users who are speakers within the stage. + /// + public IReadOnlyCollection Speakers + => Users.Where(x => !x.IsSuppressed).ToImmutableArray(); + + /// + /// + /// This property is not supported in stage channels and will always return . + /// + public override string Status => string.Empty; + + internal new SocketStageChannel Clone() => MemberwiseClone() as SocketStageChannel; + + internal SocketStageChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id, guild) { } + + internal new static SocketStageChannel Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketStageChannel(guild?.Discord, model.Id, guild); + entity.Update(state, model); + return entity; + } + internal void Update(StageInstance model, bool isLive = false) + { + IsLive = isLive; + if (isLive) + { + PrivacyLevel = model.PrivacyLevel; + IsDiscoverableDisabled = model.DiscoverableDisabled; + } + else + { + PrivacyLevel = null; + IsDiscoverableDisabled = null; + } + } + + /// + public async Task StartStageAsync(string topic, StagePrivacyLevel privacyLevel = StagePrivacyLevel.GuildOnly, RequestOptions options = null) + { + var args = new API.Rest.CreateStageInstanceParams + { + ChannelId = Id, + Topic = topic, + PrivacyLevel = privacyLevel + }; + + var model = await Discord.ApiClient.CreateStageInstanceAsync(args, options).ConfigureAwait(false); + + Update(model, true); + } + + /// + public async Task ModifyInstanceAsync(Action func, RequestOptions options = null) + { + var model = await ChannelHelper.ModifyAsync(this, Discord, func, options); + + Update(model, true); + } + + /// + public async Task StopStageAsync(RequestOptions options = null) + { + await Discord.ApiClient.DeleteStageInstanceAsync(Id, options); + + Update(null); + } + + /// + public Task RequestToSpeakAsync(RequestOptions options = null) + { + var args = new API.Rest.ModifyVoiceStateParams + { + ChannelId = Id, + RequestToSpeakTimestamp = DateTimeOffset.UtcNow + }; + return Discord.ApiClient.ModifyMyVoiceState(Guild.Id, args, options); + } + + /// + public Task BecomeSpeakerAsync(RequestOptions options = null) + { + var args = new API.Rest.ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = false + }; + return Discord.ApiClient.ModifyMyVoiceState(Guild.Id, args, options); + } + + /// + public Task StopSpeakingAsync(RequestOptions options = null) + { + var args = new API.Rest.ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = true + }; + return Discord.ApiClient.ModifyMyVoiceState(Guild.Id, args, options); + } + + /// + public Task MoveToSpeakerAsync(IGuildUser user, RequestOptions options = null) + { + var args = new API.Rest.ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = false + }; + + return Discord.ApiClient.ModifyUserVoiceState(Guild.Id, user.Id, args); + } + + /// + public Task RemoveFromSpeakerAsync(IGuildUser user, RequestOptions options = null) + { + var args = new API.Rest.ModifyVoiceStateParams + { + ChannelId = Id, + Suppressed = true + }; + + return Discord.ApiClient.ModifyUserVoiceState(Guild.Id, user.Id, args); + } + + /// + /// + /// Setting voice channel status is not supported in stage channels. + /// + /// Setting voice channel status is not supported in stage channels. + public override Task SetStatusAsync(string status, RequestOptions options = null) + => throw new NotSupportedException("Setting voice channel status is not supported in stage channels."); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs new file mode 100644 index 0000000..06560af --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketTextChannel.cs @@ -0,0 +1,477 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based channel in a guild that can send and receive messages. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketTextChannel : SocketGuildChannel, ITextChannel, ISocketMessageChannel + { + #region SocketTextChannel + private readonly MessageCache _messages; + + /// + public string Topic { get; private set; } + /// + public virtual int SlowModeInterval { get; private set; } + /// + public ulong? CategoryId { get; private set; } + /// + public int DefaultSlowModeInterval { get; private set; } + /// + /// Gets the parent (category) of this channel in the guild's channel list. + /// + /// + /// An representing the parent of this channel; if none is set. + /// + public ICategoryChannel Category + => CategoryId.HasValue ? Guild.GetChannel(CategoryId.Value) as ICategoryChannel : null; + /// + public virtual Task SyncPermissionsAsync(RequestOptions options = null) + => ChannelHelper.SyncPermissionsAsync(this, Discord, options); + + private bool _nsfw; + /// + public bool IsNsfw => _nsfw; + /// + public ThreadArchiveDuration DefaultArchiveDuration { get; private set; } + /// + public string Mention => MentionUtils.MentionChannel(Id); + /// + public IReadOnlyCollection CachedMessages => _messages?.Messages ?? ImmutableArray.Create(); + /// + public override IReadOnlyCollection Users + => Guild.Users.Where(x => Permissions.GetValue( + Permissions.ResolveChannel(Guild, x, this, Permissions.ResolveGuild(Guild, x)), + ChannelPermission.ViewChannel)).ToImmutableArray(); + + /// + /// Gets a collection of threads within this text channel. + /// + public IReadOnlyCollection Threads + => Guild.ThreadChannels.Where(x => x.ParentChannel.Id == Id).ToImmutableArray(); + + internal SocketTextChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id, guild) + { + if (Discord?.MessageCacheSize > 0) + _messages = new MessageCache(Discord); + } + internal new static SocketTextChannel Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketTextChannel(guild?.Discord, model.Id, guild); + entity.Update(state, model); + return entity; + } + internal override void Update(ClientState state, Model model) + { + base.Update(state, model); + CategoryId = model.CategoryId; + Topic = model.Topic.GetValueOrDefault(); + SlowModeInterval = model.SlowMode.GetValueOrDefault(); // some guilds haven't been patched to include this yet? + _nsfw = model.Nsfw.GetValueOrDefault(); + if (model.AutoArchiveDuration.IsSpecified) + DefaultArchiveDuration = model.AutoArchiveDuration.Value; + else + DefaultArchiveDuration = ThreadArchiveDuration.OneDay; + + DefaultSlowModeInterval = model.ThreadRateLimitPerUser.GetValueOrDefault(0); + // basic value at channel creation. Shouldn't be called since guild text channels always have this property + } + + /// + public virtual Task ModifyAsync(Action func, RequestOptions options = null) + => ChannelHelper.ModifyAsync(this, Discord, func, options); + + /// + /// Creates a thread within this . + /// + /// + /// When is the thread type will be based off of the + /// channel its created in. When called on a , it creates a . + /// When called on a , it creates a . The id of the created + /// thread will be the same as the id of the message, and as such a message can only have a + /// single thread created from it. + /// + /// The name of the thread. + /// + /// The type of the thread. + /// + /// Note: This parameter is not used if the parameter is not specified. + /// + /// + /// + /// The duration on which this thread archives after. + /// + /// The message which to start the thread from. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous create operation. The task result contains a + /// + public virtual async Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, + ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) + { + var model = await ThreadHelper.CreateThreadAsync(Discord, this, name, type, autoArchiveDuration, message, invitable, slowmode, options); + + var thread = (SocketThreadChannel)Guild.AddOrUpdateChannel(Discord.State, model); + + if (Discord.AlwaysDownloadUsers && Discord.HasGatewayIntent(GatewayIntents.GuildMembers)) + await thread.DownloadUsersAsync(); + + return thread; + } + + /// + public virtual Task> GetActiveThreadsAsync(RequestOptions options = null) + => ThreadHelper.GetActiveThreadsAsync(Guild, Id, Discord, options); + + #endregion + + #region Messages + /// + public virtual SocketMessage GetCachedMessage(ulong id) + => _messages?.Get(id); + /// + /// Gets a message from this message channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The snowflake identifier of the message. + /// The options to be used when sending the request. + /// + /// A task that represents an asynchronous get operation for retrieving the message. The task result contains + /// the retrieved message; if no message is found with the specified identifier. + /// + public virtual async Task GetMessageAsync(ulong id, RequestOptions options = null) + { + IMessage msg = _messages?.Get(id); + if (msg == null) + msg = await ChannelHelper.GetMessageAsync(this, Discord, id, options).ConfigureAwait(false); + return msg; + } + + /// + /// Gets the last N messages from this message channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public virtual IAsyncEnumerable> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, null, Direction.Before, limit, CacheMode.AllowDownload, options); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The ID of the starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public virtual IAsyncEnumerable> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessageId, dir, limit, CacheMode.AllowDownload, options); + /// + /// Gets a collection of messages in this channel. + /// + /// + /// This method follows the same behavior as described in . + /// Please visit its documentation for more details on this method. + /// + /// The starting message to get the messages from. + /// The direction of the messages to be gotten from. + /// The numbers of message to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of messages. + /// + public virtual IAsyncEnumerable> GetMessagesAsync(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch, RequestOptions options = null) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessage.Id, dir, limit, CacheMode.AllowDownload, options); + /// + public virtual IReadOnlyCollection GetCachedMessages(int limit = DiscordConfig.MaxMessagesPerBatch) + => SocketChannelHelper.GetCachedMessages(this, Discord, _messages, null, Direction.Before, limit); + /// + public virtual IReadOnlyCollection GetCachedMessages(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + => SocketChannelHelper.GetCachedMessages(this, Discord, _messages, fromMessageId, dir, limit); + /// + public virtual IReadOnlyCollection GetCachedMessages(IMessage fromMessage, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + => SocketChannelHelper.GetCachedMessages(this, Discord, _messages, fromMessage.Id, dir, limit); + /// + public virtual Task> GetPinnedMessagesAsync(RequestOptions options = null) + => ChannelHelper.GetPinnedMessagesAsync(this, Discord, options); + + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendMessageAsync(string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageReference messageReference = null, + MessageComponent components = null, ISticker[] stickers = null, Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendMessageAsync(this, Discord, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, embeds, flags); + + /// + /// The only valid are , and . + public virtual Task SendFileAsync(string filePath, string text = null, bool isTTS = false, Embed embed = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, filePath, text, isTTS, embed, allowedMentions, messageReference, + components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendFileAsync(Stream stream, string filename, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, stream, filename, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, isSpoiler, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendFileAsync(FileAttachment attachment, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFileAsync(this, Discord, attachment, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, embeds, flags); + /// + /// Message content is too long, length must be less or equal to . + /// The only valid are , and . + public virtual Task SendFilesAsync(IEnumerable attachments, string text = null, bool isTTS = false, + Embed embed = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageReference messageReference = null, MessageComponent components = null, ISticker[] stickers = null, + Embed[] embeds = null, MessageFlags flags = MessageFlags.None) + => ChannelHelper.SendFilesAsync(this, Discord, attachments, text, isTTS, embed, allowedMentions, + messageReference, components, stickers, options, embeds, flags); + + /// + public virtual Task DeleteMessagesAsync(IEnumerable messages, RequestOptions options = null) + => ChannelHelper.DeleteMessagesAsync(this, Discord, messages.Select(x => x.Id), options); + /// + public virtual Task DeleteMessagesAsync(IEnumerable messageIds, RequestOptions options = null) + => ChannelHelper.DeleteMessagesAsync(this, Discord, messageIds, options); + + /// + public virtual async Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null) + => await ChannelHelper.ModifyMessageAsync(this, messageId, func, Discord, options).ConfigureAwait(false); + + /// + public virtual Task DeleteMessageAsync(ulong messageId, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, messageId, Discord, options); + /// + public virtual Task DeleteMessageAsync(IMessage message, RequestOptions options = null) + => ChannelHelper.DeleteMessageAsync(this, message.Id, Discord, options); + + /// + public virtual Task TriggerTypingAsync(RequestOptions options = null) + => ChannelHelper.TriggerTypingAsync(this, Discord, options); + /// + public virtual IDisposable EnterTypingState(RequestOptions options = null) + => ChannelHelper.EnterTypingState(this, Discord, options); + + internal void AddMessage(SocketMessage msg) + => _messages?.Add(msg); + internal SocketMessage RemoveMessage(ulong id) + => _messages?.Remove(id); + #endregion + + #region Users + /// + public override SocketGuildUser GetUser(ulong id) + { + var user = Guild.GetUser(id); + if (user != null) + { + var guildPerms = Permissions.ResolveGuild(Guild, user); + var channelPerms = Permissions.ResolveChannel(Guild, user, this, guildPerms); + if (Permissions.GetValue(channelPerms, ChannelPermission.ViewChannel)) + return user; + } + return null; + } + #endregion + + #region Webhooks + /// + /// Creates a webhook in this text channel. + /// + /// The name of the webhook. + /// The avatar of the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// webhook. + /// + public virtual Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + => ChannelHelper.CreateWebhookAsync(this, Discord, name, avatar, options); + /// + /// Gets a webhook available in this text channel. + /// + /// The identifier of the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a webhook associated + /// with the identifier; if the webhook is not found. + /// + public virtual Task GetWebhookAsync(ulong id, RequestOptions options = null) + => ChannelHelper.GetWebhookAsync(this, Discord, id, options); + /// + /// Gets the webhooks available in this text channel. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of webhooks that is available in this channel. + /// + public virtual Task> GetWebhooksAsync(RequestOptions options = null) + => ChannelHelper.GetWebhooksAsync(this, Discord, options); + #endregion + + #region Invites + /// + public virtual async Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, options).ConfigureAwait(false); + /// + public virtual async Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, applicationId, options).ConfigureAwait(false); + /// + public virtual async Task CreateInviteToApplicationAsync(DefaultApplications application, int? maxAge = 86400, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToApplicationAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, (ulong)application, options); + /// + public virtual async Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = default(int?), bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => await ChannelHelper.CreateInviteToStreamAsync(this, Discord, maxAge, maxUses, isTemporary, isUnique, user, options).ConfigureAwait(false); + /// + public virtual async Task> GetInvitesAsync(RequestOptions options = null) + => await ChannelHelper.GetInvitesAsync(this, Discord, options).ConfigureAwait(false); + + private string DebuggerDisplay => $"{Name} ({Id}, Text)"; + internal new SocketTextChannel Clone() => MemberwiseClone() as SocketTextChannel; + #endregion + + #region IIntegrationChannel + + /// + async Task IIntegrationChannel.CreateWebhookAsync(string name, Stream avatar, RequestOptions options) + => await CreateWebhookAsync(name, avatar, options).ConfigureAwait(false); + /// + async Task IIntegrationChannel.GetWebhookAsync(ulong id, RequestOptions options) + => await GetWebhookAsync(id, options).ConfigureAwait(false); + /// + async Task> IIntegrationChannel.GetWebhooksAsync(RequestOptions options) + => await GetWebhooksAsync(options).ConfigureAwait(false); + /// + #endregion + + #region ITextChannel + async Task ITextChannel.CreateThreadAsync(string name, ThreadType type, ThreadArchiveDuration autoArchiveDuration, IMessage message, bool? invitable, int? slowmode, RequestOptions options) + => await CreateThreadAsync(name, type, autoArchiveDuration, message, invitable, slowmode, options); + /// + async Task> ITextChannel.GetActiveThreadsAsync(RequestOptions options) + => await GetActiveThreadsAsync(options); + #endregion + + #region IGuildChannel + /// + async Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + var user = GetUser(id); + if (user is not null || mode == CacheMode.CacheOnly) + return user; + + return await ChannelHelper.GetUserAsync(this, Guild, Discord, id, options).ConfigureAwait(false); + } + /// + IAsyncEnumerable> IGuildChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + { + return mode == CacheMode.AllowDownload + ? ChannelHelper.GetUsersAsync(this, Guild, Discord, null, null, options) + : ImmutableArray.Create>(Users).ToAsyncEnumerable(); + } + + #endregion + + #region IMessageChannel + /// + Task IMessageChannel.GetMessageAsync(ulong id, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return GetMessageAsync(id, options); + else + return Task.FromResult((IMessage)GetCachedMessage(id)); + } + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(int limit, CacheMode mode, RequestOptions options) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, null, Direction.Before, limit, mode, options); + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(ulong fromMessageId, Direction dir, int limit, CacheMode mode, RequestOptions options) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessageId, dir, limit, mode, options); + /// + IAsyncEnumerable> IMessageChannel.GetMessagesAsync(IMessage fromMessage, Direction dir, int limit, CacheMode mode, RequestOptions options) + => SocketChannelHelper.GetMessagesAsync(this, Discord, _messages, fromMessage.Id, dir, limit, mode, options); + /// + async Task> IMessageChannel.GetPinnedMessagesAsync(RequestOptions options) + => await GetPinnedMessagesAsync(options).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(string filePath, string text, bool isTTS, Embed embed, + RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(filePath, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(Stream stream, string filename, string text, bool isTTS, + Embed embed, RequestOptions options, bool isSpoiler, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(stream, filename, text, isTTS, embed, options, isSpoiler, allowedMentions, messageReference, + components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFileAsync(FileAttachment attachment, string text, bool isTTS, + Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFileAsync(attachment, text, isTTS, embed, options, allowedMentions, messageReference, components, + stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendFilesAsync(IEnumerable attachments, string text, + bool isTTS, Embed embed, RequestOptions options, AllowedMentions allowedMentions, MessageReference messageReference, + MessageComponent components, ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendFilesAsync(attachments, text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + /// + async Task IMessageChannel.SendMessageAsync(string text, bool isTTS, Embed embed, RequestOptions options, + AllowedMentions allowedMentions, MessageReference messageReference, MessageComponent components, + ISticker[] stickers, Embed[] embeds, MessageFlags flags) + => await SendMessageAsync(text, isTTS, embed, options, allowedMentions, messageReference, components, stickers, embeds, flags).ConfigureAwait(false); + + #endregion + + #region INestedChannel + /// + Task INestedChannel.GetCategoryAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(Category); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketThreadChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketThreadChannel.cs new file mode 100644 index 0000000..3df89d8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketThreadChannel.cs @@ -0,0 +1,392 @@ +using Discord.Rest; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; +using ThreadMember = Discord.API.ThreadMember; + +namespace Discord.WebSocket +{ + /// + /// Represents a thread channel inside of a guild. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketThreadChannel : SocketTextChannel, IThreadChannel + { + /// + public ThreadType Type { get; private set; } + + /// + /// Gets the owner of the current thread. + /// + public SocketThreadUser Owner + { + get + { + lock (_ownerLock) + { + var user = GetUser(_ownerId); + + if (user == null) + { + var guildMember = Guild.GetUser(_ownerId); + if (guildMember == null) + return null; + + user = SocketThreadUser.Create(Guild, this, guildMember); + _members[user.Id] = user; + return user; + } + else + return user; + } + } + } + + /// + /// Gets the current users within this thread. + /// + public SocketThreadUser CurrentUser + => Users.FirstOrDefault(x => x.Id == Discord.CurrentUser.Id); + + /// + public bool HasJoined { get; private set; } + + /// + /// if this thread is private, otherwise + /// + public bool IsPrivateThread + => Type == ThreadType.PrivateThread; + + /// + /// Gets the parent channel this thread resides in. + /// + public SocketGuildChannel ParentChannel { get; private set; } + + /// + public int MessageCount { get; private set; } + + /// + public int MemberCount { get; private set; } + + /// + public bool IsArchived { get; private set; } + + /// + public DateTimeOffset ArchiveTimestamp { get; private set; } + + /// + public ThreadArchiveDuration AutoArchiveDuration { get; private set; } + + /// + public bool IsLocked { get; private set; } + + /// + public bool? IsInvitable { get; private set; } + + /// + public IReadOnlyCollection AppliedTags { get; private set; } + + /// + public override DateTimeOffset CreatedAt { get; } + + /// + ulong IThreadChannel.OwnerId => _ownerId; + + /// + /// Gets a collection of cached users within this thread. + /// + public new IReadOnlyCollection Users => + _members.Values.ToImmutableArray(); + + private readonly ConcurrentDictionary _members; + + private string DebuggerDisplay => $"{Name} ({Id}, Thread)"; + + private bool _usersDownloaded; + + private readonly object _downloadLock = new object(); + private readonly object _ownerLock = new object(); + + private ulong _ownerId; + + internal SocketThreadChannel(DiscordSocketClient discord, SocketGuild guild, ulong id, SocketGuildChannel parent, + DateTimeOffset? createdAt) + : base(discord, id, guild) + { + ParentChannel = parent; + _members = new ConcurrentDictionary(); + CreatedAt = createdAt ?? new DateTimeOffset(2022, 1, 9, 0, 0, 0, TimeSpan.Zero); + } + + internal new static SocketThreadChannel Create(SocketGuild guild, ClientState state, Model model) + { + var parent = guild.GetChannel(model.CategoryId.Value); + var entity = new SocketThreadChannel(guild.Discord, guild, model.Id, parent, model.ThreadMetadata.GetValueOrDefault()?.CreatedAt.GetValueOrDefault(null)); + entity.Update(state, model); + return entity; + } + + internal override void Update(ClientState state, Model model) + { + base.Update(state, model); + + Type = (ThreadType)model.Type; + MessageCount = model.MessageCount.GetValueOrDefault(-1); + MemberCount = model.MemberCount.GetValueOrDefault(-1); + + if (model.ThreadMetadata.IsSpecified) + { + IsInvitable = model.ThreadMetadata.Value.Invitable.ToNullable(); + IsArchived = model.ThreadMetadata.Value.Archived; + ArchiveTimestamp = model.ThreadMetadata.Value.ArchiveTimestamp; + AutoArchiveDuration = model.ThreadMetadata.Value.AutoArchiveDuration; + IsLocked = model.ThreadMetadata.Value.Locked.GetValueOrDefault(false); + } + + if (model.OwnerId.IsSpecified) + { + _ownerId = model.OwnerId.Value; + } + + HasJoined = model.ThreadMember.IsSpecified; + + AppliedTags = model.AppliedTags.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + } + + internal IReadOnlyCollection RemoveUsers(ulong[] users) + { + List threadUsers = new(); + + foreach (var userId in users) + { + if (_members.TryRemove(userId, out var user)) + threadUsers.Add(user); + } + + return threadUsers.ToImmutableArray(); + } + + internal SocketThreadUser AddOrUpdateThreadMember(ThreadMember model, SocketGuildUser guildMember = null) + { + if (_members.TryGetValue(model.UserId.Value, out SocketThreadUser member)) + member.Update(model); + else + { + member = SocketThreadUser.Create(Guild, this, model, guildMember); + member.GlobalUser.AddRef(); + _members[member.Id] = member; + } + return member; + } + + /// + public new SocketThreadUser GetUser(ulong id) + { + var user = Users.FirstOrDefault(x => x.Id == id); + return user; + } + + /// + /// Gets all users inside this thread. + /// + /// + /// If all users are not downloaded then this method will call and return the result. + /// + /// The options to be used when sending the request. + /// A task representing the download operation. + public async Task> GetUsersAsync(RequestOptions options = null) + { + // download all users if we haven't + if (!_usersDownloaded) + { + await DownloadUsersAsync(options); + _usersDownloaded = true; + } + + return Users; + } + + /// + /// Downloads all users that have access to this thread. + /// + /// The options to be used when sending the request. + /// A task representing the asynchronous download operation. + public async Task DownloadUsersAsync(RequestOptions options = null) + { + var prevBatchCount = DiscordConfig.MaxThreadMembersPerBatch; + ulong? maxId = null; + + while (prevBatchCount == DiscordConfig.MaxThreadMembersPerBatch) + { + var users = await Discord.ApiClient.ListThreadMembersAsync(Id, maxId, DiscordConfig.MaxThreadMembersPerBatch, options); + prevBatchCount = users.Length; + maxId = users.Max(x => x.UserId.GetValueOrDefault()); + + lock (_downloadLock) + { + foreach (var threadMember in users) + { + AddOrUpdateThreadMember(threadMember); + } + } + } + } + + internal new SocketThreadChannel Clone() => MemberwiseClone() as SocketThreadChannel; + + /// + public Task JoinAsync(RequestOptions options = null) + => Discord.ApiClient.JoinThreadAsync(Id, options); + + /// + public Task LeaveAsync(RequestOptions options = null) + => Discord.ApiClient.LeaveThreadAsync(Id, options); + + /// + /// Adds a user to this thread. + /// + /// The to add. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation of adding a member to a thread. + /// + public Task AddUserAsync(IGuildUser user, RequestOptions options = null) + => Discord.ApiClient.AddThreadMemberAsync(Id, user.Id, options); + + /// + /// Removes a user from this thread. + /// + /// The to remove from this thread. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous operation of removing a user from this thread. + /// + public Task RemoveUserAsync(IGuildUser user, RequestOptions options = null) + => Discord.ApiClient.RemoveThreadMemberAsync(Id, user.Id, options); + + /// + /// + /// This method is not supported in threads. + /// + public override Task AddPermissionOverwriteAsync(IRole role, OverwritePermissions permissions, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task AddPermissionOverwriteAsync(IUser user, OverwritePermissions permissions, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateInviteAsync(int? maxAge = 86400, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateInviteToApplicationAsync(ulong applicationId, int? maxAge, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateInviteToStreamAsync(IUser user, int? maxAge, int? maxUses = null, bool isTemporary = false, bool isUnique = false, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task CreateWebhookAsync(string name, Stream avatar = null, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task> GetInvitesAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override OverwritePermissions? GetPermissionOverwrite(IRole role) + => ParentChannel.GetPermissionOverwrite(role); + + /// + /// + /// This method is not supported in threads. + /// + public override OverwritePermissions? GetPermissionOverwrite(IUser user) + => ParentChannel.GetPermissionOverwrite(user); + + /// + /// + /// This method is not supported in threads. + /// + public override Task GetWebhookAsync(ulong id, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task> GetWebhooksAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + public override Task ModifyAsync(Action func, RequestOptions options = null) + => ThreadHelper.ModifyAsync(this, Discord, func, options); + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + => ThreadHelper.ModifyAsync(this, Discord, func, options); + + /// + /// + /// This method is not supported in threads. + /// + public override Task RemovePermissionOverwriteAsync(IRole role, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task RemovePermissionOverwriteAsync(IUser user, RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override IReadOnlyCollection PermissionOverwrites + => throw new NotSupportedException("This method is not supported in threads."); + + /// + /// + /// This method is not supported in threads. + /// + public override Task SyncPermissionsAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + /// This method is not supported in threads. + public override Task> GetActiveThreadsAsync(RequestOptions options = null) + => throw new NotSupportedException("This method is not supported in threads."); + + string IChannel.Name => Name; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs new file mode 100644 index 0000000..4711b3d --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketVoiceChannel.cs @@ -0,0 +1,145 @@ +using Discord.Audio; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Channel; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based voice channel in a guild. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketVoiceChannel : SocketTextChannel, IVoiceChannel, ISocketAudioChannel + { + #region SocketVoiceChannel + /// + /// Gets whether or not the guild has Text-In-Voice enabled and the voice channel is a TiV channel. + /// + /// + /// Discord currently doesn't have a way to disable Text-In-Voice yet so this field is always + /// on s and on + /// s. + /// + [Obsolete("This property is no longer used because Discord enabled text-in-voice for all channels.")] + public virtual bool IsTextInVoice => true; + + /// + public int Bitrate { get; private set; } + /// + public int? UserLimit { get; private set; } + /// + public string RTCRegion { get; private set; } + /// + public VideoQualityMode VideoQualityMode { get; private set; } + + /// + /// Gets the voice channel status set in this channel. if it is not set. + /// + public virtual string Status { get; private set; } + + /// + /// Gets a collection of users that are currently connected to this voice channel. + /// + /// + /// A read-only collection of users that are currently connected to this voice channel. + /// + public IReadOnlyCollection ConnectedUsers + => Guild.Users.Where(x => x.VoiceChannel?.Id == Id).ToImmutableArray(); + + internal SocketVoiceChannel(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id, guild) + { + } + + internal new static SocketVoiceChannel Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketVoiceChannel(guild?.Discord, model.Id, guild); + entity.Update(state, model); + return entity; + } + + internal void UpdateVoiceStatus(string status) + { + Status = status; + } + + /// + internal override void Update(ClientState state, Model model) + { + base.Update(state, model); + Bitrate = model.Bitrate.GetValueOrDefault(64000); + UserLimit = model.UserLimit.GetValueOrDefault() != 0 ? model.UserLimit.Value : (int?)null; + VideoQualityMode = model.VideoQualityMode.GetValueOrDefault(VideoQualityMode.Auto); + RTCRegion = model.RTCRegion.GetValueOrDefault(null); + Status = model.Status.GetValueOrDefault(null); + } + + /// + public virtual Task SetStatusAsync(string status, RequestOptions options = null) + => ChannelHelper.ModifyVoiceChannelStatusAsync(this, status, Discord, options); + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + => ChannelHelper.ModifyAsync(this, Discord, func, options); + + /// + public Task ConnectAsync(bool selfDeaf = false, bool selfMute = false, bool external = false, bool disconnect = true) + => Guild.ConnectAudioAsync(Id, selfDeaf, selfMute, external, disconnect); + + /// + public Task DisconnectAsync() + => Guild.DisconnectAudioAsync(); + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + => Guild.ModifyAudioAsync(Id, func, options); + + + /// + public override SocketGuildUser GetUser(ulong id) + { + var user = Guild.GetUser(id); + if (user?.VoiceChannel?.Id == Id) + return user; + return null; + } + + /// Cannot create threads in voice channels. + public override Task CreateThreadAsync(string name, ThreadType type = ThreadType.PublicThread, ThreadArchiveDuration autoArchiveDuration = ThreadArchiveDuration.OneDay, IMessage message = null, bool? invitable = null, int? slowmode = null, RequestOptions options = null) + => throw new InvalidOperationException("Voice channels cannot contain threads."); + + #endregion + + #region TextOverrides + + /// Threads are not supported in voice channels + public override Task> GetActiveThreadsAsync(RequestOptions options = null) + => throw new NotSupportedException("Threads are not supported in voice channels"); + + #endregion + + private string DebuggerDisplay => $"{Name} ({Id}, Voice)"; + internal new SocketVoiceChannel Clone() => MemberwiseClone() as SocketVoiceChannel; + + #region IGuildChannel + /// + Task IGuildChannel.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetUser(id)); + /// + IAsyncEnumerable> IGuildChannel.GetUsersAsync(CacheMode mode, RequestOptions options) + => ImmutableArray.Create>(Users).ToAsyncEnumerable(); + #endregion + + #region INestedChannel + /// + Task INestedChannel.GetCategoryAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(Category); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/AutoModActionExecutedData.cs b/src/Discord.Net.WebSocket/Entities/Guilds/AutoModActionExecutedData.cs new file mode 100644 index 0000000..0643c15 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Guilds/AutoModActionExecutedData.cs @@ -0,0 +1,86 @@ +using Discord.Rest; + +namespace Discord.WebSocket; + +public class AutoModActionExecutedData +{ + /// + /// Gets the id of the rule which action belongs to. + /// + public Cacheable Rule { get; } + + /// + /// Gets the trigger type of rule which was triggered. + /// + public AutoModTriggerType TriggerType { get; } + + /// + /// Gets the user which generated the content which triggered the rule. + /// + public Cacheable User { get; } + + /// + /// Gets the channel in which user content was posted. + /// + public Cacheable Channel { get; } + + /// + /// Gets the message that triggered the action. + /// + /// + /// This property will be if the message was blocked by the automod. + /// + public Cacheable? Message { get; } + + /// + /// Gets the id of the system auto moderation messages posted as a result of this action. + /// + /// + /// This property will be if this event does not correspond to an action + /// with type . + /// + public ulong AlertMessageId { get; } + + /// + /// Gets the user-generated text content. + /// + /// + /// This property will be empty if is disabled. + /// + public string Content { get; } + + /// + /// Gets the substring in content that triggered the rule. + /// + /// + /// This property will be empty if is disabled. + /// + public string MatchedContent { get; } + + /// + /// Gets the word or phrase configured in the rule that triggered the rule. + /// + public string MatchedKeyword { get; } + + internal AutoModActionExecutedData(Cacheable rule, + AutoModTriggerType triggerType, + Cacheable user, + Cacheable channel, + Cacheable? message, + ulong alertMessageId, + string content, + string matchedContent, + string matchedKeyword + ) + { + Rule = rule; + TriggerType = triggerType; + User = user; + Channel = channel; + Message = message; + AlertMessageId = alertMessageId; + Content = content; + MatchedContent = matchedContent; + MatchedKeyword = matchedKeyword; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboarding.cs b/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboarding.cs new file mode 100644 index 0000000..404943d --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboarding.cs @@ -0,0 +1,79 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.GuildOnboarding; + +namespace Discord.WebSocket; + +/// +public class SocketGuildOnboarding : IGuildOnboarding +{ + internal DiscordSocketClient Discord; + + /// + public ulong GuildId { get; private set; } + + /// + public SocketGuild Guild { get; private set; } + + /// + public IReadOnlyCollection Prompts { get; private set; } + + /// + public IReadOnlyCollection DefaultChannelIds { get; private set; } + + /// + /// Gets channels members get opted in automatically. + /// + public IReadOnlyCollection DefaultChannels { get; private set; } + + /// + public bool IsEnabled { get; private set; } + + /// + public bool IsBelowRequirements { get; private set; } + + /// + public GuildOnboardingMode Mode { get; private set; } + + internal SocketGuildOnboarding(DiscordSocketClient discord, Model model, SocketGuild guild) + { + Discord = discord; + Guild = guild; + Update(model); + } + + internal void Update(Model model) + { + GuildId = model.GuildId; + IsEnabled = model.Enabled; + Mode = model.Mode; + IsBelowRequirements = model.IsBelowRequirements; + + DefaultChannelIds = model.DefaultChannelIds; + DefaultChannels = model.DefaultChannelIds.Select(Guild.GetChannel).ToImmutableArray(); + + Prompts = model.Prompts.Select(x => new SocketGuildOnboardingPrompt(Discord, x.Id, x, Guild)).ToImmutableArray(); + } + + /// + public async Task ModifyAsync(Action props, RequestOptions options = null) + { + var model = await GuildHelper.ModifyGuildOnboardingAsync(Guild, props, Discord, options); + + Update(model); + } + + #region IGuildOnboarding + + /// + IGuild IGuildOnboarding.Guild => Guild; + + /// + IReadOnlyCollection IGuildOnboarding.Prompts => Prompts; + + #endregion +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboardingPrompt.cs b/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboardingPrompt.cs new file mode 100644 index 0000000..9bcbfac --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboardingPrompt.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using Model = Discord.API.GuildOnboardingPrompt; + +namespace Discord.WebSocket; + + +/// +public class SocketGuildOnboardingPrompt : SocketEntity, IGuildOnboardingPrompt +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public IReadOnlyCollection Options { get; private set; } + + /// + public string Title { get; private set; } + + /// + public bool IsSingleSelect { get; private set; } + + /// + public bool IsRequired { get; private set; } + + /// + public bool IsInOnboarding { get; private set; } + + /// + public GuildOnboardingPromptType Type { get; private set; } + + internal SocketGuildOnboardingPrompt(DiscordSocketClient discord, ulong id, Model model, SocketGuild guild) : base(discord, id) + { + Title = model.Title; + IsSingleSelect = model.IsSingleSelect; + IsInOnboarding = model.IsInOnboarding; + IsRequired = model.IsRequired; + Type = model.Type; + + Options = model.Options.Select(option => new SocketGuildOnboardingPromptOption(discord, option.Id, option, guild)).ToImmutableArray(); + } + + #region IGuildOnboardingPrompt + + /// + IReadOnlyCollection IGuildOnboardingPrompt.Options => Options; + + #endregion +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboardingPromptOption.cs b/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboardingPromptOption.cs new file mode 100644 index 0000000..4e96a9f --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Guilds/Onboarding/SocketGuildOnboardingPromptOption.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +using Model = Discord.API.GuildOnboardingPromptOption; + +namespace Discord.WebSocket; + +/// +public class SocketGuildOnboardingPromptOption : SocketEntity, IGuildOnboardingPromptOption +{ + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + public IReadOnlyCollection ChannelIds { get; private set; } + + /// + /// Gets channels a member is added to when the option is selected. + /// + public IReadOnlyCollection Channels { get; private set; } + + /// + public IReadOnlyCollection RoleIds { get; private set; } + + /// + /// Gets roles assigned to a member when the option is selected. + /// + public IReadOnlyCollection Roles { get; private set; } + + /// + public IEmote Emoji { get; private set; } + + /// + public string Title { get; private set; } + + /// + public string Description { get; private set; } + + internal SocketGuildOnboardingPromptOption(DiscordSocketClient discord, ulong id, Model model, SocketGuild guild) : base(discord, id) + { + ChannelIds = model.ChannelIds.ToImmutableArray(); + RoleIds = model.RoleIds.ToImmutableArray(); + Title = model.Title; + Description = model.Description; + + if (model.Emoji.Id.HasValue) + { + Emoji = new Emote(model.Emoji.Id.Value, model.Emoji.Name, model.Emoji.Animated ?? false); + } + else if (!string.IsNullOrWhiteSpace(model.Emoji.Name)) + { + Emoji = new Emoji(model.Emoji.Name); + } + else + { + Emoji = null; + } + + Roles = model.RoleIds.Select(guild.GetRole).ToImmutableArray(); + Channels = model.ChannelIds.Select(guild.GetChannel).ToImmutableArray(); + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketAutoModRule.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketAutoModRule.cs new file mode 100644 index 0000000..844a682 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketAutoModRule.cs @@ -0,0 +1,131 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.AutoModerationRule; + +namespace Discord.WebSocket +{ + public class SocketAutoModRule : SocketEntity, IAutoModRule + { + /// + /// Gets the guild that this rule is in. + /// + public SocketGuild Guild { get; } + + /// + public string Name { get; private set; } + + /// + /// Gets the creator of this rule. + /// + public SocketGuildUser Creator { get; private set; } + + /// + public AutoModEventType EventType { get; private set; } + + /// + public AutoModTriggerType TriggerType { get; private set; } + + /// + public IReadOnlyCollection KeywordFilter { get; private set; } + + /// + public IReadOnlyCollection RegexPatterns { get; private set; } + + /// + public IReadOnlyCollection AllowList { get; private set; } + + /// + public IReadOnlyCollection Presets { get; private set; } + + /// + public IReadOnlyCollection Actions { get; private set; } + + /// + public int? MentionTotalLimit { get; private set; } + + /// + public bool Enabled { get; private set; } + + /// + /// Gets the roles that are exempt from this rule. + /// + public IReadOnlyCollection ExemptRoles { get; private set; } + + /// + /// Gets the channels that are exempt from this rule. + /// + public IReadOnlyCollection ExemptChannels { get; private set; } + + /// + public DateTimeOffset CreatedAt + => SnowflakeUtils.FromSnowflake(Id); + + private ulong _creatorId; + + internal SocketAutoModRule(DiscordSocketClient discord, ulong id, SocketGuild guild) + : base(discord, id) + { + Guild = guild; + } + + internal static SocketAutoModRule Create(DiscordSocketClient discord, SocketGuild guild, Model model) + { + var entity = new SocketAutoModRule(discord, model.Id, guild); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Name = model.Name; + _creatorId = model.CreatorId; + Creator ??= Guild.GetUser(_creatorId); + EventType = model.EventType; + TriggerType = model.TriggerType; + KeywordFilter = model.TriggerMetadata.KeywordFilter.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + Presets = model.TriggerMetadata.Presets.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + RegexPatterns = model.TriggerMetadata.RegexPatterns.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + AllowList = model.TriggerMetadata.AllowList.GetValueOrDefault(Array.Empty()).ToImmutableArray(); + MentionTotalLimit = model.TriggerMetadata.MentionLimit.IsSpecified + ? model.TriggerMetadata.MentionLimit.Value + : null; + Actions = model.Actions.Select(x => new AutoModRuleAction( + x.Type, + x.Metadata.GetValueOrDefault()?.ChannelId.ToNullable(), + x.Metadata.GetValueOrDefault()?.DurationSeconds.ToNullable(), + x.Metadata.IsSpecified + ? x.Metadata.Value.CustomMessage.IsSpecified + ? x.Metadata.Value.CustomMessage.Value + : null + : null + )).ToImmutableArray(); + Enabled = model.Enabled; + ExemptRoles = model.ExemptRoles.Select(x => Guild.GetRole(x)).ToImmutableArray(); + ExemptChannels = model.ExemptChannels.Select(x => Guild.GetChannel(x)).ToImmutableArray(); + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await GuildHelper.ModifyRuleAsync(Discord, this, func, options); + Guild.AddOrUpdateAutoModRule(model); + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => GuildHelper.DeleteRuleAsync(Discord, this, options); + + internal SocketAutoModRule Clone() => MemberwiseClone() as SocketAutoModRule; + + #region IAutoModRule + IReadOnlyCollection IAutoModRule.ExemptRoles => ExemptRoles.Select(x => x.Id).ToImmutableArray(); + IReadOnlyCollection IAutoModRule.ExemptChannels => ExemptChannels.Select(x => x.Id).ToImmutableArray(); + ulong IAutoModRule.GuildId => Guild.Id; + ulong IAutoModRule.CreatorId => _creatorId; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs new file mode 100644 index 0000000..86335d0 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -0,0 +1,2327 @@ +using Discord.API.Gateway; +using Discord.Audio; +using Discord.Rest; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using AutoModRuleModel = Discord.API.AutoModerationRule; +using ChannelModel = Discord.API.Channel; +using EmojiUpdateModel = Discord.API.Gateway.GuildEmojiUpdateEvent; +using EventModel = Discord.API.GuildScheduledEvent; +using ExtendedModel = Discord.API.Gateway.ExtendedGuild; +using GuildSyncModel = Discord.API.Gateway.GuildSyncEvent; +using MemberModel = Discord.API.GuildMember; +using Model = Discord.API.Guild; +using PresenceModel = Discord.API.Presence; +using RoleModel = Discord.API.Role; +using StickerModel = Discord.API.Sticker; +using UserModel = Discord.API.User; +using VoiceStateModel = Discord.API.VoiceState; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based guild object. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketGuild : SocketEntity, IGuild, IDisposable + { + #region SocketGuild +#pragma warning disable IDISP002, IDISP006 + private readonly SemaphoreSlim _audioLock; + private TaskCompletionSource _syncPromise, _downloaderPromise; + private TaskCompletionSource _audioConnectPromise; + private ConcurrentDictionary _channels; + private ConcurrentDictionary _members; + private ConcurrentDictionary _roles; + private ConcurrentDictionary _voiceStates; + private ConcurrentDictionary _stickers; + private ConcurrentDictionary _events; + private ConcurrentDictionary _automodRules; + private ImmutableArray _emotes; + + private readonly AuditLogCache _auditLogs; + + private AudioClient _audioClient; + private VoiceStateUpdateParams _voiceStateUpdateParams; +#pragma warning restore IDISP002, IDISP006 + + /// + public string Name { get; private set; } + /// + public int AFKTimeout { get; private set; } + /// + public bool IsWidgetEnabled { get; private set; } + /// + public VerificationLevel VerificationLevel { get; private set; } + /// + public MfaLevel MfaLevel { get; private set; } + /// + public DefaultMessageNotifications DefaultMessageNotifications { get; private set; } + /// + public ExplicitContentFilterLevel ExplicitContentFilter { get; private set; } + /// + /// Gets the number of members. + /// + /// + /// This property retrieves the number of members returned by Discord. + /// + /// + /// Due to how this property is returned by Discord instead of relying on the WebSocket cache, the + /// number here is the most accurate in terms of counting the number of users within this guild. + /// + /// + /// Use this instead of enumerating the count of the + /// collection, as you may see discrepancy + /// between that and this property. + /// + /// + /// + public int MemberCount { get; internal set; } + /// Gets the number of members downloaded to the local guild cache. + public int DownloadedMemberCount { get; private set; } + internal bool IsAvailable { get; private set; } + /// Indicates whether the client is connected to this guild. + public bool IsConnected { get; internal set; } + /// + public ulong? ApplicationId { get; internal set; } + + internal ulong? AFKChannelId { get; private set; } + internal ulong? WidgetChannelId { get; private set; } + internal ulong? SafetyAlertsChannelId { get; private set; } + internal ulong? SystemChannelId { get; private set; } + internal ulong? RulesChannelId { get; private set; } + internal ulong? PublicUpdatesChannelId { get; private set; } + /// + public ulong OwnerId { get; private set; } + /// Gets the user that owns this guild. + public SocketGuildUser Owner => GetUser(OwnerId); + /// + public string VoiceRegionId { get; private set; } + /// + public string IconId { get; private set; } + /// + public string SplashId { get; private set; } + /// + public string DiscoverySplashId { get; private set; } + /// + public PremiumTier PremiumTier { get; private set; } + /// + public string BannerId { get; private set; } + /// + public string VanityURLCode { get; private set; } + /// + public SystemChannelMessageDeny SystemChannelFlags { get; private set; } + /// + public string Description { get; private set; } + /// + public int PremiumSubscriptionCount { get; private set; } + /// + public string PreferredLocale { get; private set; } + /// + public int? MaxPresences { get; private set; } + /// + public int? MaxMembers { get; private set; } + /// + public int? MaxVideoChannelUsers { get; private set; } + /// + public int? MaxStageVideoChannelUsers { get; private set; } + /// + public NsfwLevel NsfwLevel { get; private set; } + /// + public CultureInfo PreferredCulture { get; private set; } + /// + public bool IsBoostProgressBarEnabled { get; private set; } + /// + public GuildFeatures Features { get; private set; } + /// + public GuildIncidentsData IncidentsData { get; private set; } + + /// + public GuildInventorySettings? InventorySettings { get; private set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public string IconUrl => CDN.GetGuildIconUrl(Id, IconId); + /// + public string SplashUrl => CDN.GetGuildSplashUrl(Id, SplashId); + /// + public string DiscoverySplashUrl => CDN.GetGuildDiscoverySplashUrl(Id, DiscoverySplashId); + /// + public string BannerUrl => CDN.GetGuildBannerUrl(Id, BannerId, ImageFormat.Auto); + /// Indicates whether the client has all the members downloaded to the local guild cache. + public bool HasAllMembers => MemberCount <= DownloadedMemberCount;// _downloaderPromise.Task.IsCompleted; + /// Indicates whether the guild cache is synced to this guild. + public bool IsSynced => _syncPromise.Task.IsCompleted; + public Task SyncPromise => _syncPromise.Task; + public Task DownloaderPromise => _downloaderPromise.Task; + /// + /// Gets the associated with this guild. + /// + public IAudioClient AudioClient => _audioClient; + /// + /// Gets the default channel in this guild. + /// + /// + /// This property retrieves the first viewable text channel for this guild. + /// + /// This channel does not guarantee the user can send message to it, as it only looks for the first viewable + /// text channel. + /// + /// + /// + /// A representing the first viewable channel that the user has access to. + /// + public SocketTextChannel DefaultChannel => TextChannels + .Where(c => CurrentUser.GetPermissions(c).ViewChannel && c is not IThreadChannel) + .OrderBy(c => c.Position) + .FirstOrDefault(); + /// + /// Gets the AFK voice channel in this guild. + /// + /// + /// A that the AFK users will be moved to after they have idled for too + /// long; if none is set. + /// + public SocketVoiceChannel AFKChannel + { + get + { + var id = AFKChannelId; + return id.HasValue ? GetVoiceChannel(id.Value) : null; + } + } + /// + public int MaxBitrate + => GuildHelper.GetMaxBitrate(PremiumTier); + /// + public ulong MaxUploadLimit + => GuildHelper.GetUploadLimit(PremiumTier); + /// + /// Gets the widget channel (i.e. the channel set in the guild's widget settings) in this guild. + /// + /// + /// A channel set within the server's widget settings; if none is set. + /// + public SocketGuildChannel WidgetChannel + { + get + { + var id = WidgetChannelId; + return id.HasValue ? GetChannel(id.Value) : null; + } + } + + /// + /// Gets the safety alerts channel in this guild. + /// + /// + /// The channel set for receiving safety alerts channel; if none is set. + /// + public SocketGuildChannel SafetyAlertsChannel + { + get + { + var id = SafetyAlertsChannelId; + return id.HasValue ? GetChannel(id.Value) : null; + } + } + + /// + /// Gets the system channel where randomized welcome messages are sent in this guild. + /// + /// + /// A text channel where randomized welcome messages will be sent to; if none is set. + /// + public SocketTextChannel SystemChannel + { + get + { + var id = SystemChannelId; + return id.HasValue ? GetTextChannel(id.Value) : null; + } + } + /// + /// Gets the channel with the guild rules. + /// + /// + /// A text channel with the guild rules; if none is set. + /// + public SocketTextChannel RulesChannel + { + get + { + var id = RulesChannelId; + return id.HasValue ? GetTextChannel(id.Value) : null; + } + } + /// + /// Gets the channel where admins and moderators of Community guilds receive + /// notices from Discord. + /// + /// + /// A text channel where admins and moderators of Community guilds receive + /// notices from Discord; if none is set. + /// + public SocketTextChannel PublicUpdatesChannel + { + get + { + var id = PublicUpdatesChannelId; + return id.HasValue ? GetTextChannel(id.Value) : null; + } + } + /// + /// Gets a collection of all text channels in this guild. + /// + /// + /// A read-only collection of message channels found within this guild. + /// + public IReadOnlyCollection TextChannels + => Channels.OfType().ToImmutableArray(); + /// + /// Gets a collection of all voice channels in this guild. + /// + /// + /// A read-only collection of voice channels found within this guild. + /// + public IReadOnlyCollection VoiceChannels + => Channels.OfType().ToImmutableArray(); + /// + /// Gets a collection of all stage channels in this guild. + /// + /// + /// A read-only collection of stage channels found within this guild. + /// + public IReadOnlyCollection StageChannels + => Channels.OfType().ToImmutableArray(); + /// + /// Gets a collection of all category channels in this guild. + /// + /// + /// A read-only collection of category channels found within this guild. + /// + public IReadOnlyCollection CategoryChannels + => Channels.OfType().ToImmutableArray(); + /// + /// Gets a collection of all thread channels in this guild. + /// + /// + /// A read-only collection of thread channels found within this guild. + /// + public IReadOnlyCollection ThreadChannels + => Channels.OfType().ToImmutableArray(); + + /// + /// Gets a collection of all forum channels in this guild. + /// + /// + /// A read-only collection of forum channels found within this guild. + /// + public IReadOnlyCollection ForumChannels + => Channels.OfType().ToImmutableArray(); + + /// + /// Gets a collection of all media channels in this guild. + /// + /// + /// A read-only collection of forum channels found within this guild. + /// + public IReadOnlyCollection MediaChannels + => Channels.OfType().ToImmutableArray(); + + /// + /// Gets the current logged-in user. + /// + public SocketGuildUser CurrentUser => _members.TryGetValue(Discord.CurrentUser.Id, out SocketGuildUser member) ? member : null; + /// + /// Gets the built-in role containing all users in this guild. + /// + /// + /// A role object that represents an @everyone role in this guild. + /// + public SocketRole EveryoneRole => GetRole(Id); + /// + /// Gets a collection of all channels in this guild. + /// + /// + /// A read-only collection of generic channels found within this guild. + /// + public IReadOnlyCollection Channels + { + get + { + var channels = _channels; + var state = Discord.State; + return channels.Select(x => x.Value).Where(x => x != null).ToReadOnlyCollection(channels); + } + } + /// + public IReadOnlyCollection Emotes => _emotes; + /// + /// Gets a collection of all custom stickers for this guild. + /// + public IReadOnlyCollection Stickers + => _stickers.Select(x => x.Value).ToImmutableArray(); + /// + /// Gets a collection of users in this guild. + /// + /// + /// This property retrieves all users found within this guild. + /// + /// + /// This property may not always return all the members for large guilds (i.e. guilds containing + /// 100+ users). If you are simply looking to get the number of users present in this guild, + /// consider using instead. + /// + /// + /// Otherwise, you may need to enable to fetch + /// the full user list upon startup, or use to manually download + /// the users. + /// + /// + /// + /// + /// A collection of guild users found within this guild. + /// + public IReadOnlyCollection Users => _members.ToReadOnlyCollection(); + /// + /// Gets a collection of all roles in this guild. + /// + /// + /// A read-only collection of roles found within this guild. + /// + public IReadOnlyCollection Roles => _roles.ToReadOnlyCollection(); + + /// + /// Gets a collection of all events within this guild. + /// + /// + /// This field is based off of caching alone, since there is no events returned on the guild model. + /// + /// + /// A read-only collection of guild events found within this guild. + /// + public IReadOnlyCollection Events => _events.ToReadOnlyCollection(); + + internal SocketGuild(DiscordSocketClient client, ulong id) + : base(client, id) + { + _audioLock = new SemaphoreSlim(1, 1); + _emotes = ImmutableArray.Create(); + _automodRules = new ConcurrentDictionary(); + _auditLogs = new AuditLogCache(client); + } + internal static SocketGuild Create(DiscordSocketClient discord, ClientState state, ExtendedModel model) + { + var entity = new SocketGuild(discord, model.Id); + entity.Update(state, model); + return entity; + } + internal void Update(ClientState state, ExtendedModel model) + { + IsAvailable = !(model.Unavailable ?? false); + if (!IsAvailable) + { + if (_events == null) + _events = new ConcurrentDictionary(); + if (_channels == null) + _channels = new ConcurrentDictionary(); + if (_members == null) + _members = new ConcurrentDictionary(); + if (_roles == null) + _roles = new ConcurrentDictionary(); + /*if (Emojis == null) + _emojis = ImmutableArray.Create(); + if (Features == null) + _features = ImmutableArray.Create();*/ + _syncPromise = new TaskCompletionSource(); + _downloaderPromise = new TaskCompletionSource(); + return; + } + + Update(state, model as Model); + + var channels = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.Channels.Length * 1.05)); + { + for (int i = 0; i < model.Channels.Length; i++) + { + var channel = SocketGuildChannel.Create(this, state, model.Channels[i]); + state.AddChannel(channel); + channels.TryAdd(channel.Id, channel); + } + + for (int i = 0; i < model.Threads.Length; i++) + { + var threadChannel = SocketThreadChannel.Create(this, state, model.Threads[i]); + state.AddChannel(threadChannel); + channels.TryAdd(threadChannel.Id, threadChannel); + } + } + + _channels = channels; + + var members = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.Members.Length * 1.05)); + { + for (int i = 0; i < model.Members.Length; i++) + { + var member = SocketGuildUser.Create(this, state, model.Members[i]); + if (members.TryAdd(member.Id, member)) + member.GlobalUser.AddRef(); + } + DownloadedMemberCount = members.Count; + + for (int i = 0; i < model.Presences.Length; i++) + { + if (members.TryGetValue(model.Presences[i].User.Id, out SocketGuildUser member)) + member.Update(state, model.Presences[i], true); + } + } + _members = members; + MemberCount = model.MemberCount; + + var voiceStates = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.VoiceStates.Length * 1.05)); + { + for (int i = 0; i < model.VoiceStates.Length; i++) + { + SocketVoiceChannel channel = null; + if (model.VoiceStates[i].ChannelId.HasValue) + channel = state.GetChannel(model.VoiceStates[i].ChannelId.Value) as SocketVoiceChannel; + var voiceState = SocketVoiceState.Create(channel, model.VoiceStates[i]); + voiceStates.TryAdd(model.VoiceStates[i].UserId, voiceState); + } + } + _voiceStates = voiceStates; + + var events = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.GuildScheduledEvents.Length * 1.05)); + { + for (int i = 0; i < model.GuildScheduledEvents.Length; i++) + { + var guildEvent = SocketGuildEvent.Create(Discord, this, model.GuildScheduledEvents[i]); + events.TryAdd(guildEvent.Id, guildEvent); + } + } + _events = events; + + + _syncPromise = new TaskCompletionSource(); + _downloaderPromise = new TaskCompletionSource(); + var _ = _syncPromise.TrySetResultAsync(true); + /*if (!model.Large) + _ = _downloaderPromise.TrySetResultAsync(true);*/ + } + internal void Update(ClientState state, Model model) + { + AFKChannelId = model.AFKChannelId; + if (model.WidgetChannelId.IsSpecified) + WidgetChannelId = model.WidgetChannelId.Value; + if (model.SafetyAlertsChannelId.IsSpecified) + SafetyAlertsChannelId = model.SafetyAlertsChannelId.Value; + SystemChannelId = model.SystemChannelId; + RulesChannelId = model.RulesChannelId; + PublicUpdatesChannelId = model.PublicUpdatesChannelId; + AFKTimeout = model.AFKTimeout; + if (model.WidgetEnabled.IsSpecified) + IsWidgetEnabled = model.WidgetEnabled.Value; + IconId = model.Icon; + Name = model.Name; + OwnerId = model.OwnerId; + VoiceRegionId = model.Region; + SplashId = model.Splash; + DiscoverySplashId = model.DiscoverySplash; + VerificationLevel = model.VerificationLevel; + MfaLevel = model.MfaLevel; + DefaultMessageNotifications = model.DefaultMessageNotifications; + ExplicitContentFilter = model.ExplicitContentFilter; + ApplicationId = model.ApplicationId; + PremiumTier = model.PremiumTier; + VanityURLCode = model.VanityURLCode; + BannerId = model.Banner; + SystemChannelFlags = model.SystemChannelFlags; + Description = model.Description; + PremiumSubscriptionCount = model.PremiumSubscriptionCount.GetValueOrDefault(); + NsfwLevel = model.NsfwLevel; + if (model.MaxPresences.IsSpecified) + MaxPresences = model.MaxPresences.Value ?? 25000; + if (model.MaxMembers.IsSpecified) + MaxMembers = model.MaxMembers.Value; + if (model.MaxVideoChannelUsers.IsSpecified) + MaxVideoChannelUsers = model.MaxVideoChannelUsers.Value; + if (model.MaxStageVideoChannelUsers.IsSpecified) + MaxStageVideoChannelUsers = model.MaxStageVideoChannelUsers.Value; + PreferredLocale = model.PreferredLocale; + PreferredCulture = PreferredLocale == null ? null : new CultureInfo(PreferredLocale); + if (model.IsBoostProgressBarEnabled.IsSpecified) + IsBoostProgressBarEnabled = model.IsBoostProgressBarEnabled.Value; + if (model.InventorySettings.IsSpecified) + InventorySettings = model.InventorySettings.Value is null ? null : new(model.InventorySettings.Value.IsEmojiPackCollectible.GetValueOrDefault(false)); + + IncidentsData = model.IncidentsData is not null + ? new GuildIncidentsData { DmsDisabledUntil = model.IncidentsData.DmsDisabledUntil, InvitesDisabledUntil = model.IncidentsData.InvitesDisabledUntil } + : new GuildIncidentsData(); + if (model.Emojis != null) + { + var emojis = ImmutableArray.CreateBuilder(model.Emojis.Length); + for (int i = 0; i < model.Emojis.Length; i++) + emojis.Add(model.Emojis[i].ToEntity()); + _emotes = emojis.ToImmutable(); + } + else + _emotes = ImmutableArray.Create(); + + Features = model.Features; + + var roles = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.Roles.Length * 1.05)); + for (int i = 0; i < model.Roles.Length; i++) + { + var role = SocketRole.Create(this, state, model.Roles[i]); + roles.TryAdd(role.Id, role); + } + _roles = roles; + + if (model.Stickers != null) + { + var stickers = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.Stickers.Length * 1.05)); + for (int i = 0; i < model.Stickers.Length; i++) + { + var sticker = model.Stickers[i]; + if (sticker.User.IsSpecified) + AddOrUpdateUser(sticker.User.Value); + + var entity = SocketCustomSticker.Create(Discord, sticker, this, sticker.User.IsSpecified ? sticker.User.Value.Id : null); + + stickers.TryAdd(sticker.Id, entity); + } + + _stickers = stickers; + } + else + _stickers = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, 7); + } + /*internal void Update(ClientState state, GuildSyncModel model) //TODO remove? userbot related + { + var members = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.Members.Length * 1.05)); + { + for (int i = 0; i < model.Members.Length; i++) + { + var member = SocketGuildUser.Create(this, state, model.Members[i]); + members.TryAdd(member.Id, member); + } + DownloadedMemberCount = members.Count; + + for (int i = 0; i < model.Presences.Length; i++) + { + if (members.TryGetValue(model.Presences[i].User.Id, out SocketGuildUser member)) + member.Update(state, model.Presences[i], true); + } + } + _members = members; + + var _ = _syncPromise.TrySetResultAsync(true); + //if (!model.Large) + // _ = _downloaderPromise.TrySetResultAsync(true); + }*/ + + internal void Update(ClientState state, EmojiUpdateModel model) + { + var emotes = ImmutableArray.CreateBuilder(model.Emojis.Length); + for (int i = 0; i < model.Emojis.Length; i++) + emotes.Add(model.Emojis[i].ToEntity()); + _emotes = emotes.ToImmutable(); + } + #endregion + + #region General + /// + public Task DeleteAsync(RequestOptions options = null) + => GuildHelper.DeleteAsync(this, Discord, options); + + /// + /// is . + public Task ModifyAsync(Action func, RequestOptions options = null) + => GuildHelper.ModifyAsync(this, Discord, func, options); + + /// + /// is . + public Task ModifyWidgetAsync(Action func, RequestOptions options = null) + => GuildHelper.ModifyWidgetAsync(this, Discord, func, options); + /// + public Task ReorderChannelsAsync(IEnumerable args, RequestOptions options = null) + => GuildHelper.ReorderChannelsAsync(this, Discord, args, options); + /// + public Task ReorderRolesAsync(IEnumerable args, RequestOptions options = null) + => GuildHelper.ReorderRolesAsync(this, Discord, args, options); + + /// + public Task LeaveAsync(RequestOptions options = null) + => GuildHelper.LeaveAsync(this, Discord, options); + + /// + public Task ModifyIncidentActionsAsync(Action props, RequestOptions options = null) + => GuildHelper.ModifyGuildIncidentActionsAsync(this, Discord, props, options); + + #endregion + + #region Bans + + /// + public IAsyncEnumerable> GetBansAsync(int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null) + => GuildHelper.GetBansAsync(this, Discord, null, Direction.Before, limit, options); + + /// + public IAsyncEnumerable> GetBansAsync(ulong fromUserId, Direction dir, int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null) + => GuildHelper.GetBansAsync(this, Discord, fromUserId, dir, limit, options); + + /// + public IAsyncEnumerable> GetBansAsync(IUser fromUser, Direction dir, int limit = DiscordConfig.MaxBansPerBatch, RequestOptions options = null) + => GuildHelper.GetBansAsync(this, Discord, fromUser.Id, dir, limit, options); + + /// + /// Gets a ban object for a banned user. + /// + /// The banned user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a ban object, which + /// contains the user information and the reason for the ban; if the ban entry cannot be found. + /// + public Task GetBanAsync(IUser user, RequestOptions options = null) + => GuildHelper.GetBanAsync(this, Discord, user.Id, options); + /// + /// Gets a ban object for a banned user. + /// + /// The snowflake identifier for the banned user. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a ban object, which + /// contains the user information and the reason for the ban; if the ban entry cannot be found. + /// + public Task GetBanAsync(ulong userId, RequestOptions options = null) + => GuildHelper.GetBanAsync(this, Discord, userId, options); + + /// + public Task AddBanAsync(IUser user, int pruneDays = 0, string reason = null, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, user.Id, pruneDays, reason, options); + /// + public Task AddBanAsync(ulong userId, int pruneDays = 0, string reason = null, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, userId, pruneDays, reason, options); + + /// + public Task BanUserAsync(IUser user, uint pruneSeconds = 0, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, user.Id, pruneSeconds, options); + /// + public Task BanUserAsync(ulong userId, uint pruneSeconds = 0, RequestOptions options = null) + => GuildHelper.AddBanAsync(this, Discord, userId, pruneSeconds, options); + + /// + public Task RemoveBanAsync(IUser user, RequestOptions options = null) + => GuildHelper.RemoveBanAsync(this, Discord, user.Id, options); + /// + public Task RemoveBanAsync(ulong userId, RequestOptions options = null) + => GuildHelper.RemoveBanAsync(this, Discord, userId, options); + + /// + public Task BulkBanAsync(IEnumerable userIds, int? deleteMessageSeconds = null, RequestOptions options = null) + => GuildHelper.BulkBanAsync(this, Discord, userIds.ToArray(), deleteMessageSeconds, options); + #endregion + + #region Channels + /// + /// Gets a channel in this guild. + /// + /// The snowflake identifier for the channel. + /// + /// A generic channel associated with the specified ; if none is found. + /// + public SocketGuildChannel GetChannel(ulong id) + { + var channel = Discord.State.GetChannel(id) as SocketGuildChannel; + if (channel?.Guild.Id == Id) + return channel; + return null; + } + /// + /// Gets a text channel in this guild. + /// + /// The snowflake identifier for the text channel. + /// + /// A text channel associated with the specified ; if none is found. + /// + public SocketTextChannel GetTextChannel(ulong id) + => GetChannel(id) as SocketTextChannel; + /// + /// Gets a thread in this guild. + /// + /// The snowflake identifier for the thread. + /// + /// A thread channel associated with the specified ; if none is found. + /// + public SocketThreadChannel GetThreadChannel(ulong id) + => GetChannel(id) as SocketThreadChannel; + /// + /// Gets a forum channel in this guild. + /// + /// The snowflake identifier for the forum channel. + /// + /// A forum channel associated with the specified ; if none is found. + /// + public SocketForumChannel GetForumChannel(ulong id) + => GetChannel(id) as SocketForumChannel; + /// + /// Gets a voice channel in this guild. + /// + /// The snowflake identifier for the voice channel. + /// + /// A voice channel associated with the specified ; if none is found. + /// + public SocketVoiceChannel GetVoiceChannel(ulong id) + => GetChannel(id) as SocketVoiceChannel; + /// + /// Gets a stage channel in this guild. + /// + /// The snowflake identifier for the stage channel. + /// + /// A stage channel associated with the specified ; if none is found. + /// + public SocketStageChannel GetStageChannel(ulong id) + => GetChannel(id) as SocketStageChannel; + /// + /// Gets a category channel in this guild. + /// + /// The snowflake identifier for the category channel. + /// + /// A category channel associated with the specified ; if none is found. + /// + public SocketCategoryChannel GetCategoryChannel(ulong id) + => GetChannel(id) as SocketCategoryChannel; + + /// + /// Gets a media channel in this guild. + /// + /// The snowflake identifier for the stage channel. + /// + /// A stage channel associated with the specified ; if none is found. + /// + public SocketMediaChannel GetMediaChannel(ulong id) + => GetChannel(id) as SocketMediaChannel; + + /// + /// Creates a new text channel in this guild. + /// + /// + /// The following example creates a new text channel under an existing category named Wumpus with a set topic. + /// + /// var categories = await guild.GetCategoriesAsync(); + /// var targetCategory = categories.FirstOrDefault(x => x.Name == "wumpus"); + /// if (targetCategory == null) return; + /// await Context.Guild.CreateTextChannelAsync(name, x => + /// { + /// x.CategoryId = targetCategory.Id; + /// x.Topic = $"This channel was created at {DateTimeOffset.UtcNow} by {user}."; + /// }); + /// + /// + /// The new name for the text channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// text channel. + /// + public Task CreateTextChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateTextChannelAsync(this, Discord, name, options, func); + + /// + public Task CreateNewsChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateNewsChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a new voice channel in this guild. + /// + /// The new name for the voice channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// is . + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// voice channel. + /// + public Task CreateVoiceChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateVoiceChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a new stage channel in this guild. + /// + /// The new name for the stage channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// stage channel. + /// + public Task CreateStageChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateStageChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a new channel category in this guild. + /// + /// The new name for the category. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// is . + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// category channel. + /// + public Task CreateCategoryChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateCategoryChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a new forum channel in this guild. + /// + /// The new name for the forum. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// is . + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// forum channel. + /// + public Task CreateForumChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateForumChannelAsync(this, Discord, name, options, func); + + /// + /// Creates a new media channel in this guild. + /// + /// The new name for the media channel. + /// The delegate containing the properties to be applied to the channel upon its creation. + /// The options to be used when sending the request. + /// is . + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// media channel. + /// + public Task CreateMediaChannelAsync(string name, Action func = null, RequestOptions options = null) + => GuildHelper.CreateMediaChannelAsync(this, Discord, name, options, func); + + internal SocketGuildChannel AddChannel(ClientState state, ChannelModel model) + { + var channel = SocketGuildChannel.Create(this, state, model); + _channels.TryAdd(model.Id, channel); + state.AddChannel(channel); + return channel; + } + + internal SocketGuildChannel AddOrUpdateChannel(ClientState state, ChannelModel model) + { + if (_channels.TryGetValue(model.Id, out SocketGuildChannel channel)) + channel.Update(Discord.State, model); + else + { + channel = SocketGuildChannel.Create(this, Discord.State, model); + _channels[channel.Id] = channel; + state.AddChannel(channel); + } + return channel; + } + + internal SocketGuildChannel RemoveChannel(ClientState state, ulong id) + { + if (_channels.TryRemove(id, out var _)) + return state.RemoveChannel(id) as SocketGuildChannel; + return null; + } + internal void PurgeChannelCache(ClientState state) + { + foreach (var channelId in _channels) + state.RemoveChannel(channelId.Key); + + _channels.Clear(); + } + #endregion + + #region Voice Regions + /// + /// Gets a collection of all the voice regions this guild can access. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// voice regions the guild can access. + /// + public Task> GetVoiceRegionsAsync(RequestOptions options = null) + => GuildHelper.GetVoiceRegionsAsync(this, Discord, options); + #endregion + + #region Integrations + public Task> GetIntegrationsAsync(RequestOptions options = null) + => GuildHelper.GetIntegrationsAsync(this, Discord, options); + public Task DeleteIntegrationAsync(ulong id, RequestOptions options = null) + => GuildHelper.DeleteIntegrationAsync(this, Discord, id, options); + #endregion + + #region Interactions + /// + /// Deletes all application commands in the current guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous delete operation. + /// + public Task DeleteApplicationCommandsAsync(RequestOptions options = null) + => InteractionHelper.DeleteAllGuildCommandsAsync(Discord, Id, options); + + /// + /// Gets a collection of slash commands created by the current user in this guild. + /// + /// Whether to include full localization dictionaries in the returned objects, instead of the name localized and description localized fields. + /// The target locale of the localized name and description fields. Sets X-Discord-Locale header, which takes precedence over Accept-Language. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// slash commands created by the current user. + /// + public async Task> GetApplicationCommandsAsync(bool withLocalizations = false, string locale = null, RequestOptions options = null) + { + var commands = (await Discord.ApiClient.GetGuildApplicationCommandsAsync(Id, withLocalizations, locale, options)) + .Select(x => SocketApplicationCommand.Create(Discord, x, Id)); + + foreach (var command in commands) + { + Discord.State.AddCommand(command); + } + + return commands.ToImmutableArray(); + } + + /// + /// Gets an application command within this guild with the specified id. + /// + /// The id of the application command to get. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A ValueTask that represents the asynchronous get operation. The task result contains a + /// if found, otherwise . + /// + public async ValueTask GetApplicationCommandAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + var command = Discord.State.GetCommand(id); + + if (command != null) + return command; + + if (mode == CacheMode.CacheOnly) + return null; + + var model = await Discord.ApiClient.GetGlobalApplicationCommandAsync(id, options); + + if (model == null) + return null; + + command = SocketApplicationCommand.Create(Discord, model, Id); + + Discord.State.AddCommand(command); + + return command; + } + + /// + /// Creates an application command within this guild. + /// + /// The properties to use when creating the command. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the command that was created. + /// + public async Task CreateApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options = null) + { + var model = await InteractionHelper.CreateGuildCommandAsync(Discord, Id, properties, options); + + var entity = Discord.State.GetOrAddCommand(model.Id, (id) => SocketApplicationCommand.Create(Discord, model)); + + entity.Update(model); + + return entity; + } + + /// + /// Overwrites the application commands within this guild. + /// + /// A collection of properties to use when creating the commands. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains a collection of commands that was created. + /// + public async Task> BulkOverwriteApplicationCommandAsync(ApplicationCommandProperties[] properties, + RequestOptions options = null) + { + var models = await InteractionHelper.BulkOverwriteGuildCommandsAsync(Discord, Id, properties, options); + + var entities = models.Select(x => SocketApplicationCommand.Create(Discord, x)); + + Discord.State.PurgeCommands(x => !x.IsGlobalCommand && x.Guild.Id == Id); + + foreach (var entity in entities) + { + Discord.State.AddCommand(entity); + } + + return entities.ToImmutableArray(); + } + #endregion + + #region Invites + /// + /// Gets a collection of all invites in this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection of + /// invite metadata, each representing information for an invite found within this guild. + /// + public Task> GetInvitesAsync(RequestOptions options = null) + => GuildHelper.GetInvitesAsync(this, Discord, options); + /// + /// Gets the vanity invite URL of this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the partial metadata of + /// the vanity invite found within this guild; if none is found. + /// + public Task GetVanityInviteAsync(RequestOptions options = null) + => GuildHelper.GetVanityInviteAsync(this, Discord, options); + #endregion + + #region Roles + /// + /// Gets a role in this guild. + /// + /// The snowflake identifier for the role. + /// + /// A role that is associated with the specified ; if none is found. + /// + public SocketRole GetRole(ulong id) + { + if (_roles.TryGetValue(id, out SocketRole value)) + return value; + return null; + } + + /// + /// Creates a new role with the provided name. + /// + /// The new name for the role. + /// The guild permission that the role should possess. + /// The color of the role. + /// Whether the role is separated from others on the sidebar. + /// Whether the role can be mentioned. + /// The options to be used when sending the request. + /// The icon for the role. + /// The unicode emoji to be used as an icon for the role. + /// is . + /// + /// A task that represents the asynchronous creation operation. The task result contains the newly created + /// role. + /// + public Task CreateRoleAsync(string name, GuildPermissions? permissions = default(GuildPermissions?), Color? color = default(Color?), + bool isHoisted = false, bool isMentionable = false, RequestOptions options = null, Image? icon = null, Emoji emoji = null) + => GuildHelper.CreateRoleAsync(this, Discord, name, permissions, color, isHoisted, isMentionable, options, icon, emoji); + internal SocketRole AddRole(RoleModel model) + { + var role = SocketRole.Create(this, Discord.State, model); + _roles[model.Id] = role; + return role; + } + internal SocketRole RemoveRole(ulong id) + { + if (_roles.TryRemove(id, out SocketRole role)) + return role; + return null; + } + + internal SocketRole AddOrUpdateRole(RoleModel model) + { + if (_roles.TryGetValue(model.Id, out SocketRole role)) + _roles[model.Id].Update(Discord.State, model); + else + role = AddRole(model); + + return role; + } + + internal SocketCustomSticker AddSticker(StickerModel model) + { + if (model.User.IsSpecified) + AddOrUpdateUser(model.User.Value); + + var sticker = SocketCustomSticker.Create(Discord, model, this, model.User.IsSpecified ? model.User.Value.Id : null); + _stickers[model.Id] = sticker; + return sticker; + } + + internal SocketCustomSticker AddOrUpdateSticker(StickerModel model) + { + if (_stickers.TryGetValue(model.Id, out SocketCustomSticker sticker)) + _stickers[model.Id].Update(model); + else + sticker = AddSticker(model); + + return sticker; + } + + internal SocketCustomSticker RemoveSticker(ulong id) + { + if (_stickers.TryRemove(id, out SocketCustomSticker sticker)) + return sticker; + return null; + } + #endregion + + #region Users + /// + public Task AddGuildUserAsync(ulong id, string accessToken, Action func = null, RequestOptions options = null) + => GuildHelper.AddGuildUserAsync(this, Discord, id, accessToken, func, options); + + /// + /// Gets a user from this guild. + /// + /// + /// This method retrieves a user found within this guild. + /// + /// This may return in the WebSocket implementation due to incomplete user collection in + /// large guilds. + /// + /// + /// The snowflake identifier of the user. + /// + /// A guild user associated with the specified ; if none is found. + /// + public SocketGuildUser GetUser(ulong id) + { + if (_members.TryGetValue(id, out SocketGuildUser member)) + return member; + return null; + } + /// + public Task PruneUsersAsync(int days = 30, bool simulate = false, RequestOptions options = null, IEnumerable includeRoleIds = null) + => GuildHelper.PruneUsersAsync(this, Discord, days, simulate, options, includeRoleIds); + + internal SocketGuildUser AddOrUpdateUser(UserModel model) + { + if (_members.TryGetValue(model.Id, out SocketGuildUser member)) + member.GlobalUser?.Update(Discord.State, model); + else + { + member = SocketGuildUser.Create(this, Discord.State, model); + member.GlobalUser.AddRef(); + _members[member.Id] = member; + DownloadedMemberCount++; + } + return member; + } + internal SocketGuildUser AddOrUpdateUser(MemberModel model) + { + if (_members.TryGetValue(model.User.Id, out SocketGuildUser member)) + member.Update(Discord.State, model); + else + { + member = SocketGuildUser.Create(this, Discord.State, model); + member.GlobalUser.AddRef(); + _members[member.Id] = member; + DownloadedMemberCount++; + } + return member; + } + internal SocketGuildUser AddOrUpdateUser(PresenceModel model) + { + if (_members.TryGetValue(model.User.Id, out SocketGuildUser member)) + member.Update(Discord.State, model, false); + else + { + member = SocketGuildUser.Create(this, Discord.State, model); + member.GlobalUser.AddRef(); + _members[member.Id] = member; + DownloadedMemberCount++; + } + return member; + } + internal SocketGuildUser RemoveUser(ulong id) + { + if (_members.TryRemove(id, out SocketGuildUser member)) + { + DownloadedMemberCount--; + member.GlobalUser.RemoveRef(Discord); + return member; + } + return null; + } + + /// + /// Purges this guild's user cache. + /// + public void PurgeUserCache() => PurgeUserCache(_ => true); + /// + /// Purges this guild's user cache. + /// + /// The predicate used to select which users to clear. + public void PurgeUserCache(Func predicate) + { + var membersToPurge = Users.Where(x => predicate.Invoke(x) && x?.Id != Discord.CurrentUser.Id); + var membersToKeep = Users.Where(x => !predicate.Invoke(x) || x?.Id == Discord.CurrentUser.Id); + + foreach (var member in membersToPurge) + if (_members.TryRemove(member.Id, out _)) + member.GlobalUser.RemoveRef(Discord); + + foreach (var member in membersToKeep) + _members.TryAdd(member.Id, member); + + _downloaderPromise = new TaskCompletionSource(); + DownloadedMemberCount = _members.Count; + } + + /// + /// Gets a collection of all users in this guild. + /// + /// + /// This method retrieves all users found within this guild through REST. + /// Users returned by this method are not cached. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users found within this guild. + /// + public IAsyncEnumerable> GetUsersAsync(RequestOptions options = null) + { + if (HasAllMembers) + return ImmutableArray.Create(Users).ToAsyncEnumerable>(); + return GuildHelper.GetUsersAsync(this, Discord, null, null, options); + } + + /// + public Task DownloadUsersAsync() + => Discord.DownloadUsersAsync(new[] { this }); + + internal void CompleteDownloadUsers() + { + _downloaderPromise.TrySetResultAsync(true); + } + + /// + /// Gets a collection of users in this guild that the name or nickname starts with the + /// provided at . + /// + /// + /// The can not be higher than . + /// + /// The partial name or nickname to search. + /// The maximum number of users to be gotten. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a collection of guild + /// users that the name or nickname starts with the provided at . + /// + public Task> SearchUsersAsync(string query, int limit = DiscordConfig.MaxUsersPerBatch, RequestOptions options = null) + => GuildHelper.SearchUsersAsync(this, Discord, query, limit, options); + #endregion + + #region Guild Events + + /// + /// Gets an event in this guild. + /// + /// The snowflake identifier for the event. + /// + /// An event that is associated with the specified ; if none is found. + /// + public SocketGuildEvent GetEvent(ulong id) + { + if (_events.TryGetValue(id, out SocketGuildEvent value)) + return value; + return null; + } + + internal SocketGuildEvent RemoveEvent(ulong id) + { + if (_events.TryRemove(id, out SocketGuildEvent value)) + return value; + return null; + } + + internal SocketGuildEvent AddOrUpdateEvent(EventModel model) + { + if (_events.TryGetValue(model.Id, out SocketGuildEvent value)) + value.Update(model); + else + { + value = SocketGuildEvent.Create(Discord, this, model); + _events[model.Id] = value; + } + return value; + } + + /// + /// Gets an event within this guild. + /// + /// The snowflake identifier for the event. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. + /// + public Task GetEventAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetGuildEventAsync(Discord, id, this, options); + + /// + /// Gets all active events within this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. + /// + public Task> GetEventsAsync(RequestOptions options = null) + => GuildHelper.GetGuildEventsAsync(Discord, this, options); + + /// + /// Creates an event within this guild. + /// + /// The name of the event. + /// The privacy level of the event. + /// The start time of the event. + /// The type of the event. + /// The description of the event. + /// The end time of the event. + /// + /// The channel id of the event. + /// + /// The event must have a type of or + /// in order to use this property. + /// + /// + /// The location of the event; links are supported + /// The optional banner image for the event. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous create operation. + /// + public Task CreateEventAsync( + string name, + DateTimeOffset startTime, + GuildScheduledEventType type, + GuildScheduledEventPrivacyLevel privacyLevel = GuildScheduledEventPrivacyLevel.Private, + string description = null, + DateTimeOffset? endTime = null, + ulong? channelId = null, + string location = null, + Image? coverImage = null, + RequestOptions options = null) + { + // requirements taken from https://discord.com/developers/docs/resources/guild-scheduled-event#guild-scheduled-event-permissions-requirements + switch (type) + { + case GuildScheduledEventType.Stage: + CurrentUser.GuildPermissions.Ensure(GuildPermission.ManageEvents | GuildPermission.ManageChannels | GuildPermission.MuteMembers | GuildPermission.MoveMembers); + break; + case GuildScheduledEventType.Voice: + CurrentUser.GuildPermissions.Ensure(GuildPermission.ManageEvents | GuildPermission.ViewChannel | GuildPermission.Connect); + break; + case GuildScheduledEventType.External: + CurrentUser.GuildPermissions.Ensure(GuildPermission.ManageEvents); + break; + } + + return GuildHelper.CreateGuildEventAsync(Discord, this, name, privacyLevel, startTime, type, description, endTime, channelId, location, coverImage, options); + } + + + #endregion + + #region Audit logs + /// + /// Gets the specified number of audit log entries for this guild. + /// + /// The number of audit log entries to fetch. + /// The options to be used when sending the request. + /// The audit log entry ID to filter entries before. + /// The type of actions to filter. + /// The user ID to filter entries for. + /// The audit log entry ID to filter entries after. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of the requested audit log entries. + /// + public IAsyncEnumerable> GetAuditLogsAsync(int limit, RequestOptions options = null, ulong? beforeId = null, ulong? userId = null, ActionType? actionType = null, ulong? afterId = null) + => GuildHelper.GetAuditLogsAsync(this, Discord, beforeId, limit, options, userId: userId, actionType: actionType, afterId: afterId); + + /// + /// Gets all cached audit log entries from this guild. + /// + public IReadOnlyCollection CachedAuditLogs => _auditLogs?.AuditLogs ?? ImmutableArray.Create(); + + /// + /// Gets cached audit log entry with the provided id. + /// + /// + /// Returns if no entry with provided id was found in cache. + /// + public SocketAuditLogEntry GetCachedAuditLog(ulong id) + => _auditLogs.Get(id); + + /// + /// Gets audit log entries with the specified type from cache. + /// + public IReadOnlyCollection GetCachedAuditLogs(int limit = DiscordConfig.MaxAuditLogEntriesPerBatch, ActionType? action = null, + ulong? fromEntryId = null, Direction direction = Direction.Before) + { + return _auditLogs.GetMany(fromEntryId, direction, limit, action); + } + + internal void AddAuditLog(SocketAuditLogEntry entry) + => _auditLogs.Add(entry); + + #endregion + + #region Webhooks + /// + /// Gets a webhook found within this guild. + /// + /// The identifier for the webhook. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the webhook with the + /// specified ; if none is found. + /// + public Task GetWebhookAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetWebhookAsync(this, Discord, id, options); + /// + /// Gets a collection of all webhook from this guild. + /// + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of webhooks found within the guild. + /// + public Task> GetWebhooksAsync(RequestOptions options = null) + => GuildHelper.GetWebhooksAsync(this, Discord, options); + #endregion + + #region Emotes + /// + public Task> GetEmotesAsync(RequestOptions options = null) + => GuildHelper.GetEmotesAsync(this, Discord, options); + /// + public Task GetEmoteAsync(ulong id, RequestOptions options = null) + => GuildHelper.GetEmoteAsync(this, Discord, id, options); + /// + public Task CreateEmoteAsync(string name, Image image, Optional> roles = default(Optional>), RequestOptions options = null) + => GuildHelper.CreateEmoteAsync(this, Discord, name, image, roles, options); + /// + /// is . + public Task ModifyEmoteAsync(GuildEmote emote, Action func, RequestOptions options = null) + => GuildHelper.ModifyEmoteAsync(this, Discord, emote.Id, func, options); + /// + public Task DeleteEmoteAsync(GuildEmote emote, RequestOptions options = null) + => GuildHelper.DeleteEmoteAsync(this, Discord, emote.Id, options); + + /// + /// Moves the user to the voice channel. + /// + /// The user to move. + /// the channel where the user gets moved to. + /// A task that represents the asynchronous operation for moving a user. + public Task MoveAsync(IGuildUser user, IVoiceChannel targetChannel) + => user.ModifyAsync(x => x.Channel = new Optional(targetChannel)); + + /// + /// Disconnects the user from its current voice channel + /// + /// The user to disconnect. + /// A task that represents the asynchronous operation for disconnecting a user. + Task IGuild.DisconnectAsync(IGuildUser user) + => user.ModifyAsync(x => x.Channel = null); + #endregion + + #region Stickers + /// + /// Gets a specific sticker within this guild. + /// + /// The id of the sticker to get. + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains the sticker found with the + /// specified ; if none is found. + /// + public async ValueTask GetStickerAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + { + var sticker = _stickers?.FirstOrDefault(x => x.Key == id); + + if (sticker?.Value != null) + return sticker?.Value; + + if (mode == CacheMode.CacheOnly) + return null; + + var model = await Discord.ApiClient.GetGuildStickerAsync(Id, id, options).ConfigureAwait(false); + + if (model == null) + return null; + + return AddOrUpdateSticker(model); + } + /// + /// Gets a specific sticker within this guild. + /// + /// The id of the sticker to get. + /// A sticker, if none is found then . + public SocketCustomSticker GetSticker(ulong id) + => GetStickerAsync(id, CacheMode.CacheOnly).GetAwaiter().GetResult(); + /// + /// Gets a collection of all stickers within this guild. + /// + /// The that determines whether the object should be fetched from cache. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous get operation. The task result contains a read-only collection + /// of stickers found within the guild. + /// + public async ValueTask> GetStickersAsync(CacheMode mode = CacheMode.AllowDownload, + RequestOptions options = null) + { + if (Stickers.Count > 0) + return Stickers; + + if (mode == CacheMode.CacheOnly) + return ImmutableArray.Create(); + + var models = await Discord.ApiClient.ListGuildStickersAsync(Id, options).ConfigureAwait(false); + + List stickers = new(); + + foreach (var model in models) + { + stickers.Add(AddOrUpdateSticker(model)); + } + + return stickers; + } + /// + /// Creates a new sticker in this guild. + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The image of the new emote. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + public async Task CreateStickerAsync(string name, Image image, IEnumerable tags, string description = null, + RequestOptions options = null) + { + var model = await GuildHelper.CreateStickerAsync(Discord, this, name, image, tags, description, options).ConfigureAwait(false); + + return AddOrUpdateSticker(model); + } + /// + /// Creates a new sticker in this guild + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The path of the file to upload. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + public async Task CreateStickerAsync(string name, string path, IEnumerable tags, string description = null, + RequestOptions options = null) + { + using var fs = File.OpenRead(path); + return await CreateStickerAsync(name, fs, Path.GetFileName(fs.Name), tags, description, options); + } + /// + /// Creates a new sticker in this guild + /// + /// The name of the sticker. + /// The description of the sticker. + /// The tags of the sticker. + /// The stream containing the file data. + /// The name of the file with the extension, ex: image.png. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous creation operation. The task result contains the created sticker. + /// + public async Task CreateStickerAsync(string name, Stream stream, string filename, IEnumerable tags, string description = null, + RequestOptions options = null) + { + var model = await GuildHelper.CreateStickerAsync(Discord, this, name, stream, filename, tags, description, options).ConfigureAwait(false); + + return AddOrUpdateSticker(model); + } + /// + /// Deletes a sticker within this guild. + /// + /// The sticker to delete. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous removal operation. + /// + public Task DeleteStickerAsync(SocketCustomSticker sticker, RequestOptions options = null) + => sticker.DeleteAsync(options); + #endregion + + #region Voice States + internal async Task AddOrUpdateVoiceStateAsync(ClientState state, VoiceStateModel model) + { + var voiceChannel = state.GetChannel(model.ChannelId.Value) as SocketVoiceChannel; + var before = GetVoiceState(model.UserId) ?? SocketVoiceState.Default; + var after = SocketVoiceState.Create(voiceChannel, model); + _voiceStates[model.UserId] = after; + + if (_audioClient != null && before.VoiceChannel?.Id != after.VoiceChannel?.Id) + { + if (model.UserId == CurrentUser.Id) + { + if (after.VoiceChannel != null && _audioClient.ChannelId != after.VoiceChannel?.Id) + { + _audioClient.ChannelId = after.VoiceChannel.Id; + await _audioClient.StopAsync(Audio.AudioClient.StopReason.Moved); + } + } + else + { + await _audioClient.RemoveInputStreamAsync(model.UserId).ConfigureAwait(false); //User changed channels, end their stream + if (CurrentUser.VoiceChannel != null && after.VoiceChannel?.Id == CurrentUser.VoiceChannel?.Id) + await _audioClient.CreateInputStreamAsync(model.UserId).ConfigureAwait(false); + } + } + + return after; + } + internal SocketVoiceState? GetVoiceState(ulong id) + { + if (_voiceStates.TryGetValue(id, out SocketVoiceState voiceState)) + return voiceState; + return null; + } + internal async Task RemoveVoiceStateAsync(ulong id) + { + if (_voiceStates.TryRemove(id, out SocketVoiceState voiceState)) + { + if (_audioClient != null) + { + await _audioClient.RemoveInputStreamAsync(id).ConfigureAwait(false); //User changed channels, end their stream + + if (id == CurrentUser.Id) + await _audioClient.StopAsync(Audio.AudioClient.StopReason.Disconnected); + } + + return voiceState; + } + return null; + } + #endregion + + #region Audio + internal AudioInStream GetAudioStream(ulong userId) + { + return _audioClient?.GetInputStream(userId); + } + internal async Task ConnectAudioAsync(ulong channelId, bool selfDeaf, bool selfMute, bool external, bool disconnect = true) + { + TaskCompletionSource promise; + + await _audioLock.WaitAsync().ConfigureAwait(false); + try + { + if (disconnect || !external) + await DisconnectAudioInternalAsync().ConfigureAwait(false); + promise = new TaskCompletionSource(); + _audioConnectPromise = promise; + + _voiceStateUpdateParams = new VoiceStateUpdateParams + { + GuildId = Id, + ChannelId = channelId, + SelfDeaf = selfDeaf, + SelfMute = selfMute + }; + + if (external) + { + _ = promise.TrySetResultAsync(null); + await Discord.ApiClient.SendVoiceStateUpdateAsync(_voiceStateUpdateParams).ConfigureAwait(false); + return null; + } + + if (_audioClient == null) + { + var audioClient = new AudioClient(this, Discord.GetAudioId(), channelId); + audioClient.Disconnected += async ex => + { + if (promise.Task.IsCompleted && audioClient.IsFinished) + { + try + { audioClient.Dispose(); } + catch { } + _audioClient = null; + if (ex != null) + await promise.TrySetExceptionAsync(ex); + else + await promise.TrySetCanceledAsync(); + } + }; + audioClient.Connected += () => + { + _ = promise.TrySetResultAsync(_audioClient); + return Task.Delay(0); + }; + + _audioClient = audioClient; + } + + await Discord.ApiClient.SendVoiceStateUpdateAsync(_voiceStateUpdateParams).ConfigureAwait(false); + } + catch + { + await DisconnectAudioInternalAsync().ConfigureAwait(false); + throw; + } + finally + { + _audioLock.Release(); + } + + try + { + var timeoutTask = Task.Delay(15000); + if (await Task.WhenAny(promise.Task, timeoutTask).ConfigureAwait(false) == timeoutTask) + throw new TimeoutException(); + return await promise.Task.ConfigureAwait(false); + } + catch + { + await DisconnectAudioAsync().ConfigureAwait(false); + throw; + } + } + + internal async Task DisconnectAudioAsync() + { + await _audioLock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectAudioInternalAsync().ConfigureAwait(false); + } + finally + { + _audioLock.Release(); + } + } + private async Task DisconnectAudioInternalAsync() + { + _audioConnectPromise?.TrySetCanceledAsync(); //Cancel any previous audio connection + _audioConnectPromise = null; + if (_audioClient != null) + await _audioClient.StopAsync().ConfigureAwait(false); + await Discord.ApiClient.SendVoiceStateUpdateAsync(Id, null, false, false).ConfigureAwait(false); + _audioClient?.Dispose(); + _audioClient = null; + _voiceStateUpdateParams = null; + } + + internal async Task ModifyAudioAsync(ulong channelId, Action func, RequestOptions options) + { + await _audioLock.WaitAsync().ConfigureAwait(false); + try + { + await ModifyAudioInternalAsync(channelId, func, options).ConfigureAwait(false); + } + finally + { + _audioLock.Release(); + } + } + + private Task ModifyAudioInternalAsync(ulong channelId, Action func, RequestOptions options) + { + if (_voiceStateUpdateParams == null || _voiceStateUpdateParams.ChannelId != channelId) + throw new InvalidOperationException("Cannot modify properties of not connected audio channel"); + + var props = new AudioChannelProperties(); + func(props); + + if (props.SelfDeaf.IsSpecified) + _voiceStateUpdateParams.SelfDeaf = props.SelfDeaf.Value; + if (props.SelfMute.IsSpecified) + _voiceStateUpdateParams.SelfMute = props.SelfMute.Value; + + return Discord.ApiClient.SendVoiceStateUpdateAsync(_voiceStateUpdateParams, options); + } + + internal async Task FinishConnectAudio(string url, string token) + { + //TODO: Mem Leak: Disconnected/Connected handlers aren't cleaned up + var voiceState = GetVoiceState(Discord.CurrentUser.Id).Value; + + await _audioLock.WaitAsync().ConfigureAwait(false); + try + { + if (_audioClient != null) + { + await RepopulateAudioStreamsAsync().ConfigureAwait(false); + + if (_audioClient.ConnectionState != ConnectionState.Disconnected) + { + try + { + await _audioClient.WaitForDisconnectAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + catch (TimeoutException) + { + await Discord.LogManager.WarningAsync("Failed to wait for disconnect audio client in time", null).ConfigureAwait(false); + } + } + + await Task.Delay(TimeSpan.FromMilliseconds(5)).ConfigureAwait(false); + + await _audioClient.StartAsync(url, Discord.CurrentUser.Id, voiceState.VoiceSessionId, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + await DisconnectAudioInternalAsync().ConfigureAwait(false); + } + catch (Exception e) + { + await _audioConnectPromise.SetExceptionAsync(e).ConfigureAwait(false); + await DisconnectAudioInternalAsync().ConfigureAwait(false); + } + finally + { + _audioLock.Release(); + } + } + + internal async Task RepopulateAudioStreamsAsync() + { + await _audioClient.ClearInputStreamsAsync().ConfigureAwait(false); //We changed channels, end all current streams + if (CurrentUser.VoiceChannel != null) + { + foreach (var pair in _voiceStates) + { + if (pair.Value.VoiceChannel?.Id == CurrentUser.VoiceChannel?.Id && pair.Key != CurrentUser.Id) + await _audioClient.CreateInputStreamAsync(pair.Key).ConfigureAwait(false); + } + } + } + + /// + /// Gets the name of the guild. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id})"; + internal SocketGuild Clone() => MemberwiseClone() as SocketGuild; + #endregion + + #region AutoMod + + internal SocketAutoModRule AddOrUpdateAutoModRule(AutoModRuleModel model) + { + if (_automodRules.TryGetValue(model.Id, out var rule)) + { + rule.Update(model); + return rule; + } + + var socketRule = SocketAutoModRule.Create(Discord, this, model); + _automodRules.TryAdd(model.Id, socketRule); + return socketRule; + } + + /// + /// Gets a single rule configured in a guild from cache. Returns if the rule was not found. + /// + public SocketAutoModRule GetAutoModRule(ulong id) + { + return _automodRules.TryGetValue(id, out var rule) ? rule : null; + } + + internal SocketAutoModRule RemoveAutoModRule(ulong id) + { + return _automodRules.TryRemove(id, out var rule) ? rule : null; + } + + internal SocketAutoModRule RemoveAutoModRule(AutoModRuleModel model) + { + if (_automodRules.TryRemove(model.Id, out var rule)) + { + rule.Update(model); + } + + return rule ?? SocketAutoModRule.Create(Discord, this, model); + } + + /// + public async Task GetAutoModRuleAsync(ulong ruleId, RequestOptions options = null) + { + var rule = await GuildHelper.GetAutoModRuleAsync(ruleId, this, Discord, options); + + return AddOrUpdateAutoModRule(rule); + } + + /// + public async Task GetAutoModRulesAsync(RequestOptions options = null) + { + var rules = await GuildHelper.GetAutoModRulesAsync(this, Discord, options); + + return rules.Select(AddOrUpdateAutoModRule).ToArray(); + } + + /// + public async Task CreateAutoModRuleAsync(Action props, RequestOptions options = null) + { + var rule = await GuildHelper.CreateAutoModRuleAsync(this, props, Discord, options); + + return AddOrUpdateAutoModRule(rule); + } + + /// + /// Gets the auto moderation rules defined in this guild. + /// + /// + /// This property may not always return all auto moderation rules if they haven't been cached. + /// + public IReadOnlyCollection AutoModRules => _automodRules.ToReadOnlyCollection(); + + #endregion + + #region Onboarding + + /// + public async Task GetOnboardingAsync(RequestOptions options = null) + { + var model = await GuildHelper.GetGuildOnboardingAsync(this, Discord, options); + + return new SocketGuildOnboarding(Discord, model, this); + } + + /// + public async Task ModifyOnboardingAsync(Action props, RequestOptions options = null) + { + var model = await GuildHelper.ModifyGuildOnboardingAsync(this, props, Discord, options); + + return new SocketGuildOnboarding(Discord, model, this); + } + + #endregion + + #region IGuild + /// + ulong? IGuild.AFKChannelId => AFKChannelId; + /// + IAudioClient IGuild.AudioClient => AudioClient; + /// + bool IGuild.Available => true; + /// + ulong? IGuild.WidgetChannelId => WidgetChannelId; + /// + ulong? IGuild.SafetyAlertsChannelId => SafetyAlertsChannelId; + /// + ulong? IGuild.SystemChannelId => SystemChannelId; + /// + ulong? IGuild.RulesChannelId => RulesChannelId; + /// + ulong? IGuild.PublicUpdatesChannelId => PublicUpdatesChannelId; + /// + IRole IGuild.EveryoneRole => EveryoneRole; + /// + IReadOnlyCollection IGuild.Roles => Roles; + /// + int? IGuild.ApproximateMemberCount => null; + /// + int? IGuild.ApproximatePresenceCount => null; + /// + IReadOnlyCollection IGuild.Stickers => Stickers; + /// + async Task IGuild.CreateEventAsync(string name, DateTimeOffset startTime, GuildScheduledEventType type, GuildScheduledEventPrivacyLevel privacyLevel, string description, DateTimeOffset? endTime, ulong? channelId, string location, Image? coverImage, RequestOptions options) + => await CreateEventAsync(name, startTime, type, privacyLevel, description, endTime, channelId, location, coverImage, options).ConfigureAwait(false); + /// + async Task IGuild.GetEventAsync(ulong id, RequestOptions options) + => await GetEventAsync(id, options).ConfigureAwait(false); + /// + async Task> IGuild.GetEventsAsync(RequestOptions options) + => await GetEventsAsync(options).ConfigureAwait(false); + /// + IAsyncEnumerable> IGuild.GetBansAsync(int limit, RequestOptions options) + => GetBansAsync(limit, options); + /// + IAsyncEnumerable> IGuild.GetBansAsync(ulong fromUserId, Direction dir, int limit, RequestOptions options) + => GetBansAsync(fromUserId, dir, limit, options); + /// + IAsyncEnumerable> IGuild.GetBansAsync(IUser fromUser, Direction dir, int limit, RequestOptions options) + => GetBansAsync(fromUser, dir, limit, options); + /// + async Task IGuild.GetBanAsync(IUser user, RequestOptions options) + => await GetBanAsync(user, options).ConfigureAwait(false); + /// + async Task IGuild.GetBanAsync(ulong userId, RequestOptions options) + => await GetBanAsync(userId, options).ConfigureAwait(false); + + /// + Task> IGuild.GetChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(Channels); + /// + Task IGuild.GetChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetChannel(id)); + /// + Task> IGuild.GetTextChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(TextChannels); + /// + Task IGuild.GetTextChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetTextChannel(id)); + /// + Task IGuild.GetThreadChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetThreadChannel(id)); + /// + Task> IGuild.GetThreadChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(ThreadChannels); + /// + Task> IGuild.GetVoiceChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(VoiceChannels); + /// + Task> IGuild.GetCategoriesAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(CategoryChannels); + /// + Task IGuild.GetVoiceChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetVoiceChannel(id)); + /// + Task IGuild.GetStageChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetStageChannel(id)); + /// + Task> IGuild.GetStageChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(StageChannels); + /// + Task IGuild.GetAFKChannelAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(AFKChannel); + /// + Task IGuild.GetDefaultChannelAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(DefaultChannel); + /// + Task IGuild.GetWidgetChannelAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(WidgetChannel); + /// + Task IGuild.GetSystemChannelAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(SystemChannel); + /// + Task IGuild.GetRulesChannelAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(RulesChannel); + /// + Task IGuild.GetPublicUpdatesChannelAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(PublicUpdatesChannel); + + /// + Task IGuild.GetForumChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetForumChannel(id)); + /// + Task> IGuild.GetForumChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(ForumChannels); + + /// + Task IGuild.GetMediaChannelAsync(ulong id, CacheMode mode, RequestOptions options) + => Task.FromResult(GetMediaChannel(id)); + /// + Task> IGuild.GetMediaChannelsAsync(CacheMode mode, RequestOptions options) + => Task.FromResult>(MediaChannels); + + /// + async Task IGuild.CreateTextChannelAsync(string name, Action func, RequestOptions options) + => await CreateTextChannelAsync(name, func, options).ConfigureAwait(false); + + /// + async Task IGuild.CreateNewsChannelAsync(string name, Action func, RequestOptions options) + => await CreateNewsChannelAsync(name, func, options).ConfigureAwait(false); + + /// + async Task IGuild.CreateVoiceChannelAsync(string name, Action func, RequestOptions options) + => await CreateVoiceChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateStageChannelAsync(string name, Action func, RequestOptions options) + => await CreateStageChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateCategoryAsync(string name, Action func, RequestOptions options) + => await CreateCategoryChannelAsync(name, func, options).ConfigureAwait(false); + /// + async Task IGuild.CreateForumChannelAsync(string name, Action func, RequestOptions options) + => await CreateForumChannelAsync(name, func, options).ConfigureAwait(false); + + /// + async Task IGuild.CreateMediaChannelAsync(string name, Action func, RequestOptions options) + => await CreateMediaChannelAsync(name, func, options).ConfigureAwait(false); + + /// + async Task> IGuild.GetVoiceRegionsAsync(RequestOptions options) + => await GetVoiceRegionsAsync(options).ConfigureAwait(false); + + /// + async Task> IGuild.GetIntegrationsAsync(RequestOptions options) + => await GetIntegrationsAsync(options).ConfigureAwait(false); + /// + async Task IGuild.DeleteIntegrationAsync(ulong id, RequestOptions options) + => await DeleteIntegrationAsync(id, options).ConfigureAwait(false); + + /// + async Task> IGuild.GetInvitesAsync(RequestOptions options) + => await GetInvitesAsync(options).ConfigureAwait(false); + /// + async Task IGuild.GetVanityInviteAsync(RequestOptions options) + => await GetVanityInviteAsync(options).ConfigureAwait(false); + + /// + IRole IGuild.GetRole(ulong id) + => GetRole(id); + /// + async Task IGuild.CreateRoleAsync(string name, GuildPermissions? permissions, Color? color, bool isHoisted, RequestOptions options) + => await CreateRoleAsync(name, permissions, color, isHoisted, false, options).ConfigureAwait(false); + /// + async Task IGuild.CreateRoleAsync(string name, GuildPermissions? permissions, Color? color, bool isHoisted, bool isMentionable, RequestOptions options, Image? icon, Emoji emoji) + => await CreateRoleAsync(name, permissions, color, isHoisted, isMentionable, options, icon, emoji).ConfigureAwait(false); + + /// + async Task> IGuild.GetUsersAsync(CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload && !HasAllMembers) + return (await GetUsersAsync(options).FlattenAsync().ConfigureAwait(false)).ToImmutableArray(); + else + return Users; + } + + /// + async Task IGuild.AddGuildUserAsync(ulong userId, string accessToken, Action func, RequestOptions options) + => await AddGuildUserAsync(userId, accessToken, func, options); + /// + async Task IGuild.GetUserAsync(ulong id, CacheMode mode, RequestOptions options) + { + var user = GetUser(id); + if (user is not null || mode == CacheMode.CacheOnly) + return user; + + return await GuildHelper.GetUserAsync(this, Discord, id, options).ConfigureAwait(false); + } + + /// + Task IGuild.GetCurrentUserAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(CurrentUser); + /// + Task IGuild.GetOwnerAsync(CacheMode mode, RequestOptions options) + => Task.FromResult(Owner); + /// + async Task> IGuild.SearchUsersAsync(string query, int limit, CacheMode mode, RequestOptions options) + { + if (mode == CacheMode.AllowDownload) + return await SearchUsersAsync(query, limit, options).ConfigureAwait(false); + else + return ImmutableArray.Create(); + } + + /// + async Task> IGuild.GetAuditLogsAsync(int limit, CacheMode cacheMode, RequestOptions options, + ulong? beforeId, ulong? userId, ActionType? actionType, ulong? afterId) + { + if (cacheMode == CacheMode.AllowDownload) + return (await GetAuditLogsAsync(limit, options, beforeId: beforeId, userId: userId, actionType: actionType, afterId: afterId).FlattenAsync().ConfigureAwait(false)).ToImmutableArray(); + else + return ImmutableArray.Create(); + } + + /// + async Task IGuild.GetWebhookAsync(ulong id, RequestOptions options) + => await GetWebhookAsync(id, options).ConfigureAwait(false); + /// + async Task> IGuild.GetWebhooksAsync(RequestOptions options) + => await GetWebhooksAsync(options).ConfigureAwait(false); + /// + async Task> IGuild.GetApplicationCommandsAsync(bool withLocalizations, string locale, RequestOptions options) + => await GetApplicationCommandsAsync(withLocalizations, locale, options).ConfigureAwait(false); + async Task IGuild.CreateStickerAsync(string name, Image image, IEnumerable tags, string description, RequestOptions options) + => await CreateStickerAsync(name, image, tags, description, options); + /// + async Task IGuild.CreateStickerAsync(string name, Stream stream, string filename, IEnumerable tags, string description, RequestOptions options) + => await CreateStickerAsync(name, stream, filename, tags, description, options); + /// + async Task IGuild.CreateStickerAsync(string name, string path, IEnumerable tags, string description, RequestOptions options) + => await CreateStickerAsync(name, path, tags, description, options); + /// + async Task IGuild.GetStickerAsync(ulong id, CacheMode mode, RequestOptions options) + => await GetStickerAsync(id, mode, options); + /// + async Task> IGuild.GetStickersAsync(CacheMode mode, RequestOptions options) + => await GetStickersAsync(mode, options); + /// + Task IGuild.DeleteStickerAsync(ICustomSticker sticker, RequestOptions options) + => DeleteStickerAsync(_stickers[sticker.Id], options); + /// + async Task IGuild.GetApplicationCommandAsync(ulong id, CacheMode mode, RequestOptions options) + => await GetApplicationCommandAsync(id, mode, options); + /// + async Task IGuild.CreateApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options) + => await CreateApplicationCommandAsync(properties, options); + /// + async Task> IGuild.BulkOverwriteApplicationCommandsAsync(ApplicationCommandProperties[] properties, + RequestOptions options) + => await BulkOverwriteApplicationCommandAsync(properties, options); + + /// + public Task GetWelcomeScreenAsync(RequestOptions options = null) + => GuildHelper.GetWelcomeScreenAsync(this, Discord, options); + + /// + public Task ModifyWelcomeScreenAsync(bool enabled, WelcomeScreenChannelProperties[] channels, string description = null, RequestOptions options = null) + => GuildHelper.ModifyWelcomeScreenAsync(enabled, description, channels, this, Discord, options); + + void IDisposable.Dispose() + { + DisconnectAudioAsync().GetAwaiter().GetResult(); + _audioLock?.Dispose(); + _audioClient?.Dispose(); + } + + /// + async Task IGuild.GetAutoModRuleAsync(ulong ruleId, RequestOptions options) + => await GetAutoModRuleAsync(ruleId, options).ConfigureAwait(false); + + /// + async Task IGuild.GetAutoModRulesAsync(RequestOptions options) + => await GetAutoModRulesAsync(options).ConfigureAwait(false); + + /// + async Task IGuild.CreateAutoModRuleAsync(Action props, RequestOptions options) + => await CreateAutoModRuleAsync(props, options).ConfigureAwait(false); + + /// + async Task IGuild.GetOnboardingAsync(RequestOptions options) + => await GetOnboardingAsync(options); + + /// + async Task IGuild.ModifyOnboardingAsync(Action props, RequestOptions options) + => await ModifyOnboardingAsync(props, options); + + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuildEvent.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuildEvent.cs new file mode 100644 index 0000000..07ec839 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuildEvent.cs @@ -0,0 +1,230 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.GuildScheduledEvent; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based guild event. + /// + public class SocketGuildEvent : SocketEntity, IGuildScheduledEvent + { + /// + /// Gets the guild of the event. + /// + public SocketGuild Guild { get; private set; } + + /// + public ulong GuildId { get; private set; } + + /// + /// Gets the channel of the event. + /// + public SocketGuildChannel Channel { get; private set; } + + /// + /// Gets the user who created the event. + /// + public SocketGuildUser Creator { get; private set; } + + /// + public string Name { get; private set; } + + /// + public string Description { get; private set; } + + /// + public string CoverImageId { get; private set; } + + /// + public DateTimeOffset StartTime { get; private set; } + + /// + public DateTimeOffset? EndTime { get; private set; } + + /// + public GuildScheduledEventPrivacyLevel PrivacyLevel { get; private set; } + + /// + public GuildScheduledEventStatus Status { get; private set; } + + /// + public GuildScheduledEventType Type { get; private set; } + + /// + public ulong? EntityId { get; private set; } + + /// + public string Location { get; private set; } + + /// + public int? UserCount { get; private set; } + + internal SocketGuildEvent(DiscordSocketClient client, SocketGuild guild, ulong id) + : base(client, id) + { + Guild = guild; + } + + internal static SocketGuildEvent Create(DiscordSocketClient client, SocketGuild guild, Model model) + { + var entity = new SocketGuildEvent(client, guild, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + if (model.ChannelId.IsSpecified && model.ChannelId.Value != null) + { + Channel = Guild.GetChannel(model.ChannelId.Value.Value); + } + + if (model.CreatorId.IsSpecified) + { + var guildUser = Guild.GetUser(model.CreatorId.GetValueOrDefault(0) ?? 0); + + if (guildUser != null) + { + if (model.Creator.IsSpecified) + guildUser.Update(Discord.State, model.Creator.Value); + + Creator = guildUser; + } + else if (model.Creator.IsSpecified) + { + guildUser = SocketGuildUser.Create(Guild, Discord.State, model.Creator.Value); + Creator = guildUser; + } + } + + Name = model.Name; + Description = model.Description.GetValueOrDefault(); + + EntityId = model.EntityId; + Location = model.EntityMetadata?.Location.GetValueOrDefault(); + Type = model.EntityType; + + PrivacyLevel = model.PrivacyLevel; + EndTime = model.ScheduledEndTime; + StartTime = model.ScheduledStartTime; + Status = model.Status; + UserCount = model.UserCount.ToNullable(); + CoverImageId = model.Image; + GuildId = model.GuildId; + } + + /// + public string GetCoverImageUrl(ImageFormat format = ImageFormat.Auto, ushort size = 1024) + => CDN.GetEventCoverImageUrl(GuildId, Id, CoverImageId, format, size); + + /// + public Task DeleteAsync(RequestOptions options = null) + => GuildHelper.DeleteEventAsync(Discord, this, options); + + /// + public Task StartAsync(RequestOptions options = null) + => ModifyAsync(x => x.Status = GuildScheduledEventStatus.Active); + + /// + public Task EndAsync(RequestOptions options = null) + => ModifyAsync(x => x.Status = Status == GuildScheduledEventStatus.Scheduled + ? GuildScheduledEventStatus.Cancelled + : GuildScheduledEventStatus.Completed); + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await GuildHelper.ModifyGuildEventAsync(Discord, func, this, options).ConfigureAwait(false); + Update(model); + } + + /// + /// Gets a collection of users that are interested in this event. + /// + /// The amount of users to fetch. + /// The options to be used when sending the request. + /// + /// A read-only collection of users. + /// + public Task> GetUsersAsync(int limit = 100, RequestOptions options = null) + => GuildHelper.GetEventUsersAsync(Discord, this, limit, options); + + /// + /// Gets a collection of N users interested in the event. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual messages as a + /// collection. + /// + /// This method will attempt to fetch all users that are interested in the event. + /// The library will attempt to split up the requests according to and . + /// In other words, if there are 300 users, and the constant + /// is 100, the request will be split into 3 individual requests; thus returning 3 individual asynchronous + /// responses, hence the need of flattening. + /// + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + public IAsyncEnumerable> GetUsersAsync(RequestOptions options = null) + => GuildHelper.GetEventUsersAsync(Discord, this, null, null, options); + + /// + /// Gets a collection of N users interested in the event. + /// + /// + /// + /// The returned collection is an asynchronous enumerable object; one must call + /// to access the individual users as a + /// collection. + /// + /// + /// Do not fetch too many users at once! This may cause unwanted preemptive rate limit or even actual + /// rate limit, causing your bot to freeze! + /// + /// This method will attempt to fetch the number of users specified under around + /// the user depending on the . The library will + /// attempt to split up the requests according to your and + /// . In other words, should the user request 500 users, + /// and the constant is 100, the request will + /// be split into 5 individual requests; thus returning 5 individual asynchronous responses, hence the need + /// of flattening. + /// + /// The ID of the starting user to get the users from. + /// The direction of the users to be gotten from. + /// The numbers of users to be gotten from. + /// The options to be used when sending the request. + /// + /// Paged collection of users. + /// + public IAsyncEnumerable> GetUsersAsync(ulong fromUserId, Direction dir, int limit = DiscordConfig.MaxGuildEventUsersPerBatch, RequestOptions options = null) + => GuildHelper.GetEventUsersAsync(Discord, this, fromUserId, dir, limit, options); + + internal SocketGuildEvent Clone() => MemberwiseClone() as SocketGuildEvent; + + #region IGuildScheduledEvent + + /// + IAsyncEnumerable> IGuildScheduledEvent.GetUsersAsync(RequestOptions options) + => GetUsersAsync(options); + /// + IAsyncEnumerable> IGuildScheduledEvent.GetUsersAsync(ulong fromUserId, Direction dir, int limit, RequestOptions options) + => GetUsersAsync(fromUserId, dir, limit, options); + /// + IGuild IGuildScheduledEvent.Guild => Guild; + /// + IUser IGuildScheduledEvent.Creator => Creator; + /// + ulong? IGuildScheduledEvent.ChannelId => Channel?.Id; + + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommand.cs new file mode 100644 index 0000000..0c473bc --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommand.cs @@ -0,0 +1,47 @@ +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents a Websocket-based slash command received over the gateway. + /// + public class SocketMessageCommand : SocketCommandBase, IMessageCommandInteraction, IDiscordInteraction + { + /// + /// Gets the data associated with this interaction. + /// + public new SocketMessageCommandData Data { get; } + + internal SocketMessageCommand(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + : base(client, model, channel, user) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + ulong? guildId = model.GuildId.ToNullable(); + + Data = SocketMessageCommandData.Create(client, dataModel, model.Id, guildId); + } + + internal new static SocketInteraction Create(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + { + var entity = new SocketMessageCommand(client, model, channel, user); + entity.Update(model); + return entity; + } + + //IMessageCommandInteraction + /// + IMessageCommandInteractionData IMessageCommandInteraction.Data => Data; + + //IDiscordInteraction + /// + IDiscordInteractionData IDiscordInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommandData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommandData.cs new file mode 100644 index 0000000..71a30b4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/MessageCommands/SocketMessageCommandData.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents the data tied with the interaction. + /// + public class SocketMessageCommandData : SocketCommandBaseData, IMessageCommandInteractionData, IDiscordInteractionData + { + /// + /// Gets the message associated with this message command. + /// + public SocketMessage Message + => ResolvableData?.Messages.FirstOrDefault().Value; + + /// + /// + /// Note Not implemented for + /// + public override IReadOnlyCollection Options + => throw new System.NotImplementedException(); + + internal SocketMessageCommandData(DiscordSocketClient client, Model model, ulong? guildId) + : base(client, model, guildId) { } + + internal new static SocketMessageCommandData Create(DiscordSocketClient client, Model model, ulong id, ulong? guildId) + { + var entity = new SocketMessageCommandData(client, model, guildId); + entity.Update(model); + return entity; + } + + //IMessageCommandInteractionData + /// + IMessage IMessageCommandInteractionData.Message => Message; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommand.cs new file mode 100644 index 0000000..70e06f2 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommand.cs @@ -0,0 +1,47 @@ +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents a Websocket-based slash command received over the gateway. + /// + public class SocketUserCommand : SocketCommandBase, IUserCommandInteraction, IDiscordInteraction + { + /// + /// The data associated with this interaction. + /// + public new SocketUserCommandData Data { get; } + + internal SocketUserCommand(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + : base(client, model, channel, user) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + ulong? guildId = model.GuildId.ToNullable(); + + Data = SocketUserCommandData.Create(client, dataModel, model.Id, guildId); + } + + internal new static SocketInteraction Create(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + { + var entity = new SocketUserCommand(client, model, channel, user); + entity.Update(model); + return entity; + } + + //IUserCommandInteraction + /// + IUserCommandInteractionData IUserCommandInteraction.Data => Data; + + //IDiscordInteraction + /// + IDiscordInteractionData IDiscordInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommandData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommandData.cs new file mode 100644 index 0000000..eaebbcb --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/ContextMenuCommands/UserCommands/SocketUserCommandData.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents the data tied with the interaction. + /// + public class SocketUserCommandData : SocketCommandBaseData, IUserCommandInteractionData, IDiscordInteractionData + { + /// + /// Gets the user who this command targets. + /// + public SocketUser Member + => (SocketUser)ResolvableData.GuildMembers.Values.FirstOrDefault() ?? ResolvableData.Users.Values.FirstOrDefault(); + + /// + /// + /// Note Not implemented for + /// + public override IReadOnlyCollection Options + => throw new System.NotImplementedException(); + + internal SocketUserCommandData(DiscordSocketClient client, Model model, ulong? guildId) + : base(client, model, guildId) { } + + internal new static SocketUserCommandData Create(DiscordSocketClient client, Model model, ulong id, ulong? guildId) + { + var entity = new SocketUserCommandData(client, model, guildId); + entity.Update(model); + return entity; + } + + //IUserCommandInteractionData + /// + IUser IUserCommandInteractionData.User => Member; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs new file mode 100644 index 0000000..25c48a7 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs @@ -0,0 +1,491 @@ +using Discord.Net.Rest; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DataModel = Discord.API.MessageComponentInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents a Websocket-based interaction type for Message Components. + /// + public class SocketMessageComponent : SocketInteraction, IComponentInteraction, IDiscordInteraction + { + /// + /// Gets the data received with this interaction, contains the button that was clicked. + /// + public new SocketMessageComponentData Data { get; } + + /// + public SocketUserMessage Message { get; private set; } + + private object _lock = new object(); + public override bool HasResponded { get; internal set; } = false; + + internal SocketMessageComponent(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + : base(client, model.Id, channel, user) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data = new SocketMessageComponentData(dataModel, client, client.State, client.Guilds.FirstOrDefault(x => x.Id == model.GuildId.GetValueOrDefault()), model.User.GetValueOrDefault()); + } + + internal new static SocketMessageComponent Create(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + { + var entity = new SocketMessageComponent(client, model, channel, user); + entity.Update(model); + return entity; + } + internal override void Update(Model model) + { + base.Update(model); + + if (model.Message.IsSpecified) + { + if (Message == null) + { + SocketUser author = null; + if (Channel is SocketGuildChannel channel) + { + if (model.Message.Value.WebhookId.IsSpecified) + author = SocketWebhookUser.Create(channel.Guild, Discord.State, model.Message.Value.Author.Value, model.Message.Value.WebhookId.Value); + else if (model.Message.Value.Author.IsSpecified) + author = channel.Guild.GetUser(model.Message.Value.Author.Value.Id); + } + else if (model.Message.Value.Author.IsSpecified) + author = (Channel as SocketChannel)?.GetUser(model.Message.Value.Author.Value.Id); + + author ??= Discord.State.GetOrAddUser(model.Message.Value.Author.Value.Id, _ => SocketGlobalUser.Create(Discord, Discord.State, model.Message.Value.Author.Value)); + + Message = SocketUserMessage.Create(Discord, Discord.State, author, Channel, model.Message.Value); + } + else + { + Message.Update(Discord.State, model.Message.Value); + } + } + } + public override async Task RespondWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.Rest.UploadInteractionFileParams(attachments?.ToArray()) + { + Type = InteractionResponseType.ChannelMessageWithSource, + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions != null ? allowedMentions?.ToModel() : Optional.Unspecified, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + IsTTS = isTTS, + MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer the same interaction twice"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override async Task RespondAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel(), + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified, + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public async Task UpdateAsync(Action func, RequestOptions options = null) + { + var args = new MessageProperties(); + func(args); + + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + if (args.AllowedMentions.IsSpecified) + { + var allowedMentions = args.AllowedMentions.Value; + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 user Ids are allowed."); + } + + var embed = args.Embed; + var embeds = args.Embeds; + + var apiEmbeds = embed.IsSpecified || embeds.IsSpecified ? new List() : null; + + if (embed.IsSpecified && embed.Value != null) + { + apiEmbeds.Add(embed.Value.ToModel()); + } + + if (embeds.IsSpecified && embeds.Value != null) + { + apiEmbeds.AddRange(embeds.Value.Select(x => x.ToModel())); + } + + Preconditions.AtMost(apiEmbeds?.Count ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (args.AllowedMentions.IsSpecified && args.AllowedMentions.Value != null && args.AllowedMentions.Value.AllowedTypes.HasValue) + { + var allowedMentions = args.AllowedMentions.Value; + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) + && allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(args.AllowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) + && allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(args.AllowedMentions)); + } + } + + if (!args.Attachments.IsSpecified) + { + var response = new API.InteractionResponse + { + Type = InteractionResponseType.UpdateMessage, + Data = new API.InteractionCallbackData + { + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + Components = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + } + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + else + { + var attachments = args.Attachments.Value?.ToArray() ?? Array.Empty(); + + var response = new API.Rest.UploadInteractionFileParams(attachments) + { + Type = InteractionResponseType.UpdateMessage, + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + MessageComponents = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + } + + HasResponded = true; + } + + /// + public override Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); + } + + /// + public override Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + public async Task DeferLoadingAsync(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredChannelMessageWithSource, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + await Discord.Rest.ApiClient.CreateInteractionResponseAsync(response, Id, Token, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override async Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredUpdateMessage, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + await Discord.Rest.ApiClient.CreateInteractionResponseAsync(response, Id, Token, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + + lock (_lock) + { + HasResponded = true; + } + } + //IComponentInteraction + /// + IComponentInteractionData IComponentInteraction.Data => Data; + + /// + IUserMessage IComponentInteraction.Message => Message; + + //IDiscordInteraction + /// + IDiscordInteractionData IDiscordInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs new file mode 100644 index 0000000..b193b77 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs @@ -0,0 +1,137 @@ +using Discord.Rest; +using Discord.Utils; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.MessageComponentInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents the data sent with a . + /// + public class SocketMessageComponentData : IComponentInteractionData + { + /// + public string CustomId { get; } + + /// + public ComponentType Type { get; } + + /// + public IReadOnlyCollection Values { get; } + + /// + public IReadOnlyCollection Channels { get; } + + /// + /// Returns if user is cached, otherwise. + public IReadOnlyCollection Users { get; } + + /// + public IReadOnlyCollection Roles { get; } + + /// + public IReadOnlyCollection Members { get; } + + #region IComponentInteractionData + + /// + IReadOnlyCollection IComponentInteractionData.Channels => Channels; + + /// + IReadOnlyCollection IComponentInteractionData.Users => Users; + + /// + IReadOnlyCollection IComponentInteractionData.Roles => Roles; + + /// + IReadOnlyCollection IComponentInteractionData.Members => Members; + + #endregion + /// + public string Value { get; } + + internal SocketMessageComponentData(Model model, DiscordSocketClient discord, ClientState state, SocketGuild guild, API.User dmUser) + { + CustomId = model.CustomId; + Type = model.ComponentType; + Values = model.Values.GetValueOrDefault(); + Value = model.Value.GetValueOrDefault(); + + if (model.Resolved.IsSpecified) + { + Users = model.Resolved.Value.Users.IsSpecified + ? model.Resolved.Value.Users.Value.Select(user => (IUser)state.GetUser(user.Value.Id) ?? RestUser.Create(discord, user.Value)).ToImmutableArray() + : null; + + Members = model.Resolved.Value.Members.IsSpecified + ? model.Resolved.Value.Members.Value.Select(member => + { + member.Value.User = model.Resolved.Value.Users.Value.First(u => u.Key == member.Key).Value; + return SocketGuildUser.Create(guild, state, member.Value); + }).ToImmutableArray() + : null; + + Channels = model.Resolved.Value.Channels.IsSpecified + ? model.Resolved.Value.Channels.Value.Select( + channel => + { + if (channel.Value.Type is ChannelType.DM) + return SocketDMChannel.Create(discord, state, channel.Value.Id, dmUser); + return (SocketChannel)SocketGuildChannel.Create(guild, state, channel.Value); + }).ToImmutableArray() + : null; + + Roles = model.Resolved.Value.Roles.IsSpecified + ? model.Resolved.Value.Roles.Value.Select(role => SocketRole.Create(guild, state, role.Value)).ToImmutableArray() + : null; + } + } + + internal SocketMessageComponentData(IMessageComponent component, DiscordSocketClient discord, ClientState state, SocketGuild guild, API.User dmUser) + { + CustomId = component.CustomId; + Type = component.Type; + + Value = component.Type == ComponentType.TextInput + ? (component as API.TextInputComponent).Value.Value + : null; + + if (component is API.SelectMenuComponent select) + { + Values = select.Values.GetValueOrDefault(null); + + if (select.Resolved.IsSpecified) + { + Users = select.Resolved.Value.Users.IsSpecified + ? select.Resolved.Value.Users.Value.Select(user => (IUser)state.GetUser(user.Value.Id) ?? RestUser.Create(discord, user.Value)).ToImmutableArray() + : null; + + Members = select.Resolved.Value.Members.IsSpecified + ? select.Resolved.Value.Members.Value.Select(member => + { + member.Value.User = select.Resolved.Value.Users.Value.First(u => u.Key == member.Key).Value; + return SocketGuildUser.Create(guild, state, member.Value); + }).ToImmutableArray() + : null; + + Channels = select.Resolved.Value.Channels.IsSpecified + ? select.Resolved.Value.Channels.Value.Select( + channel => + { + if (channel.Value.Type is ChannelType.DM) + return SocketDMChannel.Create(discord, state, channel.Value.Id, dmUser); + return (SocketChannel)SocketGuildChannel.Create(guild, state, channel.Value); + }).ToImmutableArray() + : null; + + Roles = select.Resolved.Value.Roles.IsSpecified + ? select.Resolved.Value.Roles.Value.Select(role => SocketRole.Create(guild, state, role.Value)).ToImmutableArray() + : null; + } + } + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs new file mode 100644 index 0000000..9543b8a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModal.cs @@ -0,0 +1,458 @@ +using Discord.Net.Rest; +using Discord.Rest; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using DataModel = Discord.API.ModalInteractionData; +using ModelBase = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents a user submitted received via GateWay. + /// + public class SocketModal : SocketInteraction, IDiscordInteraction, IModalInteraction + { + /// + /// Gets the data for this interaction. + /// + public new SocketModalData Data { get; set; } + + /// + public SocketUserMessage Message { get; private set; } + + IUserMessage IModalInteraction.Message => Message; + + internal SocketModal(DiscordSocketClient client, ModelBase model, ISocketMessageChannel channel, SocketUser user) + : base(client, model.Id, channel, user) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + if (model.Message.IsSpecified) + { + SocketUser author = null; + if (Channel is SocketGuildChannel ch) + { + if (model.Message.Value.WebhookId.IsSpecified) + author = SocketWebhookUser.Create(ch.Guild, Discord.State, model.Message.Value.Author.Value, model.Message.Value.WebhookId.Value); + else if (model.Message.Value.Author.IsSpecified) + author = ch.Guild.GetUser(model.Message.Value.Author.Value.Id); + } + else if (model.Message.Value.Author.IsSpecified) + author = (Channel as SocketChannel)?.GetUser(model.Message.Value.Author.Value.Id); + + author ??= Discord.State.GetOrAddUser(model.Message.Value.Author.Value.Id, _ => SocketGlobalUser.Create(Discord, Discord.State, model.Message.Value.Author.Value)); + + Message = SocketUserMessage.Create(Discord, Discord.State, author, Channel, model.Message.Value); + } + + Data = new SocketModalData(dataModel, client, client.State, client.State.GetGuild(model.GuildId.GetValueOrDefault()), model.User.GetValueOrDefault()); + } + + internal new static SocketModal Create(DiscordSocketClient client, ModelBase model, ISocketMessageChannel channel, SocketUser user) + { + var entity = new SocketModal(client, model, channel, user); + entity.Update(model); + return entity; + } + + /// + public override bool HasResponded { get; internal set; } + private object _lock = new object(); + + /// + public override async Task RespondWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.Rest.UploadInteractionFileParams(attachments?.ToArray()) + { + Type = InteractionResponseType.ChannelMessageWithSource, + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions != null ? allowedMentions?.ToModel() : Optional.Unspecified, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + IsTTS = isTTS, + MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer the same interaction twice"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override async Task RespondAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel(), + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified, + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public async Task UpdateAsync(Action func, RequestOptions options = null) + { + var args = new MessageProperties(); + func(args); + + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + if (args.AllowedMentions.IsSpecified) + { + var allowedMentions = args.AllowedMentions.Value; + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions), "A max of 100 user Ids are allowed."); + } + + var embed = args.Embed; + var embeds = args.Embeds; + + var apiEmbeds = embed.IsSpecified || embeds.IsSpecified ? new List() : null; + + if (embed.IsSpecified && embed.Value != null) + { + apiEmbeds.Add(embed.Value.ToModel()); + } + + if (embeds.IsSpecified && embeds.Value != null) + { + apiEmbeds.AddRange(embeds.Value.Select(x => x.ToModel())); + } + + Preconditions.AtMost(apiEmbeds?.Count ?? 0, 10, nameof(args.Embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (args.AllowedMentions.IsSpecified && args.AllowedMentions.Value != null && args.AllowedMentions.Value.AllowedTypes.HasValue) + { + var allowedMentions = args.AllowedMentions.Value; + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) + && allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(args.AllowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) + && allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(args.AllowedMentions)); + } + } + + if (!args.Attachments.IsSpecified) + { + var response = new API.InteractionResponse + { + Type = InteractionResponseType.UpdateMessage, + Data = new API.InteractionCallbackData + { + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + Components = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + } + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + else + { + var attachments = args.Attachments.Value?.ToArray() ?? Array.Empty(); + + var response = new API.Rest.UploadInteractionFileParams(attachments) + { + Type = InteractionResponseType.UpdateMessage, + Content = args.Content, + AllowedMentions = args.AllowedMentions.IsSpecified ? args.AllowedMentions.Value?.ToModel() : Optional.Unspecified, + Embeds = apiEmbeds?.ToArray() ?? Optional.Unspecified, + MessageComponents = args.Components.IsSpecified + ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Array.Empty() + : Optional.Unspecified, + Flags = args.Flags.IsSpecified ? args.Flags.Value ?? Optional.Unspecified : Optional.Unspecified + }; + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + } + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer twice to the same interaction"); + } + } + + HasResponded = true; + } + + /// + public override Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); + } + + /// + public override Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// + /// Acknowledges this interaction with the if the modal was created + /// in a response to a message component interaction, otherwise. + /// + public override async Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = Message is not null + ? InteractionResponseType.DeferredUpdateMessage + : InteractionResponseType.DeferredChannelMessageWithSource, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + await Discord.Rest.ApiClient.CreateInteractionResponseAsync(response, Id, Token, options).ConfigureAwait(false); + + lock (_lock) + { + HasResponded = true; + } + } + + /// + public async Task DeferLoadingAsync(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds of no response/acknowledgement"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredChannelMessageWithSource, + Data = ephemeral ? new API.InteractionCallbackData { Flags = MessageFlags.Ephemeral } : Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + await Discord.Rest.ApiClient.CreateInteractionResponseAsync(response, Id, Token, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + => throw new NotSupportedException("You cannot respond to a modal with a modal!"); + + IModalInteractionData IModalInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs new file mode 100644 index 0000000..52c7615 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DataModel = Discord.API.MessageComponentInteractionData; +using InterationModel = Discord.API.Interaction; +using Model = Discord.API.ModalInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents data sent from a . + /// + public class SocketModalData : IModalInteractionData + { + /// + /// Gets the 's Custom Id. + /// + public string CustomId { get; } + + /// + /// Gets the 's components submitted by the user. + /// + public IReadOnlyCollection Components { get; } + + internal SocketModalData(Model model, DiscordSocketClient discord, ClientState state, SocketGuild guild, API.User dmUser) + { + CustomId = model.CustomId; + Components = model.Components + .SelectMany(x => x.Components) + .Select(x => new SocketMessageComponentData(x, discord, state, guild, dmUser)) + .ToArray(); + } + + IReadOnlyCollection IModalInteractionData.Components => Components; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs new file mode 100644 index 0000000..498eece --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteraction.cs @@ -0,0 +1,117 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using DataModel = Discord.API.AutocompleteInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents a received over the gateway. + /// + public class SocketAutocompleteInteraction : SocketInteraction, IAutocompleteInteraction, IDiscordInteraction + { + /// + /// Gets the autocomplete data of this interaction. + /// + public new SocketAutocompleteInteractionData Data { get; } + + /// + public override bool HasResponded { get; internal set; } + + private object _lock = new object(); + + internal SocketAutocompleteInteraction(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + : base(client, model.Id, channel, user) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + if (dataModel != null) + Data = new SocketAutocompleteInteractionData(dataModel); + } + + internal new static SocketAutocompleteInteraction Create(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + { + var entity = new SocketAutocompleteInteraction(client, model, channel, user); + entity.Update(model); + return entity; + } + + /// + /// Responds to this interaction with a set of choices. + /// + /// + /// The set of choices for the user to pick from. + /// + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that + /// there is no choices for their autocompleted input. + /// + /// + /// The request options for this response. + /// + /// A task that represents the asynchronous operation of responding to this interaction. + /// + public async Task RespondAsync(IEnumerable result, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + await InteractionHelper.SendAutocompleteResultAsync(Discord, result, Id, Token, options).ConfigureAwait(false); + lock (_lock) + { + HasResponded = true; + } + } + + /// + /// Responds to this interaction with a set of choices. + /// + /// The request options for this response. + /// + /// The set of choices for the user to pick from. + /// + /// A max of 25 choices are allowed. Passing for this argument will show the executing user that + /// there is no choices for their autocompleted input. + /// + /// + /// + /// A task that represents the asynchronous operation of responding to this interaction. + /// + public Task RespondAsync(RequestOptions options = null, params AutocompleteResult[] result) + => RespondAsync(result, options); + public override Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + public override Task RespondWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => throw new NotSupportedException("Autocomplete interactions don't support this method!"); + + /// + public override Task RespondWithModalAsync(Modal modal, RequestOptions requestOptions = null) + => throw new NotSupportedException("Autocomplete interactions cannot have normal responses!"); + + //IAutocompleteInteraction + /// + IAutocompleteInteractionData IAutocompleteInteraction.Data => Data; + + //IDiscordInteraction + /// + IDiscordInteractionData IDiscordInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteractionData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteractionData.cs new file mode 100644 index 0000000..dba3de3 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketAutocompleteInteractionData.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using DataModel = Discord.API.AutocompleteInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents data for a slash commands autocomplete interaction. + /// + public class SocketAutocompleteInteractionData : IAutocompleteInteractionData, IDiscordInteractionData + { + /// + public string CommandName { get; } + + /// + public ulong CommandId { get; } + + /// + public ApplicationCommandType Type { get; } + + /// + public ulong Version { get; } + + /// + public AutocompleteOption Current { get; } + + /// + public IReadOnlyCollection Options { get; } + + internal SocketAutocompleteInteractionData(DataModel model) + { + var options = model.Options.SelectMany(GetOptions); + + Current = options.FirstOrDefault(x => x.Focused); + Options = options.ToImmutableArray(); + + if (Options.Count == 1 && Current == null) + Current = Options.FirstOrDefault(); + + CommandName = model.Name; + CommandId = model.Id; + Type = model.Type; + Version = model.Version; + } + + private List GetOptions(API.AutocompleteInteractionDataOption model) + { + var options = new List(); + + options.Add(new AutocompleteOption(model.Type, model.Name, model.Value.GetValueOrDefault(null), model.Focused.GetValueOrDefault(false))); + + if (model.Options.IsSpecified) + { + options.AddRange(model.Options.Value.SelectMany(GetOptions)); + } + + return options; + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommand.cs new file mode 100644 index 0000000..69f733e --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommand.cs @@ -0,0 +1,47 @@ +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents a Websocket-based slash command received over the gateway. + /// + public class SocketSlashCommand : SocketCommandBase, ISlashCommandInteraction, IDiscordInteraction + { + /// + /// Gets the data associated with this interaction. + /// + public new SocketSlashCommandData Data { get; } + + internal SocketSlashCommand(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + : base(client, model, channel, user) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + ulong? guildId = model.GuildId.ToNullable(); + + Data = SocketSlashCommandData.Create(client, dataModel, guildId); + } + + internal new static SocketInteraction Create(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + { + var entity = new SocketSlashCommand(client, model, channel, user); + entity.Update(model); + return entity; + } + + //ISlashCommandInteraction + /// + IApplicationCommandInteractionData ISlashCommandInteraction.Data => Data; + + //IDiscordInteraction + /// + IDiscordInteractionData IDiscordInteraction.Data => Data; + + //IApplicationCommandInteraction + /// + IApplicationCommandInteractionData IApplicationCommandInteraction.Data => Data; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommandData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommandData.cs new file mode 100644 index 0000000..c385ce8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommandData.cs @@ -0,0 +1,30 @@ +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents the data tied with the interaction. + /// + public class SocketSlashCommandData : SocketCommandBaseData, IDiscordInteractionData + { + internal SocketSlashCommandData(DiscordSocketClient client, Model model, ulong? guildId) + : base(client, model, guildId) { } + + internal static SocketSlashCommandData Create(DiscordSocketClient client, Model model, ulong? guildId) + { + var entity = new SocketSlashCommandData(client, model, guildId); + entity.Update(model); + return entity; + } + internal override void Update(Model model) + { + base.Update(model); + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(x => new SocketSlashCommandDataOption(this, x)).ToImmutableArray() + : ImmutableArray.Create(); + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommandDataOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommandDataOption.cs new file mode 100644 index 0000000..29a6352 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SlashCommands/SocketSlashCommandDataOption.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.ApplicationCommandInteractionDataOption; + +namespace Discord.WebSocket +{ + /// + /// Represents a Websocket-based received by the gateway. + /// + public class SocketSlashCommandDataOption : IApplicationCommandInteractionDataOption + { + #region SocketSlashCommandDataOption + /// + public string Name { get; private set; } + + /// + public object Value { get; private set; } + + /// + public ApplicationCommandOptionType Type { get; private set; } + + /// + /// Gets the sub command options received for this sub command group. + /// + public IReadOnlyCollection Options { get; private set; } + + internal SocketSlashCommandDataOption() { } + internal SocketSlashCommandDataOption(SocketSlashCommandData data, Model model) + { + Name = model.Name; + Type = model.Type; + + if (model.Value.IsSpecified) + { + switch (Type) + { + case ApplicationCommandOptionType.User: + case ApplicationCommandOptionType.Role: + case ApplicationCommandOptionType.Channel: + case ApplicationCommandOptionType.Mentionable: + case ApplicationCommandOptionType.Attachment: + if (ulong.TryParse($"{model.Value.Value}", out var valueId)) + { + switch (Type) + { + case ApplicationCommandOptionType.User: + { + var guildUser = data.ResolvableData.GuildMembers.FirstOrDefault(x => x.Key == valueId).Value; + + if (guildUser != null) + Value = guildUser; + else + Value = data.ResolvableData.Users.FirstOrDefault(x => x.Key == valueId).Value; + } + break; + case ApplicationCommandOptionType.Channel: + Value = data.ResolvableData.Channels.FirstOrDefault(x => x.Key == valueId).Value; + break; + case ApplicationCommandOptionType.Role: + Value = data.ResolvableData.Roles.FirstOrDefault(x => x.Key == valueId).Value; + break; + case ApplicationCommandOptionType.Mentionable: + { + if (data.ResolvableData.GuildMembers.Any(x => x.Key == valueId) || data.ResolvableData.Users.Any(x => x.Key == valueId)) + { + var guildUser = data.ResolvableData.GuildMembers.FirstOrDefault(x => x.Key == valueId).Value; + + if (guildUser != null) + Value = guildUser; + else + Value = data.ResolvableData.Users.FirstOrDefault(x => x.Key == valueId).Value; + } + else if (data.ResolvableData.Roles.Any(x => x.Key == valueId)) + { + Value = data.ResolvableData.Roles.FirstOrDefault(x => x.Key == valueId).Value; + } + } + break; + case ApplicationCommandOptionType.Attachment: + Value = data.ResolvableData.Attachments.FirstOrDefault(x => x.Key == valueId).Value; + break; + default: + Value = model.Value.Value; + break; + } + } + break; + case ApplicationCommandOptionType.String: + Value = model.Value.ToString(); + break; + case ApplicationCommandOptionType.Integer: + { + if (model.Value.Value is long val) + Value = val; + else if (long.TryParse(model.Value.Value.ToString(), out long res)) + Value = res; + } + break; + case ApplicationCommandOptionType.Boolean: + { + if (model.Value.Value is bool val) + Value = val; + else if (bool.TryParse(model.Value.Value.ToString(), out bool res)) + Value = res; + } + break; + case ApplicationCommandOptionType.Number: + { + if (model.Value.Value is int val) + Value = val; + else if (double.TryParse(model.Value.Value.ToString(), out double res)) + Value = res; + } + break; + } + } + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(x => new SocketSlashCommandDataOption(data, x)).ToImmutableArray() + : ImmutableArray.Create(); + } + #endregion + + #region Converters + public static explicit operator bool(SocketSlashCommandDataOption option) + => (bool)option.Value; + public static explicit operator int(SocketSlashCommandDataOption option) + => (int)option.Value; + public static explicit operator string(SocketSlashCommandDataOption option) + => option.Value.ToString(); + #endregion + + #region IApplicationCommandInteractionDataOption + IReadOnlyCollection IApplicationCommandInteractionDataOption.Options + => Options; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs new file mode 100644 index 0000000..d32c424 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommand.cs @@ -0,0 +1,176 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using GatewayModel = Discord.API.Gateway.ApplicationCommandCreatedUpdatedEvent; +using Model = Discord.API.ApplicationCommand; + +namespace Discord.WebSocket +{ + /// + /// Represents a Websocket-based . + /// + public class SocketApplicationCommand : SocketEntity, IApplicationCommand + { + #region SocketApplicationCommand + /// + /// Gets whether or not this command is a global application command. + /// + public bool IsGlobalCommand + => GuildId is null; + + /// + public ulong ApplicationId { get; private set; } + + /// + public string Name { get; private set; } + + /// + public ApplicationCommandType Type { get; private set; } + + /// + public string Description { get; private set; } + + /// + public bool IsDefaultPermission { get; private set; } + + /// + [Obsolete("This property will be deprecated soon. Use ContextTypes instead.")] + public bool IsEnabledInDm { get; private set; } + + /// + public bool IsNsfw { get; private set; } + + /// + public GuildPermissions DefaultMemberPermissions { get; private set; } + + /// + /// Gets a collection of s for this command. + /// + /// + /// If the is not a slash command, this field will be an empty collection. + /// + public IReadOnlyCollection Options { get; private set; } + + /// + /// Gets the localization dictionary for the name field of this command. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string DescriptionLocalized { get; private set; } + + /// + public IReadOnlyCollection IntegrationTypes { get; private set; } + + /// + public IReadOnlyCollection ContextTypes { get; private set; } + + /// + public DateTimeOffset CreatedAt + => SnowflakeUtils.FromSnowflake(Id); + + /// + /// Gets the guild this command resides in; if this command is a global command then it will return + /// + public SocketGuild Guild + => GuildId.HasValue ? Discord.GetGuild(GuildId.Value) : null; + + private ulong? GuildId { get; set; } + + internal SocketApplicationCommand(DiscordSocketClient client, ulong id, ulong? guildId) + : base(client, id) + { + GuildId = guildId; + } + internal static SocketApplicationCommand Create(DiscordSocketClient client, GatewayModel model) + { + var entity = new SocketApplicationCommand(client, model.Id, model.GuildId.ToNullable()); + entity.Update(model); + return entity; + } + + internal static SocketApplicationCommand Create(DiscordSocketClient client, Model model, ulong? guildId = null) + { + var entity = new SocketApplicationCommand(client, model.Id, guildId); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + ApplicationId = model.ApplicationId; + Description = model.Description; + Name = model.Name; + IsDefaultPermission = model.DefaultPermissions.GetValueOrDefault(true); + Type = model.Type; + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(SocketApplicationCommandOption.Create).ToImmutableArray() + : ImmutableArray.Create(); + + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + NameLocalized = model.NameLocalized.GetValueOrDefault(); + DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault(); + +#pragma warning disable CS0618 // Type or member is obsolete + IsEnabledInDm = model.DmPermission.GetValueOrDefault(true).GetValueOrDefault(true); +#pragma warning restore CS0618 // Type or member is obsolete + DefaultMemberPermissions = new GuildPermissions((ulong)model.DefaultMemberPermission.GetValueOrDefault(0).GetValueOrDefault(0)); + IsNsfw = model.Nsfw.GetValueOrDefault(false).GetValueOrDefault(false); + + IntegrationTypes = model.IntegrationTypes.GetValueOrDefault(null)?.ToImmutableArray(); + ContextTypes = model.ContextTypes.GetValueOrDefault(null)?.ToImmutableArray(); + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => InteractionHelper.DeleteUnknownApplicationCommandAsync(Discord, GuildId, this, options); + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + { + return ModifyAsync(func, options); + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) where TArg : ApplicationCommandProperties + { + var command = IsGlobalCommand + ? await InteractionHelper.ModifyGlobalCommandAsync(Discord, this, func, options).ConfigureAwait(false) + : await InteractionHelper.ModifyGuildCommandAsync(Discord, this, GuildId.Value, func, options); + + Update(command); + } + #endregion + + #region IApplicationCommand + IReadOnlyCollection IApplicationCommand.Options => Options; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs new file mode 100644 index 0000000..4da1eaa --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandChoice.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using Model = Discord.API.ApplicationCommandOptionChoice; + +namespace Discord.WebSocket +{ + /// + /// Represents a choice for a . + /// + public class SocketApplicationCommandChoice : IApplicationCommandOptionChoice + { + /// + public string Name { get; private set; } + + /// + public object Value { get; private set; } + + /// + /// Gets the localization dictionary for the name field of this command option choice. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localized name of this command option choice. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + internal SocketApplicationCommandChoice() { } + internal static SocketApplicationCommandChoice Create(Model model) + { + var entity = new SocketApplicationCommandChoice(); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + Name = model.Name; + Value = model.Value; + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary(); + NameLocalized = model.NameLocalized.GetValueOrDefault(null); + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs new file mode 100644 index 0000000..78bb451 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketApplicationCommandOption.cs @@ -0,0 +1,135 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Model = Discord.API.ApplicationCommandOption; + +namespace Discord.WebSocket +{ + /// + /// Represents an option for a . + /// + public class SocketApplicationCommandOption : IApplicationCommandOption + { + /// + public string Name { get; private set; } + + /// + public ApplicationCommandOptionType Type { get; private set; } + + /// + public string Description { get; private set; } + + /// + public bool? IsDefault { get; private set; } + + /// + public bool? IsRequired { get; private set; } + + public bool? IsAutocomplete { get; private set; } + + /// + public double? MinValue { get; private set; } + + /// + public double? MaxValue { get; private set; } + + /// + public int? MinLength { get; private set; } + + /// + public int? MaxLength { get; private set; } + + /// + /// Gets a collection of choices for the user to pick from. + /// + public IReadOnlyCollection Choices { get; private set; } + + /// + /// Gets a collection of nested options. + /// + public IReadOnlyCollection Options { get; private set; } + + /// + /// Gets the allowed channel types for this option. + /// + public IReadOnlyCollection ChannelTypes { get; private set; } + + /// + /// Gets the localization dictionary for the name field of this command option. + /// + public IReadOnlyDictionary NameLocalizations { get; private set; } + + /// + /// Gets the localization dictionary for the description field of this command option. + /// + public IReadOnlyDictionary DescriptionLocalizations { get; private set; } + + /// + /// Gets the localized name of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string NameLocalized { get; private set; } + + /// + /// Gets the localized description of this command option. + /// + /// + /// Only returned when the `withLocalizations` query parameter is set to when requesting the command. + /// + public string DescriptionLocalized { get; private set; } + + internal SocketApplicationCommandOption() { } + internal static SocketApplicationCommandOption Create(Model model) + { + var entity = new SocketApplicationCommandOption(); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Name = model.Name; + Type = model.Type; + Description = model.Description; + + IsDefault = model.Default.ToNullable(); + + IsRequired = model.Required.ToNullable(); + + MinValue = model.MinValue.ToNullable(); + + MaxValue = model.MaxValue.ToNullable(); + + IsAutocomplete = model.Autocomplete.ToNullable(); + + MinLength = model.MinLength.ToNullable(); + MaxLength = model.MaxLength.ToNullable(); + + Choices = model.Choices.IsSpecified + ? model.Choices.Value.Select(SocketApplicationCommandChoice.Create).ToImmutableArray() + : ImmutableArray.Create(); + + Options = model.Options.IsSpecified + ? model.Options.Value.Select(Create).ToImmutableArray() + : ImmutableArray.Create(); + + ChannelTypes = model.ChannelTypes.IsSpecified + ? model.ChannelTypes.Value.ToImmutableArray() + : ImmutableArray.Create(); + + NameLocalizations = model.NameLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + DescriptionLocalizations = model.DescriptionLocalizations.GetValueOrDefault(null)?.ToImmutableDictionary() ?? + ImmutableDictionary.Empty; + + NameLocalized = model.NameLocalized.GetValueOrDefault(); + DescriptionLocalized = model.DescriptionLocalized.GetValueOrDefault(); + } + + IReadOnlyCollection IApplicationCommandOption.Choices => Choices; + IReadOnlyCollection IApplicationCommandOption.Options => Options; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs new file mode 100644 index 0000000..2c6b27a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs @@ -0,0 +1,358 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Base class for User, Message, and Slash command interactions. + /// + public class SocketCommandBase : SocketInteraction + { + /// + /// Gets the name of the invoked command. + /// + public string CommandName + => Data.Name; + + /// + /// Gets the id of the invoked command. + /// + public ulong CommandId + => Data.Id; + + /// + /// Gets the data associated with this interaction. + /// + internal new SocketCommandBaseData Data { get; } + + /// + public override bool HasResponded { get; internal set; } + + private object _lock = new object(); + + internal SocketCommandBase(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + : base(client, model.Id, channel, user) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + ulong? guildId = model.GuildId.ToNullable(); + + Data = SocketCommandBaseData.Create(client, dataModel, model.Id, guildId); + } + + internal new static SocketInteraction Create(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + { + var entity = new SocketCommandBase(client, model, channel, user); + entity.Update(model); + return entity; + } + + internal override void Update(Model model) + { + var data = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + Data.Update(data); + + base.Update(model); + } + + /// + public override async Task RespondAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.ChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + TTS = isTTS, + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override async Task RespondWithModalAsync(Modal modal, RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.Modal, + Data = new API.InteractionCallbackData + { + CustomId = modal.CustomId, + Title = modal.Title, + Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond twice to the same interaction"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + + lock (_lock) + { + HasResponded = true; + } + } + + public override async Task RespondWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot respond to an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var response = new API.Rest.UploadInteractionFileParams(attachments?.ToArray()) + { + Type = InteractionResponseType.ChannelMessageWithSource, + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions != null ? allowedMentions?.ToModel() : Optional.Unspecified, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + IsTTS = isTTS, + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified, + MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond, update, or defer the same interaction twice"); + } + } + + await InteractionHelper.SendInteractionResponseAsync(Discord, response, this, Channel, options).ConfigureAwait(false); + HasResponded = true; + } + + /// + public override Task FollowupAsync( + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + var args = new API.Rest.CreateWebhookMessageParams + { + Content = text ?? Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + IsTTS = isTTS, + Embeds = embeds.Select(x => x.ToModel()).ToArray(), + Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified + }; + + if (ephemeral) + args.Flags = MessageFlags.Ephemeral; + + return InteractionHelper.SendFollowupAsync(Discord.Rest, args, Token, Channel, options); + } + + /// + public override Task FollowupWithFilesAsync( + IEnumerable attachments, + string text = null, + Embed[] embeds = null, + bool isTTS = false, + bool ephemeral = false, + AllowedMentions allowedMentions = null, + MessageComponent components = null, + Embed embed = null, + RequestOptions options = null) + { + if (!IsValidToken) + throw new InvalidOperationException("Interaction token is no longer valid"); + + embeds ??= Array.Empty(); + if (embed != null) + embeds = new[] { embed }.Concat(embeds).ToArray(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Length, 10, nameof(embeds), "A max of 10 embeds are allowed."); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + var flags = MessageFlags.None; + + if (ephemeral) + flags |= MessageFlags.Ephemeral; + + var args = new API.Rest.UploadWebhookFileParams(attachments.ToArray()) { Flags = flags, Content = text, IsTTS = isTTS, Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified }; + return InteractionHelper.SendFollowupAsync(Discord, args, Token, Channel, options); + } + + /// + /// Acknowledges this interaction with the . + /// + /// + /// A task that represents the asynchronous operation of acknowledging the interaction. + /// + public override async Task DeferAsync(bool ephemeral = false, RequestOptions options = null) + { + if (!InteractionHelper.CanSendResponse(this)) + throw new TimeoutException($"Cannot defer an interaction after {InteractionHelper.ResponseTimeLimit} seconds!"); + + var response = new API.InteractionResponse + { + Type = InteractionResponseType.DeferredChannelMessageWithSource, + Data = new API.InteractionCallbackData + { + Flags = ephemeral ? MessageFlags.Ephemeral : Optional.Unspecified + } + }; + + lock (_lock) + { + if (HasResponded) + { + throw new InvalidOperationException("Cannot respond or defer twice to the same interaction"); + } + } + + await Discord.Rest.ApiClient.CreateInteractionResponseAsync(response, Id, Token, options).ConfigureAwait(false); + HasResponded = true; + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBaseData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBaseData.cs new file mode 100644 index 0000000..3d62189 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBaseData.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using Model = Discord.API.ApplicationCommandInteractionData; + +namespace Discord.WebSocket +{ + /// + /// Represents the base data tied with the interaction. + /// + public class SocketCommandBaseData : SocketEntity, IApplicationCommandInteractionData where TOption : IApplicationCommandInteractionDataOption + { + /// + public string Name { get; private set; } + + /// + /// Gets the received with this interaction. + /// + public virtual IReadOnlyCollection Options { get; internal set; } + + internal readonly SocketResolvableData ResolvableData; + + internal SocketCommandBaseData(DiscordSocketClient client, Model model, ulong? guildId) + : base(client, model.Id) + { + if (model.Resolved.IsSpecified) + { + ResolvableData = new SocketResolvableData(client, guildId, model); + } + } + + internal static SocketCommandBaseData Create(DiscordSocketClient client, Model model, ulong id, ulong? guildId) + { + var entity = new SocketCommandBaseData(client, model, guildId); + entity.Update(model); + return entity; + } + + internal virtual void Update(Model model) + { + Name = model.Name; + } + + IReadOnlyCollection IApplicationCommandInteractionData.Options + => (IReadOnlyCollection)Options; + } + + /// + /// Represents the base data tied with the interaction. + /// + public class SocketCommandBaseData : SocketCommandBaseData + { + internal SocketCommandBaseData(DiscordSocketClient client, Model model, ulong? guildId) + : base(client, model, guildId) { } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketResolvableData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketResolvableData.cs new file mode 100644 index 0000000..3baf3db --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketResolvableData.cs @@ -0,0 +1,140 @@ +using Discord.Net; +using System.Collections.Generic; + +namespace Discord.WebSocket +{ + internal class SocketResolvableData where T : API.IResolvable + { + internal readonly Dictionary GuildMembers + = new Dictionary(); + internal readonly Dictionary Users + = new Dictionary(); + internal readonly Dictionary Channels + = new Dictionary(); + internal readonly Dictionary Roles + = new Dictionary(); + + internal readonly Dictionary Messages + = new Dictionary(); + + internal readonly Dictionary Attachments + = new Dictionary(); + + internal SocketResolvableData(DiscordSocketClient discord, ulong? guildId, T model) + { + var guild = guildId.HasValue ? discord.GetGuild(guildId.Value) : null; + + var resolved = model.Resolved.Value; + + if (resolved.Users.IsSpecified) + { + foreach (var user in resolved.Users.Value) + { + var socketUser = discord.GetOrCreateUser(discord.State, user.Value); + + Users.Add(ulong.Parse(user.Key), socketUser); + } + } + + if (resolved.Channels.IsSpecified) + { + foreach (var channel in resolved.Channels.Value) + { + var socketChannel = guild != null + ? guild.GetChannel(channel.Value.Id) + : discord.GetChannel(channel.Value.Id); + + if (socketChannel == null) + { + try + { + var channelModel = guild != null + ? discord.Rest.ApiClient.GetChannelAsync(guild.Id, channel.Value.Id) + .ConfigureAwait(false).GetAwaiter().GetResult() + : discord.Rest.ApiClient.GetChannelAsync(channel.Value.Id).ConfigureAwait(false) + .GetAwaiter().GetResult(); + + socketChannel = guild != null + ? SocketGuildChannel.Create(guild, discord.State, channelModel) + : (SocketChannel)SocketChannel.CreatePrivate(discord, discord.State, channelModel); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.MissingPermissions) + { + socketChannel = guildId != null + ? SocketGuildChannel.Create(guild, discord.State, channel.Value) + : (SocketChannel)SocketChannel.CreatePrivate(discord, discord.State, channel.Value); + } + } + + discord.State.AddChannel(socketChannel); + Channels.Add(ulong.Parse(channel.Key), socketChannel); + } + } + + if (resolved.Members.IsSpecified && guild != null) + { + foreach (var member in resolved.Members.Value) + { + member.Value.User = resolved.Users.Value[member.Key]; + var user = guild.AddOrUpdateUser(member.Value); + GuildMembers.Add(ulong.Parse(member.Key), user); + } + } + + if (resolved.Roles.IsSpecified && guild != null) + { + foreach (var role in resolved.Roles.Value) + { + var socketRole = guild is null + ? SocketRole.Create(null, discord.State, role.Value) + : guild.AddOrUpdateRole(role.Value); + + Roles.Add(ulong.Parse(role.Key), socketRole); + } + } + + if (resolved.Messages.IsSpecified) + { + foreach (var msg in resolved.Messages.Value) + { + var channel = discord.GetChannel(msg.Value.ChannelId) as ISocketMessageChannel; + + SocketUser author; + if (guild != null) + { + if (msg.Value.WebhookId.IsSpecified) + author = SocketWebhookUser.Create(guild, discord.State, msg.Value.Author.Value, msg.Value.WebhookId.Value); + else + author = guild.GetUser(msg.Value.Author.Value.Id); + } + else + author = (channel as SocketChannel)?.GetUser(msg.Value.Author.Value.Id); + + if (channel == null) + { + if (guildId is null) // assume it is a DM + { + channel = discord.CreateDMChannel(msg.Value.ChannelId, msg.Value.Author.Value, discord.State); + author = ((SocketDMChannel)channel).Recipient; + } + } + + author ??= discord.State.GetOrAddUser(msg.Value.Author.Value.Id, _ => SocketGlobalUser.Create(discord, discord.State, msg.Value.Author.Value)); + + var message = SocketMessage.Create(discord, discord.State, author, channel, msg.Value); + Messages.Add(message.Id, message); + } + } + + if (resolved.Attachments.IsSpecified) + { + foreach (var attachment in resolved.Attachments.Value) + { + var discordAttachment = Attachment.Create(attachment.Value, discord); + + Attachments.Add(ulong.Parse(attachment.Key), discordAttachment); + } + } + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs new file mode 100644 index 0000000..54a00b8 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketInteraction.cs @@ -0,0 +1,488 @@ +using Discord.Net; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DataModel = Discord.API.ApplicationCommandInteractionData; +using Model = Discord.API.Interaction; + +namespace Discord.WebSocket +{ + /// + /// Represents an Interaction received over the gateway. + /// + public abstract class SocketInteraction : SocketEntity, IDiscordInteraction + { + #region SocketInteraction + /// + /// Gets the this interaction was used in. + /// + /// + /// If the channel isn't cached, the bot scope isn't used, or the bot doesn't have access to it then + /// this property will be . + /// + public ISocketMessageChannel Channel { get; private set; } + + /// + public ulong? ChannelId { get; private set; } + + /// + /// Gets the who triggered this interaction. + /// + public SocketUser User { get; private set; } + + /// + public InteractionType Type { get; private set; } + + /// + public string Token { get; private set; } + + /// + public IDiscordInteractionData Data { get; private set; } + + /// + public string UserLocale { get; private set; } + + /// + public string GuildLocale { get; private set; } + + /// + public int Version { get; private set; } + + /// + public DateTimeOffset CreatedAt { get; private set; } + + /// + public abstract bool HasResponded { get; internal set; } + + /// + /// Gets whether or not the token used to respond to this interaction is valid. + /// + public bool IsValidToken + => InteractionHelper.CanRespondOrFollowup(this); + + /// + public bool IsDMInteraction { get; private set; } + + /// + public ulong? GuildId { get; private set; } + + /// + public ulong ApplicationId { get; private set; } + + /// + public InteractionContextType? ContextType { get; private set; } + + /// + public GuildPermissions Permissions { get; private set; } + + /// + public IReadOnlyCollection Entitlements { get; private set; } + + /// + public IReadOnlyDictionary IntegrationOwners { get; private set; } + + internal SocketInteraction(DiscordSocketClient client, ulong id, ISocketMessageChannel channel, SocketUser user) + : base(client, id) + { + Channel = channel; + User = user; + + CreatedAt = client.UseInteractionSnowflakeDate + ? SnowflakeUtils.FromSnowflake(Id) + : DateTime.UtcNow; + } + + internal static SocketInteraction Create(DiscordSocketClient client, Model model, ISocketMessageChannel channel, SocketUser user) + { + if (model.Type == InteractionType.ApplicationCommand) + { + var dataModel = model.Data.IsSpecified + ? (DataModel)model.Data.Value + : null; + + if (dataModel == null) + return null; + + return dataModel.Type switch + { + ApplicationCommandType.Slash => SocketSlashCommand.Create(client, model, channel, user), + ApplicationCommandType.Message => SocketMessageCommand.Create(client, model, channel, user), + ApplicationCommandType.User => SocketUserCommand.Create(client, model, channel, user), + _ => null + }; + } + + if (model.Type == InteractionType.MessageComponent) + return SocketMessageComponent.Create(client, model, channel, user); + + if (model.Type == InteractionType.ApplicationCommandAutocomplete) + return SocketAutocompleteInteraction.Create(client, model, channel, user); + + if (model.Type == InteractionType.ModalSubmit) + return SocketModal.Create(client, model, channel, user); + + return null; + } + + internal virtual void Update(Model model) + { + ChannelId = model.Channel.IsSpecified + ? model.Channel.Value.Id + : null; + + GuildId = model.GuildId.IsSpecified + ? model.GuildId.Value + : null; + + IsDMInteraction = GuildId is null; + ApplicationId = model.ApplicationId; + + Data = model.Data.IsSpecified + ? model.Data.Value + : null; + + Token = model.Token; + Version = model.Version; + Type = model.Type; + + UserLocale = model.UserLocale.IsSpecified + ? model.UserLocale.Value + : null; + + GuildLocale = model.GuildLocale.IsSpecified + ? model.GuildLocale.Value + : null; + + Entitlements = model.Entitlements.Select(x => RestEntitlement.Create(Discord, x)).ToImmutableArray(); + + IntegrationOwners = model.IntegrationOwners; + ContextType = model.ContextType.IsSpecified + ? model.ContextType.Value + : null; + + Permissions = new GuildPermissions((ulong)model.ApplicationPermissions); + } + + /// + /// Responds to an Interaction with type . + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// Message content is too long, length must be less or equal to . + /// The parameters provided were invalid or the token was invalid. + public abstract Task RespondAsync(string text = null, Embed[] embeds = null, bool isTTS = false, + bool ephemeral = false, AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Responds to this interaction with a file attachment. + /// + /// The file to upload. + /// The file name of the attachment. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public async Task RespondWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(fileStream, fileName)) + { + await RespondWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } + + /// + /// Responds to this interaction with a file attachment. + /// + /// The file to upload. + /// The file name of the attachment. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public async Task RespondWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(filePath, fileName)) + { + await RespondWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } + + /// + /// Responds to this interaction with a file attachment. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public Task RespondWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => RespondWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + /// Responds to this interaction with a collection of file attachments. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task RespondWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// The sent message. + /// + public abstract Task FollowupAsync(string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// The sent message. + /// + public async Task FollowupWithFileAsync(Stream fileStream, string fileName, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(fileStream, fileName)) + { + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } + + /// + /// Sends a followup message for this interaction. + /// + /// The text of the message to be sent. + /// The file to upload. + /// The file name of the attachment. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// The request options for this response. + /// + /// The sent message. + /// + public async Task FollowupWithFileAsync(string filePath, string fileName = null, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + { + using (var file = new FileAttachment(filePath, fileName)) + { + return await FollowupWithFileAsync(file, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + } + } + + /// + /// Sends a followup message for this interaction. + /// + /// The attachment containing the file and description. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public Task FollowupWithFileAsync(FileAttachment attachment, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null) + => FollowupWithFilesAsync(new FileAttachment[] { attachment }, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options); + + /// + /// Sends a followup message for this interaction. + /// + /// A collection of attachments to upload. + /// The text of the message to be sent. + /// A array of embeds to send with this response. Max 10. + /// if the message should be read out by a text-to-speech reader, otherwise . + /// if the response should be hidden to everyone besides the invoker of the command, otherwise . + /// The allowed mentions for this response. + /// The request options for this response. + /// A to be sent with this response. + /// A single embed to send with this response. If this is passed alongside an array of embeds, the single embed will be ignored. + /// + /// A task that represents an asynchronous send operation for delivering the message. The task result + /// contains the sent message. + /// + public abstract Task FollowupWithFilesAsync(IEnumerable attachments, string text = null, Embed[] embeds = null, bool isTTS = false, bool ephemeral = false, + AllowedMentions allowedMentions = null, MessageComponent components = null, Embed embed = null, RequestOptions options = null); + + /// + /// Gets the original response for this interaction. + /// + /// The request options for this request. + /// A that represents the initial response. + public Task GetOriginalResponseAsync(RequestOptions options = null) + => InteractionHelper.GetOriginalResponseAsync(Discord, Channel, this, options); + + /// + /// Edits original response for this interaction. + /// + /// A delegate containing the properties to modify the message with. + /// The request options for this request. + /// A that represents the initial response. + public async Task ModifyOriginalResponseAsync(Action func, RequestOptions options = null) + { + var model = await InteractionHelper.ModifyInteractionResponseAsync(Discord, Token, func, options); + return RestInteractionMessage.Create(Discord, model, Token, Channel); + } + + /// + public Task DeleteOriginalResponseAsync(RequestOptions options = null) + => InteractionHelper.DeleteInteractionResponseAsync(Discord, this, options); + + /// + /// Acknowledges this interaction. + /// + /// to send this message ephemerally, otherwise . + /// The request options for this request. + /// + /// A task that represents the asynchronous operation of acknowledging the interaction. + /// + public abstract Task DeferAsync(bool ephemeral = false, RequestOptions options = null); + + /// + /// Responds to this interaction with a . + /// + /// The to respond with. + /// The request options for this request. + /// A task that represents the asynchronous operation of responding to the interaction. + public abstract Task RespondWithModalAsync(Modal modal, RequestOptions options = null); + + /// + public Task RespondWithPremiumRequiredAsync(RequestOptions options = null) + => InteractionHelper.RespondWithPremiumRequiredAsync(Discord, Id, Token, options); + + #endregion + + /// + /// Attempts to get the channel this interaction was executed in. + /// + /// The request options for this request. + /// + /// A task that represents the asynchronous operation of fetching the channel. + /// + public async ValueTask GetChannelAsync(RequestOptions options = null) + { + if (Channel != null) + return Channel; + + if (!ChannelId.HasValue) + return null; + + try + { + return (IMessageChannel)await Discord.GetChannelAsync(ChannelId.Value, options).ConfigureAwait(false); + } + catch (HttpException ex) when (ex.DiscordCode == DiscordErrorCode.MissingPermissions) { return null; } // bot can't view that channel, return null instead of throwing. + } + + #region IDiscordInteraction + /// + IReadOnlyCollection IDiscordInteraction.Entitlements => Entitlements; + + /// + IUser IDiscordInteraction.User => User; + + /// + async Task IDiscordInteraction.GetOriginalResponseAsync(RequestOptions options) + => await GetOriginalResponseAsync(options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.ModifyOriginalResponseAsync(Action func, RequestOptions options) + => await ModifyOriginalResponseAsync(func, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.RespondAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await RespondAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupAsync(string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupAsync(text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFilesAsync(IEnumerable attachments, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFilesAsync(attachments, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); +#if NETCOREAPP3_0_OR_GREATER != true + /// + async Task IDiscordInteraction.FollowupWithFileAsync(Stream fileStream, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(fileStream, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(string filePath, string fileName, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(filePath, fileName, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); + /// + async Task IDiscordInteraction.FollowupWithFileAsync(FileAttachment attachment, string text, Embed[] embeds, bool isTTS, bool ephemeral, AllowedMentions allowedMentions, MessageComponent components, Embed embed, RequestOptions options) + => await FollowupWithFileAsync(attachment, text, embeds, isTTS, ephemeral, allowedMentions, components, embed, options).ConfigureAwait(false); +#endif + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Invites/SocketInvite.cs b/src/Discord.Net.WebSocket/Entities/Invites/SocketInvite.cs new file mode 100644 index 0000000..c659b7b --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Invites/SocketInvite.cs @@ -0,0 +1,163 @@ +using Discord.Rest; +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Gateway.InviteCreateEvent; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based invite to a guild. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketInvite : SocketEntity, IInviteMetadata + { + private long _createdAtTicks; + + /// + public ulong ChannelId { get; private set; } + /// + /// Gets the channel where this invite was created. + /// + public SocketGuildChannel Channel { get; private set; } + /// + public ulong? GuildId { get; private set; } + /// + /// Gets the guild where this invite was created. + /// + public SocketGuild Guild { get; private set; } + /// + ChannelType IInvite.ChannelType + { + get + { + return Channel switch + { + IVoiceChannel voiceChannel => ChannelType.Voice, + ICategoryChannel categoryChannel => ChannelType.Category, + IDMChannel dmChannel => ChannelType.DM, + IGroupChannel groupChannel => ChannelType.Group, + INewsChannel newsChannel => ChannelType.News, + ITextChannel textChannel => ChannelType.Text, + IMediaChannel mediaChannel => ChannelType.Media, + IForumChannel forumChannel => ChannelType.Forum, + _ => throw new InvalidOperationException("Invalid channel type."), + }; + } + } + /// + string IInvite.ChannelName => Channel.Name; + /// + string IInvite.GuildName => Guild.Name; + /// + int? IInvite.PresenceCount => throw new NotImplementedException(); + /// + int? IInvite.MemberCount => throw new NotImplementedException(); + /// + public bool IsTemporary { get; private set; } + /// + int? IInviteMetadata.MaxAge { get => MaxAge; } + /// + int? IInviteMetadata.MaxUses { get => MaxUses; } + /// + int? IInviteMetadata.Uses { get => Uses; } + /// + /// Gets the time (in seconds) until the invite expires. + /// + public int MaxAge { get; private set; } + /// + /// Gets the max number of uses this invite may have. + /// + public int MaxUses { get; private set; } + /// + /// Gets the number of times this invite has been used. + /// + public int Uses { get; private set; } + /// + /// Gets the user that created this invite if available. + /// + public SocketGuildUser Inviter { get; private set; } + /// + DateTimeOffset? IInviteMetadata.CreatedAt => DateTimeUtils.FromTicks(_createdAtTicks); + /// + /// Gets when this invite was created. + /// + public DateTimeOffset CreatedAt => DateTimeUtils.FromTicks(_createdAtTicks); + /// + /// Gets the user targeted by this invite if available. + /// + public SocketUser TargetUser { get; private set; } + /// + /// Gets the type of the user targeted by this invite. + /// + public TargetUserType TargetUserType { get; private set; } + + /// + public RestApplication Application { get; private set; } + + /// + public DateTimeOffset? ExpiresAt { get; private set; } + + /// + public string Code => Id; + /// + public string Url => $"{DiscordConfig.InviteUrl}{Code}"; + + internal SocketInvite(DiscordSocketClient discord, SocketGuild guild, SocketGuildChannel channel, SocketGuildUser inviter, SocketUser target, string id) + : base(discord, id) + { + Guild = guild; + Channel = channel; + Inviter = inviter; + TargetUser = target; + } + internal static SocketInvite Create(DiscordSocketClient discord, SocketGuild guild, SocketGuildChannel channel, SocketGuildUser inviter, SocketUser target, Model model) + { + var entity = new SocketInvite(discord, guild, channel, inviter, target, model.Code); + entity.Update(model); + return entity; + } + internal void Update(Model model) + { + ChannelId = model.ChannelId; + GuildId = model.GuildId.IsSpecified ? model.GuildId.Value : Guild.Id; + IsTemporary = model.Temporary; + MaxAge = model.MaxAge; + MaxUses = model.MaxUses; + Uses = model.Uses; + _createdAtTicks = model.CreatedAt.UtcTicks; + TargetUserType = model.TargetUserType.IsSpecified ? model.TargetUserType.Value : TargetUserType.Undefined; + ExpiresAt = model.ExpiresAt.IsSpecified ? model.ExpiresAt.Value : null; + Application = model.Application.IsSpecified ? RestApplication.Create(Discord, model.Application.Value) : null; + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => InviteHelper.DeleteAsync(this, Discord, options); + + /// + /// Gets the URL of the invite. + /// + /// + /// A string that resolves to the Url of the invite. + /// + public override string ToString() => Url; + private string DebuggerDisplay => $"{Url} ({Guild?.Name} / {Channel.Name})"; + + #region IInvite + + /// + IGuild IInvite.Guild => Guild; + /// + IChannel IInvite.Channel => Channel; + /// + IUser IInvite.Inviter => Inviter; + /// + IUser IInvite.TargetUser => TargetUser; + + /// + IApplication IInvite.Application => Application; + + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Messages/MessageCache.cs b/src/Discord.Net.WebSocket/Entities/Messages/MessageCache.cs new file mode 100644 index 0000000..90064dd --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Messages/MessageCache.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.WebSocket +{ + internal class MessageCache + { + private readonly ConcurrentDictionary _messages; + private readonly ConcurrentQueue _orderedMessages; + private readonly int _size; + + public IReadOnlyCollection Messages => _messages.ToReadOnlyCollection(); + + public MessageCache(DiscordSocketClient discord) + { + _size = discord.MessageCacheSize; + _messages = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(_size * 1.05)); + _orderedMessages = new ConcurrentQueue(); + } + + public void Add(SocketMessage message) + { + if (_messages.TryAdd(message.Id, message)) + { + _orderedMessages.Enqueue(message.Id); + + while (_orderedMessages.Count > _size && _orderedMessages.TryDequeue(out ulong msgId)) + _messages.TryRemove(msgId, out _); + } + } + + public SocketMessage Remove(ulong id) + { + _messages.TryRemove(id, out SocketMessage msg); + return msg; + } + + public SocketMessage Get(ulong id) + { + if (_messages.TryGetValue(id, out SocketMessage result)) + return result; + return null; + } + + /// is less than 0. + public IReadOnlyCollection GetMany(ulong? fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch) + { + if (limit < 0) + throw new ArgumentOutOfRangeException(nameof(limit)); + if (limit == 0) + return ImmutableArray.Empty; + + IEnumerable cachedMessageIds; + if (fromMessageId == null) + cachedMessageIds = _orderedMessages; + else if (dir == Direction.Before) + cachedMessageIds = _orderedMessages.Where(x => x < fromMessageId.Value); + else if (dir == Direction.After) + cachedMessageIds = _orderedMessages.Where(x => x > fromMessageId.Value); + else //Direction.Around + { + if (!_messages.TryGetValue(fromMessageId.Value, out SocketMessage msg)) + return ImmutableArray.Empty; + int around = limit / 2; + var before = GetMany(fromMessageId, Direction.Before, around); + var after = GetMany(fromMessageId, Direction.After, around).Reverse(); + + return after.Concat(new SocketMessage[] { msg }).Concat(before).ToImmutableArray(); + } + + if (dir == Direction.Before) + cachedMessageIds = cachedMessageIds.Reverse(); + if (dir == Direction.Around) //Only happens if fromMessageId is null, should only get "around" and itself (+1) + limit = limit / 2 + 1; + + return cachedMessageIds + .Select(x => + { + if (_messages.TryGetValue(x, out SocketMessage msg)) + return msg; + return null; + }) + .Where(x => x != null) + .Take(limit) + .ToImmutableArray(); + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs b/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs new file mode 100644 index 0000000..930c6ca --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Messages/SocketMessage.cs @@ -0,0 +1,382 @@ +using Discord.Rest; +using Newtonsoft.Json.Linq; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Message; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based message. + /// + public abstract class SocketMessage : SocketEntity, IMessage + { + #region SocketMessage + private long _timestampTicks; + private readonly List _reactions = new List(); + private ImmutableArray _userMentions = ImmutableArray.Create(); + + /// + /// Gets the author of this message. + /// + /// + /// A WebSocket-based user object. + /// + public SocketUser Author { get; } + /// + /// Gets the source channel of the message. + /// + /// + /// A WebSocket-based message channel. + /// + public ISocketMessageChannel Channel { get; } + /// + public MessageSource Source { get; } + + /// + public string Content { get; private set; } + + /// + public string CleanContent => MessageHelper.SanitizeMessage(this); + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public virtual bool IsTTS => false; + /// + public virtual bool IsPinned => false; + /// + public virtual bool IsSuppressed => false; + /// + public virtual DateTimeOffset? EditedTimestamp => null; + /// + public virtual bool MentionedEveryone => false; + + /// + public MessageActivity Activity { get; private set; } + + /// + public MessageApplication Application { get; private set; } + + /// + public MessageReference Reference { get; private set; } + + /// + public IReadOnlyCollection Components { get; private set; } + + /// + /// Gets the interaction this message is a response to. + /// + public MessageInteraction Interaction { get; private set; } + + /// + public MessageFlags? Flags { get; private set; } + + /// + public MessageType Type { get; private set; } + + /// + public MessageRoleSubscriptionData RoleSubscriptionData { get; private set; } + + /// + public SocketThreadChannel Thread { get; private set; } + + /// + IThreadChannel IMessage.Thread => Thread; + + /// + /// Returns all attachments included in this message. + /// + /// + /// Collection of attachments. + /// + public virtual IReadOnlyCollection Attachments => ImmutableArray.Create(); + /// + /// Returns all embeds included in this message. + /// + /// + /// Collection of embed objects. + /// + public virtual IReadOnlyCollection Embeds => ImmutableArray.Create(); + /// + /// Returns the channels mentioned in this message. + /// + /// + /// Collection of WebSocket-based guild channels. + /// + public virtual IReadOnlyCollection MentionedChannels => ImmutableArray.Create(); + /// + /// Returns the roles mentioned in this message. + /// + /// + /// Collection of WebSocket-based roles. + /// + public virtual IReadOnlyCollection MentionedRoles => ImmutableArray.Create(); + /// + public virtual IReadOnlyCollection Tags => ImmutableArray.Create(); + /// + public virtual IReadOnlyCollection Stickers => ImmutableArray.Create(); + /// + public IReadOnlyDictionary Reactions => _reactions.GroupBy(r => r.Emote).ToDictionary(x => x.Key, x => new ReactionMetadata { ReactionCount = x.Count(), IsMe = x.Any(y => y.UserId == Discord.CurrentUser.Id) }); + /// + /// Returns the users mentioned in this message. + /// + /// + /// Collection of WebSocket-based users. + /// + public IReadOnlyCollection MentionedUsers => _userMentions; + /// + public DateTimeOffset Timestamp => DateTimeUtils.FromTicks(_timestampTicks); + + internal SocketMessage(DiscordSocketClient discord, ulong id, ISocketMessageChannel channel, SocketUser author, MessageSource source) + : base(discord, id) + { + Channel = channel; + Author = author; + Source = source; + } + internal static SocketMessage Create(DiscordSocketClient discord, ClientState state, SocketUser author, ISocketMessageChannel channel, Model model) + { + if (model.Type == MessageType.Default || + model.Type == MessageType.Reply || + model.Type == MessageType.ApplicationCommand || + model.Type == MessageType.ThreadStarterMessage || + model.Type == MessageType.ContextMenuCommand) + return SocketUserMessage.Create(discord, state, author, channel, model); + else + return SocketSystemMessage.Create(discord, state, author, channel, model); + } + internal virtual void Update(ClientState state, Model model) + { + Type = model.Type; + + if (model.Timestamp.IsSpecified) + _timestampTicks = model.Timestamp.Value.UtcTicks; + + if (model.Content.IsSpecified) + { + Content = model.Content.Value; + } + + if (model.Application.IsSpecified) + { + // create a new Application from the API model + Application = new MessageApplication() + { + Id = model.Application.Value.Id, + CoverImage = model.Application.Value.CoverImage, + Description = model.Application.Value.Description, + Icon = model.Application.Value.Icon, + Name = model.Application.Value.Name + }; + } + + if (model.Activity.IsSpecified) + { + // create a new Activity from the API model + Activity = new MessageActivity() + { + Type = model.Activity.Value.Type.Value, + PartyId = model.Activity.Value.PartyId.GetValueOrDefault() + }; + } + + if (model.Reference.IsSpecified) + { + // Creates a new Reference from the API model + Reference = new MessageReference + { + GuildId = model.Reference.Value.GuildId, + InternalChannelId = model.Reference.Value.ChannelId, + MessageId = model.Reference.Value.MessageId, + FailIfNotExists = model.Reference.Value.FailIfNotExists + }; + } + + if (model.Components.IsSpecified) + { + Components = model.Components.Value.Select(x => new ActionRowComponent(x.Components.Select(y => + { + switch (y.Type) + { + case ComponentType.Button: + { + var parsed = (API.ButtonComponent)y; + return new Discord.ButtonComponent( + parsed.Style, + parsed.Label.GetValueOrDefault(), + parsed.Emote.IsSpecified + ? parsed.Emote.Value.Id.HasValue + ? new Emote(parsed.Emote.Value.Id.Value, parsed.Emote.Value.Name, parsed.Emote.Value.Animated.GetValueOrDefault()) + : new Emoji(parsed.Emote.Value.Name) + : null, + parsed.CustomId.GetValueOrDefault(), + parsed.Url.GetValueOrDefault(), + parsed.Disabled.GetValueOrDefault()); + } + case ComponentType.SelectMenu: + { + var parsed = (API.SelectMenuComponent)y; + return new SelectMenuComponent( + parsed.CustomId, + parsed.Options.Select(z => new SelectMenuOption( + z.Label, + z.Value, + z.Description.GetValueOrDefault(), + z.Emoji.IsSpecified + ? z.Emoji.Value.Id.HasValue + ? new Emote(z.Emoji.Value.Id.Value, z.Emoji.Value.Name, z.Emoji.Value.Animated.GetValueOrDefault()) + : new Emoji(z.Emoji.Value.Name) + : null, + z.Default.ToNullable())).ToList(), + parsed.Placeholder.GetValueOrDefault(), + parsed.MinValues, + parsed.MaxValues, + parsed.Disabled, + parsed.Type, + parsed.ChannelTypes.GetValueOrDefault(), + parsed.DefaultValues.IsSpecified + ? parsed.DefaultValues.Value.Select(x => new SelectMenuDefaultValue(x.Id, x.Type)) + : Array.Empty() + ); + } + default: + return null; + } + }).ToList())).ToImmutableArray(); + } + else + Components = new List(); + + if (model.UserMentions.IsSpecified) + { + var value = model.UserMentions.Value; + if (value.Length > 0) + { + var newMentions = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + var val = value[i]; + if (val != null) + { + var user = Channel.GetUserAsync(val.Id, CacheMode.CacheOnly).GetAwaiter().GetResult() as SocketUser; + if (user != null) + newMentions.Add(user); + else + newMentions.Add(SocketUnknownUser.Create(Discord, state, val)); + } + } + _userMentions = newMentions.ToImmutable(); + } + } + + if (model.Interaction.IsSpecified) + { + Interaction = new MessageInteraction(model.Interaction.Value.Id, + model.Interaction.Value.Type, + model.Interaction.Value.Name, + SocketGlobalUser.Create(Discord, state, model.Interaction.Value.User)); + } + + if (model.Flags.IsSpecified) + Flags = model.Flags.Value; + + if (model.RoleSubscriptionData.IsSpecified) + { + RoleSubscriptionData = new( + model.RoleSubscriptionData.Value.SubscriptionListingId, + model.RoleSubscriptionData.Value.TierName, + model.RoleSubscriptionData.Value.MonthsSubscribed, + model.RoleSubscriptionData.Value.IsRenewal); + } + + if (model.Thread.IsSpecified) + { + SocketGuild guild = (Channel as SocketGuildChannel)?.Guild; + Thread = guild?.AddOrUpdateChannel(state, model.Thread.Value) as SocketThreadChannel; + } + } + + /// + public Task DeleteAsync(RequestOptions options = null) + => MessageHelper.DeleteAsync(this, Discord, options); + + /// + /// Gets the content of the message. + /// + /// + /// Content of the message. + /// + public override string ToString() => Content; + internal SocketMessage Clone() => MemberwiseClone() as SocketMessage; + #endregion + + #region IMessage + /// + IUser IMessage.Author => Author; + /// + IMessageChannel IMessage.Channel => Channel; + /// + IReadOnlyCollection IMessage.Attachments => Attachments; + /// + IReadOnlyCollection IMessage.Embeds => Embeds; + /// + IReadOnlyCollection IMessage.MentionedChannelIds => MentionedChannels.Select(x => x.Id).ToImmutableArray(); + /// + IReadOnlyCollection IMessage.MentionedRoleIds => MentionedRoles.Select(x => x.Id).ToImmutableArray(); + /// + IReadOnlyCollection IMessage.MentionedUserIds => MentionedUsers.Select(x => x.Id).ToImmutableArray(); + + /// + IReadOnlyCollection IMessage.Components => Components; + + /// + [Obsolete("This property will be deprecated soon. Use IUserMessage.InteractionMetadata instead.")] + IMessageInteraction IMessage.Interaction => Interaction; + + /// + IReadOnlyCollection IMessage.Stickers => Stickers; + + + internal void AddReaction(SocketReaction reaction) + { + _reactions.Add(reaction); + } + internal void RemoveReaction(SocketReaction reaction) + { + if (_reactions.Contains(reaction)) + _reactions.Remove(reaction); + } + internal void ClearReactions() + { + _reactions.Clear(); + } + internal void RemoveReactionsForEmote(IEmote emote) + { + _reactions.RemoveAll(x => x.Emote.Equals(emote)); + } + + /// + public Task AddReactionAsync(IEmote emote, RequestOptions options = null) + => MessageHelper.AddReactionAsync(this, emote, Discord, options); + /// + public Task RemoveReactionAsync(IEmote emote, IUser user, RequestOptions options = null) + => MessageHelper.RemoveReactionAsync(this, user.Id, emote, Discord, options); + /// + public Task RemoveReactionAsync(IEmote emote, ulong userId, RequestOptions options = null) + => MessageHelper.RemoveReactionAsync(this, userId, emote, Discord, options); + /// + public Task RemoveAllReactionsAsync(RequestOptions options = null) + => MessageHelper.RemoveAllReactionsAsync(this, Discord, options); + /// + public Task RemoveAllReactionsForEmoteAsync(IEmote emote, RequestOptions options = null) + => MessageHelper.RemoveAllReactionsForEmoteAsync(this, emote, Discord, options); + /// + public IAsyncEnumerable> GetReactionUsersAsync(IEmote emote, int limit, RequestOptions options = null, ReactionType type = ReactionType.Normal) + => MessageHelper.GetReactionUsersAsync(this, emote, limit, Discord, type, options); + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Messages/SocketReaction.cs b/src/Discord.Net.WebSocket/Entities/Messages/SocketReaction.cs new file mode 100644 index 0000000..113093c --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Messages/SocketReaction.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using Model = Discord.API.Gateway.Reaction; + +namespace Discord.WebSocket; + +/// +/// Represents a WebSocket-based reaction object. +/// +public class SocketReaction : IReaction +{ + /// + /// Gets the ID of the user who added the reaction. + /// + /// + /// This property retrieves the snowflake identifier of the user responsible for this reaction. This + /// property will always contain the user identifier in event that + /// cannot be retrieved. + /// + /// + /// A user snowflake identifier associated with the user. + /// + public ulong UserId { get; } + + /// + /// Gets the user who added the reaction if possible. + /// + /// + /// + /// This property attempts to retrieve a WebSocket-cached user that is responsible for this reaction from + /// the client. In other words, when the user is not in the WebSocket cache, this property may not + /// contain a value, leaving the only identifiable information to be + /// . + /// + /// + /// If you wish to obtain an identifiable user object, consider utilizing + /// which will attempt to retrieve the user from REST. + /// + /// + /// + /// A user object where possible; a value is not always returned. + /// + /// + public Optional User { get; } + + /// + /// Gets the ID of the message that has been reacted to. + /// + /// + /// A message snowflake identifier associated with the message. + /// + public ulong MessageId { get; } + + /// + /// Gets the message that has been reacted to if possible. + /// + /// + /// A WebSocket-based message where possible; a value is not always returned. + /// + /// + public Optional Message { get; } + + /// + /// Gets the channel where the reaction takes place in. + /// + /// + /// A WebSocket-based message channel. + /// + public ISocketMessageChannel Channel { get; } + + /// + public IEmote Emote { get; } + + /// + /// Gets whether the reaction is a super reaction. + /// + public bool IsBurst { get; } + + /// + public IReadOnlyCollection BurstColors { get; } + + /// + /// Gets the type of the reaction. + /// + public ReactionType ReactionType { get; } + + internal SocketReaction(ISocketMessageChannel channel, ulong messageId, Optional message, ulong userId, Optional user, + IEmote emoji, bool isBurst, IReadOnlyCollection colors, ReactionType reactionType) + { + Channel = channel; + MessageId = messageId; + Message = message; + UserId = userId; + User = user; + Emote = emoji; + IsBurst = isBurst; + BurstColors = colors; + ReactionType = reactionType; + } + + internal static SocketReaction Create(Model model, ISocketMessageChannel channel, Optional message, Optional user) + { + IEmote emote; + if (model.Emoji.Id.HasValue) + emote = new Emote(model.Emoji.Id.Value, model.Emoji.Name, model.Emoji.Animated.GetValueOrDefault()); + else + emote = new Emoji(model.Emoji.Name); + return new SocketReaction(channel, + model.MessageId, + message, + model.UserId, + user, + emote, + model.IsBurst, + model.BurstColors.GetValueOrDefault(Array.Empty()).ToReadOnlyCollection(), + model.Type); + } + + /// + public override bool Equals(object other) + { + if (other == null) + return false; + if (other == this) + return true; + + var otherReaction = other as SocketReaction; + if (otherReaction == null) + return false; + + return UserId == otherReaction.UserId && MessageId == otherReaction.MessageId && Emote.Equals(otherReaction.Emote); + } + + /// + public override int GetHashCode() + { + unchecked + { + var hashCode = UserId.GetHashCode(); + hashCode = (hashCode * 397) ^ MessageId.GetHashCode(); + hashCode = (hashCode * 397) ^ Emote.GetHashCode(); + return hashCode; + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Messages/SocketSystemMessage.cs b/src/Discord.Net.WebSocket/Entities/Messages/SocketSystemMessage.cs new file mode 100644 index 0000000..f8ab896 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Messages/SocketSystemMessage.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; +using Model = Discord.API.Message; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based message sent by the system. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketSystemMessage : SocketMessage, ISystemMessage + { + internal SocketSystemMessage(DiscordSocketClient discord, ulong id, ISocketMessageChannel channel, SocketUser author) + : base(discord, id, channel, author, MessageSource.System) + { + } + internal new static SocketSystemMessage Create(DiscordSocketClient discord, ClientState state, SocketUser author, ISocketMessageChannel channel, Model model) + { + var entity = new SocketSystemMessage(discord, model.Id, channel, author); + entity.Update(state, model); + return entity; + } + internal override void Update(ClientState state, Model model) + { + base.Update(state, model); + } + + private string DebuggerDisplay => $"{Author}: {Content} ({Id}, {Type})"; + internal new SocketSystemMessage Clone() => MemberwiseClone() as SocketSystemMessage; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Messages/SocketUserMessage.cs b/src/Discord.Net.WebSocket/Entities/Messages/SocketUserMessage.cs new file mode 100644 index 0000000..74d82fd --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Messages/SocketUserMessage.cs @@ -0,0 +1,250 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Message; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based message sent by a user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketUserMessage : SocketMessage, IUserMessage + { + private bool _isMentioningEveryone, _isTTS, _isPinned; + private long? _editedTimestampTicks; + private IUserMessage _referencedMessage; + private ImmutableArray _attachments = ImmutableArray.Create(); + private ImmutableArray _embeds = ImmutableArray.Create(); + private ImmutableArray _tags = ImmutableArray.Create(); + private ImmutableArray _roleMentions = ImmutableArray.Create(); + private ImmutableArray _stickers = ImmutableArray.Create(); + + /// + public override bool IsTTS => _isTTS; + /// + public override bool IsPinned => _isPinned; + /// + public override bool IsSuppressed => Flags.HasValue && Flags.Value.HasFlag(MessageFlags.SuppressEmbeds); + /// + public override DateTimeOffset? EditedTimestamp => DateTimeUtils.FromTicks(_editedTimestampTicks); + /// + public override bool MentionedEveryone => _isMentioningEveryone; + /// + public override IReadOnlyCollection Attachments => _attachments; + /// + public override IReadOnlyCollection Embeds => _embeds; + /// + public override IReadOnlyCollection Tags => _tags; + /// + public override IReadOnlyCollection MentionedChannels => MessageHelper.FilterTagsByValue(TagType.ChannelMention, _tags); + /// + public override IReadOnlyCollection MentionedRoles => _roleMentions; + /// + public override IReadOnlyCollection Stickers => _stickers; + /// + public IUserMessage ReferencedMessage => _referencedMessage; + + /// + public IMessageInteractionMetadata InteractionMetadata { get; internal set; } + + /// + public MessageResolvedData ResolvedData { get; internal set; } + + internal SocketUserMessage(DiscordSocketClient discord, ulong id, ISocketMessageChannel channel, SocketUser author, MessageSource source) + : base(discord, id, channel, author, source) + { + } + internal new static SocketUserMessage Create(DiscordSocketClient discord, ClientState state, SocketUser author, ISocketMessageChannel channel, Model model) + { + var entity = new SocketUserMessage(discord, model.Id, channel, author, MessageHelper.GetSource(model)); + entity.Update(state, model); + return entity; + } + + internal override void Update(ClientState state, Model model) + { + base.Update(state, model); + + SocketGuild guild = (Channel as SocketGuildChannel)?.Guild; + + if (model.IsTextToSpeech.IsSpecified) + _isTTS = model.IsTextToSpeech.Value; + if (model.Pinned.IsSpecified) + _isPinned = model.Pinned.Value; + if (model.EditedTimestamp.IsSpecified) + _editedTimestampTicks = model.EditedTimestamp.Value?.UtcTicks; + if (model.MentionEveryone.IsSpecified) + _isMentioningEveryone = model.MentionEveryone.Value; + if (model.RoleMentions.IsSpecified) + _roleMentions = model.RoleMentions.Value.Select(x => guild.GetRole(x)).ToImmutableArray(); + + if (model.Attachments.IsSpecified) + { + var value = model.Attachments.Value; + if (value.Length > 0) + { + var attachments = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + attachments.Add(Attachment.Create(value[i], Discord)); + _attachments = attachments.ToImmutable(); + } + else + _attachments = ImmutableArray.Create(); + } + + if (model.Embeds.IsSpecified) + { + var value = model.Embeds.Value; + if (value.Length > 0) + { + var embeds = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + embeds.Add(value[i].ToEntity()); + _embeds = embeds.ToImmutable(); + } + else + _embeds = ImmutableArray.Create(); + } + + if (model.Content.IsSpecified) + { + var text = model.Content.Value; + _tags = MessageHelper.ParseTags(text, Channel, guild, MentionedUsers); + model.Content = text; + } + + if (model.ReferencedMessage.IsSpecified && model.ReferencedMessage.Value != null) + { + var refMsg = model.ReferencedMessage.Value; + ulong? webhookId = refMsg.WebhookId.ToNullable(); + SocketUser refMsgAuthor = null; + if (refMsg.Author.IsSpecified) + { + if (guild != null) + { + if (webhookId != null) + refMsgAuthor = SocketWebhookUser.Create(guild, state, refMsg.Author.Value, webhookId.Value); + else + refMsgAuthor = guild.GetUser(refMsg.Author.Value.Id); + } + else + refMsgAuthor = (Channel as SocketChannel)?.GetUser(refMsg.Author.Value.Id); + if (refMsgAuthor == null) + refMsgAuthor = SocketUnknownUser.Create(Discord, state, refMsg.Author.Value); + } + else + // Message author wasn't specified in the payload, so create a completely anonymous unknown user + refMsgAuthor = new SocketUnknownUser(Discord, id: 0); + _referencedMessage = SocketUserMessage.Create(Discord, state, refMsgAuthor, Channel, refMsg); + } + + if (model.StickerItems.IsSpecified) + { + var value = model.StickerItems.Value; + if (value.Length > 0) + { + var stickers = ImmutableArray.CreateBuilder(value.Length); + for (int i = 0; i < value.Length; i++) + { + var stickerItem = value[i]; + SocketSticker sticker = null; + + if (guild != null) + sticker = guild.GetSticker(stickerItem.Id); + + if (sticker == null) + sticker = Discord.GetSticker(stickerItem.Id); + + // if they want to auto resolve + if (Discord.AlwaysResolveStickers) + { + sticker = Task.Run(async () => await Discord.GetStickerAsync(stickerItem.Id).ConfigureAwait(false)).GetAwaiter().GetResult(); + } + + // if its still null, create an unknown + if (sticker == null) + sticker = SocketUnknownSticker.Create(Discord, stickerItem); + + stickers.Add(sticker); + } + + _stickers = stickers.ToImmutable(); + } + else + _stickers = ImmutableArray.Create(); + } + + if (model.Resolved.IsSpecified) + { + var users = model.Resolved.Value.Users.IsSpecified + ? model.Resolved.Value.Users.Value.Select(x => RestUser.Create(Discord, x.Value)).ToImmutableArray() + : ImmutableArray.Empty; + + var members = model.Resolved.Value.Members.IsSpecified + ? model.Resolved.Value.Members.Value.Select(x => + { + x.Value.User = model.Resolved.Value.Users.Value.TryGetValue(x.Key, out var user) + ? user + : null; + + return RestGuildUser.Create(Discord, guild, x.Value); + }).ToImmutableArray() + : ImmutableArray.Empty; + + var roles = model.Resolved.Value.Roles.IsSpecified + ? model.Resolved.Value.Roles.Value.Select(x => RestRole.Create(Discord, guild, x.Value)).ToImmutableArray() + : ImmutableArray.Empty; + + var channels = model.Resolved.Value.Channels.IsSpecified + ? model.Resolved.Value.Channels.Value.Select(x => RestChannel.Create(Discord, x.Value, guild)).ToImmutableArray() + : ImmutableArray.Empty; + + ResolvedData = new MessageResolvedData(users, members, roles, channels); + } + + if (model.InteractionMetadata.IsSpecified) + InteractionMetadata = model.InteractionMetadata.Value.ToInteractionMetadata(); + } + + /// + /// Only the author of a message may modify the message. + /// Message content is too long, length must be less or equal to . + public Task ModifyAsync(Action func, RequestOptions options = null) + => MessageHelper.ModifyAsync(this, Discord, func, options); + + /// + public Task PinAsync(RequestOptions options = null) + => MessageHelper.PinAsync(this, Discord, options); + /// + public Task UnpinAsync(RequestOptions options = null) + => MessageHelper.UnpinAsync(this, Discord, options); + + public string Resolve(int startIndex, TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + => MentionUtils.Resolve(this, startIndex, userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + /// + public string Resolve(TagHandling userHandling = TagHandling.Name, TagHandling channelHandling = TagHandling.Name, + TagHandling roleHandling = TagHandling.Name, TagHandling everyoneHandling = TagHandling.Ignore, TagHandling emojiHandling = TagHandling.Name) + => MentionUtils.Resolve(this, 0, userHandling, channelHandling, roleHandling, everyoneHandling, emojiHandling); + + /// + /// This operation may only be called on a channel. + public Task CrosspostAsync(RequestOptions options = null) + { + if (!(Channel is INewsChannel)) + { + throw new InvalidOperationException("Publishing (crossposting) is only valid in news channels."); + } + + return MessageHelper.CrosspostAsync(this, Discord, options); + } + + private string DebuggerDisplay => $"{Author}: {Content} ({Id}{(Attachments.Count > 0 ? $", {Attachments.Count} Attachments" : "")})"; + internal new SocketUserMessage Clone() => MemberwiseClone() as SocketUserMessage; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Roles/SocketRole.cs b/src/Discord.Net.WebSocket/Entities/Roles/SocketRole.cs new file mode 100644 index 0000000..d5b52da --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Roles/SocketRole.cs @@ -0,0 +1,134 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.Role; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based role to be given to a guild user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketRole : SocketEntity, IRole + { + #region SocketRole + /// + /// Gets the guild that owns this role. + /// + /// + /// A representing the parent guild of this role. + /// + public SocketGuild Guild { get; } + + /// + public Color Color { get; private set; } + /// + public bool IsHoisted { get; private set; } + /// + public bool IsManaged { get; private set; } + /// + public bool IsMentionable { get; private set; } + /// + public string Name { get; private set; } + /// + public Emoji Emoji { get; private set; } + /// + public string Icon { get; private set; } + /// + public GuildPermissions Permissions { get; private set; } + /// + public int Position { get; private set; } + /// + public RoleTags Tags { get; private set; } + + /// + public RoleFlags Flags { get; private set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + /// Returns a value that determines if the role is an @everyone role. + /// + /// + /// if the role is @everyone; otherwise . + /// + public bool IsEveryone => Id == Guild.Id; + /// + public string Mention => IsEveryone ? "@everyone" : MentionUtils.MentionRole(Id); + + /// + /// Returns an IEnumerable containing all that have this role. + /// + public IEnumerable Members + => Guild.Users.Where(x => x.Roles.Any(r => r.Id == Id)); + + internal SocketRole(SocketGuild guild, ulong id) + : base(guild?.Discord, id) + { + Guild = guild; + } + internal static SocketRole Create(SocketGuild guild, ClientState state, Model model) + { + var entity = new SocketRole(guild, model.Id); + entity.Update(state, model); + return entity; + } + internal void Update(ClientState state, Model model) + { + Name = model.Name; + IsHoisted = model.Hoist; + IsManaged = model.Managed; + IsMentionable = model.Mentionable; + Position = model.Position; + Color = new Color(model.Color); + Permissions = new GuildPermissions(model.Permissions); + Flags = model.Flags; + + if (model.Tags.IsSpecified) + Tags = model.Tags.Value.ToEntity(); + + if (model.Icon.IsSpecified) + { + Icon = model.Icon.Value; + } + + if (model.Emoji.IsSpecified) + { + Emoji = new Emoji(model.Emoji.Value); + } + } + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + => RoleHelper.ModifyAsync(this, Discord, func, options); + /// + public Task DeleteAsync(RequestOptions options = null) + => RoleHelper.DeleteAsync(this, Discord, options); + + /// + public string GetIconUrl() + => CDN.GetGuildRoleIconUrl(Id, Icon); + + /// + /// Gets the name of the role. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Name; + private string DebuggerDisplay => $"{Name} ({Id})"; + internal SocketRole Clone() => MemberwiseClone() as SocketRole; + + /// + public int CompareTo(IRole role) => RoleUtils.Compare(this, role); + #endregion + + #region IRole + /// + IGuild IRole.Guild => Guild; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/SocketEntity.cs b/src/Discord.Net.WebSocket/Entities/SocketEntity.cs new file mode 100644 index 0000000..f76694e --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/SocketEntity.cs @@ -0,0 +1,18 @@ +using System; + +namespace Discord.WebSocket +{ + public abstract class SocketEntity : IEntity + where T : IEquatable + { + internal DiscordSocketClient Discord { get; } + /// + public T Id { get; } + + internal SocketEntity(DiscordSocketClient discord, T id) + { + Discord = discord; + Id = id; + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Stickers/SocketCustomSticker.cs b/src/Discord.Net.WebSocket/Entities/Stickers/SocketCustomSticker.cs new file mode 100644 index 0000000..6a51040 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Stickers/SocketCustomSticker.cs @@ -0,0 +1,81 @@ +using Discord.Rest; +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Sticker; + +namespace Discord.WebSocket +{ + /// + /// Represents a custom sticker within a guild received over the gateway. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketCustomSticker : SocketSticker, ICustomSticker + { + #region SocketCustomSticker + /// + /// Gets the user that uploaded the guild sticker. + /// + /// + /// + /// This may return in the WebSocket implementation due to incomplete user collection in + /// large guilds, or the bot doesn't have the MANAGE_EMOJIS_AND_STICKERS permission. + /// + /// + public SocketGuildUser Author + => AuthorId.HasValue ? Guild.GetUser(AuthorId.Value) : null; + + /// + /// Gets the guild the sticker was created in. + /// + public SocketGuild Guild { get; } + + /// + public ulong? AuthorId { get; set; } + + internal SocketCustomSticker(DiscordSocketClient client, ulong id, SocketGuild guild, ulong? authorId = null) + : base(client, id) + { + Guild = guild; + AuthorId = authorId; + } + + internal static SocketCustomSticker Create(DiscordSocketClient client, Model model, SocketGuild guild, ulong? authorId = null) + { + var entity = new SocketCustomSticker(client, model.Id, guild, authorId); + entity.Update(model); + return entity; + } + + /// + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + if (!Guild.CurrentUser.GuildPermissions.Has(GuildPermission.ManageEmojisAndStickers)) + throw new InvalidOperationException($"Missing permission {nameof(GuildPermission.ManageEmojisAndStickers)}"); + + var model = await GuildHelper.ModifyStickerAsync(Discord, Guild.Id, this, func, options); + + Update(model); + } + + /// + public async Task DeleteAsync(RequestOptions options = null) + { + await GuildHelper.DeleteStickerAsync(Discord, Guild.Id, this, options); + Guild.RemoveSticker(Id); + } + + internal SocketCustomSticker Clone() => MemberwiseClone() as SocketCustomSticker; + + private new string DebuggerDisplay => Guild == null ? base.DebuggerDisplay : $"{Name} in {Guild.Name} ({Id})"; + #endregion + + #region ICustomSticker + ulong? ICustomSticker.AuthorId + => AuthorId; + + IGuild ICustomSticker.Guild + => Guild; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Stickers/SocketSticker.cs b/src/Discord.Net.WebSocket/Entities/Stickers/SocketSticker.cs new file mode 100644 index 0000000..b9c122c --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Stickers/SocketSticker.cs @@ -0,0 +1,98 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Model = Discord.API.Sticker; + +namespace Discord.WebSocket +{ + /// + /// Represents a general sticker received over the gateway. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketSticker : SocketEntity, ISticker + { + /// + public virtual ulong PackId { get; private set; } + + /// + public string Name { get; protected set; } + + /// + public virtual string Description { get; private set; } + + /// + public virtual IReadOnlyCollection Tags { get; private set; } + + /// + public virtual StickerType Type { get; private set; } + + /// + public StickerFormatType Format { get; protected set; } + + /// + public virtual bool? IsAvailable { get; protected set; } + + /// + public virtual int? SortOrder { get; private set; } + + /// + public string GetStickerUrl() + => CDN.GetStickerUrl(Id, Format); + + internal SocketSticker(DiscordSocketClient client, ulong id) + : base(client, id) { } + + internal static SocketSticker Create(DiscordSocketClient client, Model model) + { + var entity = model.GuildId.IsSpecified + ? new SocketCustomSticker(client, model.Id, client.GetGuild(model.GuildId.Value), model.User.IsSpecified ? model.User.Value.Id : null) + : new SocketSticker(client, model.Id); + + entity.Update(model); + return entity; + } + + internal virtual void Update(Model model) + { + Name = model.Name; + Description = model.Description; + PackId = model.PackId; + IsAvailable = model.Available; + Format = model.FormatType; + Type = model.Type; + SortOrder = model.SortValue; + + Tags = model.Tags.IsSpecified + ? model.Tags.Value.Split(',').Select(x => x.Trim()).ToImmutableArray() + : ImmutableArray.Create(); + } + + internal string DebuggerDisplay => $"{Name} ({Id})"; + + /// + public override bool Equals(object obj) + { + if (obj is Model stickerModel) + { + return stickerModel.Name == Name && + stickerModel.Description == Description && + stickerModel.FormatType == Format && + stickerModel.Id == Id && + stickerModel.PackId == PackId && + stickerModel.Type == Type && + stickerModel.SortValue == SortOrder && + stickerModel.Available == IsAvailable && + (!stickerModel.Tags.IsSpecified || stickerModel.Tags.Value == string.Join(", ", Tags)); + } + + return base.Equals(obj); + } + + /// + public override int GetHashCode() + { + return base.GetHashCode(); + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Stickers/SocketUnknownSticker.cs b/src/Discord.Net.WebSocket/Entities/Stickers/SocketUnknownSticker.cs new file mode 100644 index 0000000..ca7d2d0 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Stickers/SocketUnknownSticker.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.StickerItem; + +namespace Discord.WebSocket +{ + /// + /// Represents an unknown sticker received over the gateway. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketUnknownSticker : SocketSticker + { + /// + public override IReadOnlyCollection Tags + => null; + + /// + public override string Description + => null; + + /// + public override ulong PackId + => 0; + /// + public override bool? IsAvailable + => null; + + /// + public override int? SortOrder + => null; + + /// + public new StickerType? Type + => null; + + internal SocketUnknownSticker(DiscordSocketClient client, ulong id) + : base(client, id) { } + + internal static SocketUnknownSticker Create(DiscordSocketClient client, Model model) + { + var entity = new SocketUnknownSticker(client, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Name = model.Name; + Format = model.FormatType; + } + + /// + /// Attempts to try to find the sticker. + /// + /// + /// The sticker representing this unknown stickers Id, if none is found then . + /// + public Task ResolveAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + => Discord.GetStickerAsync(Id, mode, options); + + private new string DebuggerDisplay => $"{Name} ({Id})"; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGlobalUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGlobalUser.cs new file mode 100644 index 0000000..5c93344 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGlobalUser.cs @@ -0,0 +1,58 @@ +using System; +using System.Diagnostics; +using System.Linq; +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class SocketGlobalUser : SocketUser + { + public override bool IsBot { get; internal set; } + public override string Username { get; internal set; } + public override ushort DiscriminatorValue { get; internal set; } + public override string AvatarId { get; internal set; } + public override string GlobalName { get; internal set; } + internal override SocketPresence Presence { get; set; } + + public override bool IsWebhook => false; + internal override SocketGlobalUser GlobalUser { get => this; set => throw new NotImplementedException(); } + + private readonly object _lockObj = new object(); + private ushort _references; + + private SocketGlobalUser(DiscordSocketClient discord, ulong id) + : base(discord, id) + { + } + internal static SocketGlobalUser Create(DiscordSocketClient discord, ClientState state, Model model) + { + var entity = new SocketGlobalUser(discord, model.Id); + entity.Update(state, model); + return entity; + } + + internal void AddRef() + { + checked + { + lock (_lockObj) + _references++; + } + } + internal void RemoveRef(DiscordSocketClient discord) + { + lock (_lockObj) + { + if (--_references <= 0) + discord.RemoveUser(Id); + } + } + + private string DebuggerDisplay => DiscriminatorValue != 0 + ? $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")}, Guild)" + : $"{Username} ({Id}{(IsBot ? ", Bot" : "")}, Guild)"; + + internal new SocketGlobalUser Clone() => MemberwiseClone() as SocketGlobalUser; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGroupUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGroupUser.cs new file mode 100644 index 0000000..1f16f34 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGroupUser.cs @@ -0,0 +1,83 @@ +using System; +using System.Diagnostics; +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based group user. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class SocketGroupUser : SocketUser, IGroupUser + { + #region SocketGroupUser + /// + /// Gets the group channel of the user. + /// + /// + /// A representing the channel of which the user belongs to. + /// + public SocketGroupChannel Channel { get; } + /// + internal override SocketGlobalUser GlobalUser { get; set; } + + /// + public override bool IsBot { get { return GlobalUser.IsBot; } internal set { GlobalUser.IsBot = value; } } + /// + public override string Username { get { return GlobalUser.Username; } internal set { GlobalUser.Username = value; } } + /// + public override ushort DiscriminatorValue { get { return GlobalUser.DiscriminatorValue; } internal set { GlobalUser.DiscriminatorValue = value; } } + /// + public override string AvatarId { get { return GlobalUser.AvatarId; } internal set { GlobalUser.AvatarId = value; } } + /// + public override string GlobalName { get { return GlobalUser.GlobalName; } internal set { GlobalUser.GlobalName = value; } } + /// + internal override SocketPresence Presence { get { return GlobalUser.Presence; } set { GlobalUser.Presence = value; } } + + /// + public override bool IsWebhook => false; + + internal SocketGroupUser(SocketGroupChannel channel, SocketGlobalUser globalUser) + : base(channel.Discord, globalUser.Id) + { + Channel = channel; + GlobalUser = globalUser; + } + internal static SocketGroupUser Create(SocketGroupChannel channel, ClientState state, Model model) + { + var entity = new SocketGroupUser(channel, channel.Discord.GetOrCreateUser(state, model)); + entity.Update(state, model); + return entity; + } + + private string DebuggerDisplay => DiscriminatorValue != 0 + ? $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")}, Group)" + : $"{Username} ({Id}{(IsBot ? ", Bot" : "")}, Group)"; + + internal new SocketGroupUser Clone() => MemberwiseClone() as SocketGroupUser; + #endregion + + #region IVoiceState + /// + bool IVoiceState.IsDeafened => false; + /// + bool IVoiceState.IsMuted => false; + /// + bool IVoiceState.IsSelfDeafened => false; + /// + bool IVoiceState.IsSelfMuted => false; + /// + bool IVoiceState.IsSuppressed => false; + /// + IVoiceChannel IVoiceState.VoiceChannel => null; + /// + string IVoiceState.VoiceSessionId => null; + /// + bool IVoiceState.IsStreaming => false; + /// + bool IVoiceState.IsVideoing => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs new file mode 100644 index 0000000..f34fd2f --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs @@ -0,0 +1,295 @@ +using Discord.Audio; +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using MemberModel = Discord.API.GuildMember; +using PresenceModel = Discord.API.Presence; +using UserModel = Discord.API.User; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based guild user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketGuildUser : SocketUser, IGuildUser + { + #region SocketGuildUser + private long? _premiumSinceTicks; + private long? _timedOutTicks; + private long? _joinedAtTicks; + private ImmutableArray _roleIds; + + internal override SocketGlobalUser GlobalUser { get; set; } + /// + /// Gets the guild the user is in. + /// + public SocketGuild Guild { get; } + + /// + public string DisplayName => Nickname ?? GlobalName ?? Username; + + /// + public string Nickname { get; private set; } + /// + public string DisplayAvatarId => GuildAvatarId ?? AvatarId; + /// + public string GuildAvatarId { get; private set; } + /// + public override bool IsBot { get { return GlobalUser.IsBot; } internal set { GlobalUser.IsBot = value; } } + /// + public override string Username { get { return GlobalUser.Username; } internal set { GlobalUser.Username = value; } } + /// + public override ushort DiscriminatorValue { get { return GlobalUser.DiscriminatorValue; } internal set { GlobalUser.DiscriminatorValue = value; } } + /// + public override string AvatarId { get { return GlobalUser.AvatarId; } internal set { GlobalUser.AvatarId = value; } } + /// + public override string GlobalName { get { return GlobalUser.GlobalName; } internal set { GlobalUser.GlobalName = value; } } + + /// + public GuildPermissions GuildPermissions => new GuildPermissions(Permissions.ResolveGuild(Guild, this)); + internal override SocketPresence Presence { get; set; } + + /// + public override bool IsWebhook => false; + /// + public bool IsSelfDeafened => VoiceState?.IsSelfDeafened ?? false; + /// + public bool IsSelfMuted => VoiceState?.IsSelfMuted ?? false; + /// + public bool IsSuppressed => VoiceState?.IsSuppressed ?? false; + /// + public bool IsDeafened => VoiceState?.IsDeafened ?? false; + /// + public bool IsMuted => VoiceState?.IsMuted ?? false; + /// + public bool IsStreaming => VoiceState?.IsStreaming ?? false; + /// + public bool IsVideoing => VoiceState?.IsVideoing ?? false; + /// + public DateTimeOffset? RequestToSpeakTimestamp => VoiceState?.RequestToSpeakTimestamp ?? null; + /// + public bool? IsPending { get; private set; } + + /// + public GuildUserFlags Flags { get; private set; } + + /// + public DateTimeOffset? JoinedAt => DateTimeUtils.FromTicks(_joinedAtTicks); + /// + /// Returns a collection of roles that the user possesses. + /// + public IReadOnlyCollection Roles + => _roleIds.Select(id => Guild.GetRole(id)).Where(x => x != null).ToReadOnlyCollection(() => _roleIds.Length); + /// + /// Returns the voice channel the user is in, or if none. + /// + public SocketVoiceChannel VoiceChannel => VoiceState?.VoiceChannel; + /// + public string VoiceSessionId => VoiceState?.VoiceSessionId ?? ""; + /// + /// Gets the voice connection status of the user if any. + /// + /// + /// A representing the user's voice status; if the user is not + /// connected to a voice channel. + /// + public SocketVoiceState? VoiceState => Guild.GetVoiceState(Id); + public AudioInStream AudioStream => Guild.GetAudioStream(Id); + /// + public DateTimeOffset? PremiumSince => DateTimeUtils.FromTicks(_premiumSinceTicks); + /// + public DateTimeOffset? TimedOutUntil + { + get + { + if (!_timedOutTicks.HasValue || _timedOutTicks.Value < 0) + return null; + else + return DateTimeUtils.FromTicks(_timedOutTicks); + } + } + + /// + /// Returns the position of the user within the role hierarchy. + /// + /// + /// The returned value equal to the position of the highest role the user has, or + /// if user is the server owner. + /// + public int Hierarchy + { + get + { + if (Guild.OwnerId == Id) + return int.MaxValue; + + int maxPos = 0; + for (int i = 0; i < _roleIds.Length; i++) + { + var role = Guild.GetRole(_roleIds[i]); + if (role != null && role.Position > maxPos) + maxPos = role.Position; + } + return maxPos; + } + } + + internal SocketGuildUser(SocketGuild guild, SocketGlobalUser globalUser) + : base(guild.Discord, globalUser.Id) + { + Guild = guild; + GlobalUser = globalUser; + } + internal static SocketGuildUser Create(SocketGuild guild, ClientState state, UserModel model) + { + var entity = new SocketGuildUser(guild, guild.Discord.GetOrCreateUser(state, model)); + entity.Update(state, model); + entity.UpdateRoles(new ulong[0]); + return entity; + } + internal static SocketGuildUser Create(SocketGuild guild, ClientState state, MemberModel model) + { + var entity = new SocketGuildUser(guild, guild.Discord.GetOrCreateUser(state, model.User)); + entity.Update(state, model); + if (!model.Roles.IsSpecified) + entity.UpdateRoles(new ulong[0]); + return entity; + } + internal static SocketGuildUser Create(SocketGuild guild, ClientState state, PresenceModel model) + { + var entity = new SocketGuildUser(guild, guild.Discord.GetOrCreateUser(state, model.User)); + entity.Update(state, model, false); + if (!model.Roles.IsSpecified) + entity.UpdateRoles(new ulong[0]); + return entity; + } + internal void Update(ClientState state, MemberModel model) + { + base.Update(state, model.User); + if (model.JoinedAt.IsSpecified) + _joinedAtTicks = model.JoinedAt.Value.UtcTicks; + if (model.Nick.IsSpecified) + Nickname = model.Nick.Value; + if (model.Avatar.IsSpecified) + GuildAvatarId = model.Avatar.Value; + if (model.Roles.IsSpecified) + UpdateRoles(model.Roles.Value); + if (model.PremiumSince.IsSpecified) + _premiumSinceTicks = model.PremiumSince.Value?.UtcTicks; + if (model.TimedOutUntil.IsSpecified) + _timedOutTicks = model.TimedOutUntil.Value?.UtcTicks; + if (model.Pending.IsSpecified) + IsPending = model.Pending.Value; + + Flags = model.Flags; + } + internal void Update(ClientState state, PresenceModel model, bool updatePresence) + { + if (updatePresence) + { + Update(model); + } + if (model.Nick.IsSpecified) + Nickname = model.Nick.Value; + if (model.Roles.IsSpecified) + UpdateRoles(model.Roles.Value); + if (model.PremiumSince.IsSpecified) + _premiumSinceTicks = model.PremiumSince.Value?.UtcTicks; + } + + internal override void Update(PresenceModel model) + { + Presence ??= new SocketPresence(); + + Presence.Update(model); + GlobalUser.Update(model); + } + + private void UpdateRoles(ulong[] roleIds) + { + var roles = ImmutableArray.CreateBuilder(roleIds.Length + 1); + roles.Add(Guild.Id); + for (int i = 0; i < roleIds.Length; i++) + roles.Add(roleIds[i]); + _roleIds = roles.ToImmutable(); + } + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + => UserHelper.ModifyAsync(this, Discord, func, options); + /// + public Task KickAsync(string reason = null, RequestOptions options = null) + => UserHelper.KickAsync(this, Discord, reason, options); + /// + public Task AddRoleAsync(ulong roleId, RequestOptions options = null) + => AddRolesAsync(new[] { roleId }, options); + /// + public Task AddRoleAsync(IRole role, RequestOptions options = null) + => AddRoleAsync(role.Id, options); + /// + public Task AddRolesAsync(IEnumerable roleIds, RequestOptions options = null) + => UserHelper.AddRolesAsync(this, Discord, roleIds, options); + /// + public Task AddRolesAsync(IEnumerable roles, RequestOptions options = null) + => AddRolesAsync(roles.Select(x => x.Id), options); + /// + public Task RemoveRoleAsync(ulong roleId, RequestOptions options = null) + => RemoveRolesAsync(new[] { roleId }, options); + /// + public Task RemoveRoleAsync(IRole role, RequestOptions options = null) + => RemoveRoleAsync(role.Id, options); + /// + public Task RemoveRolesAsync(IEnumerable roleIds, RequestOptions options = null) + => UserHelper.RemoveRolesAsync(this, Discord, roleIds, options); + /// + public Task RemoveRolesAsync(IEnumerable roles, RequestOptions options = null) + => RemoveRolesAsync(roles.Select(x => x.Id)); + /// + public Task SetTimeOutAsync(TimeSpan span, RequestOptions options = null) + => UserHelper.SetTimeoutAsync(this, Discord, span, options); + /// + public Task RemoveTimeOutAsync(RequestOptions options = null) + => UserHelper.RemoveTimeOutAsync(this, Discord, options); + /// + public ChannelPermissions GetPermissions(IGuildChannel channel) + => new ChannelPermissions(Permissions.ResolveChannel(Guild, this, channel, GuildPermissions.RawValue)); + + /// + public string GetGuildAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => CDN.GetGuildUserAvatarUrl(Id, Guild.Id, GuildAvatarId, size, format); + + /// + public override string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => GetGuildAvatarUrl(format, size) ?? base.GetDisplayAvatarUrl(format, size); + + private string DebuggerDisplay => DiscriminatorValue != 0 + ? $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")}, Guild)" + : $"{Username} ({Id}{(IsBot ? ", Bot" : "")}, Guild)"; + + internal new SocketGuildUser Clone() + { + var clone = MemberwiseClone() as SocketGuildUser; + clone.GlobalUser = GlobalUser.Clone(); + return clone; + } + #endregion + + #region IGuildUser + /// + IGuild IGuildUser.Guild => Guild; + /// + ulong IGuildUser.GuildId => Guild.Id; + /// + IReadOnlyCollection IGuildUser.RoleIds => _roleIds; + + //IVoiceState + /// + IVoiceChannel IVoiceState.VoiceChannel => VoiceChannel; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs new file mode 100644 index 0000000..5250e15 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketPresence.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using Model = Discord.API.Presence; + +namespace Discord.WebSocket +{ + /// + /// Represents the WebSocket user's presence status. This may include their online status and their activity. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketPresence : IPresence + { + /// + public UserStatus Status { get; private set; } + /// + public IReadOnlyCollection ActiveClients { get; private set; } + /// + public IReadOnlyCollection Activities { get; private set; } + + internal SocketPresence() { } + internal SocketPresence(UserStatus status, IImmutableSet activeClients, IImmutableList activities) + { + Status = status; + ActiveClients = activeClients ?? ImmutableHashSet.Empty; + Activities = activities ?? ImmutableList.Empty; + } + + internal static SocketPresence Create(Model model) + { + var entity = new SocketPresence(); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + Status = model.Status; + ActiveClients = ConvertClientTypesDict(model.ClientStatus.GetValueOrDefault()) ?? ImmutableArray.Empty; + Activities = ConvertActivitiesList(model.Activities) ?? ImmutableArray.Empty; + } + + /// + /// Creates a new containing all of the client types + /// where a user is active from the data supplied in the Presence update frame. + /// + /// + /// A dictionary keyed by the + /// and where the value is the . + /// + /// + /// A collection of all s that this user is active. + /// + private static IReadOnlyCollection ConvertClientTypesDict(IDictionary clientTypesDict) + { + if (clientTypesDict == null || clientTypesDict.Count == 0) + return ImmutableHashSet.Empty; + var set = new HashSet(); + foreach (var key in clientTypesDict.Keys) + { + if (Enum.TryParse(key, true, out ClientType type)) + set.Add(type); + // quietly discard ClientTypes that do not match + } + return set.ToImmutableHashSet(); + } + /// + /// Creates a new containing all the activities + /// that a user has from the data supplied in the Presence update frame. + /// + /// + /// A list of . + /// + /// + /// A list of all that this user currently has available. + /// + private static IImmutableList ConvertActivitiesList(IList activities) + { + if (activities == null || activities.Count == 0) + return ImmutableList.Empty; + var list = new List(); + foreach (var activity in activities) + list.Add(activity.ToEntity()); + return list.ToImmutableList(); + } + + /// + /// Gets the status of the user. + /// + /// + /// A string that resolves to . + /// + public override string ToString() => Status.ToString(); + private string DebuggerDisplay => $"{Status}{(Activities?.FirstOrDefault()?.Name ?? "")}"; + + internal SocketPresence Clone() => MemberwiseClone() as SocketPresence; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketSelfUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketSelfUser.cs new file mode 100644 index 0000000..979a111 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketSelfUser.cs @@ -0,0 +1,130 @@ +using Discord.Rest; +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + /// + /// Represents the logged-in WebSocket-based user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketSelfUser : SocketUser, ISelfUser + { + /// + public string Email { get; private set; } + /// + public bool IsVerified { get; private set; } + /// + public bool IsMfaEnabled { get; private set; } + internal override SocketGlobalUser GlobalUser { get; set; } + + /// + /// Gets the hash of the banner. + /// + /// + /// if the user has no banner set. + /// + public string BannerId { get; internal set; } + + /// + /// Gets the color of the banner. + /// + /// + /// if the user has no banner set. + /// + public Color? BannerColor { get; internal set; } + + /// + public override bool IsBot { get { return GlobalUser.IsBot; } internal set { GlobalUser.IsBot = value; } } + /// + public override string Username { get { return GlobalUser.Username; } internal set { GlobalUser.Username = value; } } + /// + public override ushort DiscriminatorValue { get { return GlobalUser.DiscriminatorValue; } internal set { GlobalUser.DiscriminatorValue = value; } } + /// + public override string AvatarId { get { return GlobalUser.AvatarId; } internal set { GlobalUser.AvatarId = value; } } + /// + public override string GlobalName { get { return GlobalUser.GlobalName; } internal set { GlobalUser.GlobalName = value; } } + /// + internal override SocketPresence Presence { get { return GlobalUser.Presence; } set { GlobalUser.Presence = value; } } + /// + public UserProperties Flags { get; internal set; } + /// + public PremiumType PremiumType { get; internal set; } + /// + public string Locale { get; internal set; } + + /// + public override bool IsWebhook => false; + + internal SocketSelfUser(DiscordSocketClient discord, SocketGlobalUser globalUser) + : base(discord, globalUser.Id) + { + GlobalUser = globalUser; + } + internal static SocketSelfUser Create(DiscordSocketClient discord, ClientState state, Model model) + { + var entity = new SocketSelfUser(discord, discord.GetOrCreateSelfUser(state, model)); + entity.Update(state, model); + return entity; + } + internal override bool Update(ClientState state, Model model) + { + bool hasGlobalChanges = base.Update(state, model); + if (model.Email.IsSpecified) + { + Email = model.Email.Value; + hasGlobalChanges = true; + } + if (model.Verified.IsSpecified) + { + IsVerified = model.Verified.Value; + hasGlobalChanges = true; + } + if (model.MfaEnabled.IsSpecified) + { + IsMfaEnabled = model.MfaEnabled.Value; + hasGlobalChanges = true; + } + if (model.Flags.IsSpecified && model.Flags.Value != Flags) + { + Flags = (UserProperties)model.Flags.Value; + hasGlobalChanges = true; + } + if (model.PremiumType.IsSpecified && model.PremiumType.Value != PremiumType) + { + PremiumType = model.PremiumType.Value; + hasGlobalChanges = true; + } + if (model.Locale.IsSpecified && model.Locale.Value != Locale) + { + Locale = model.Locale.Value; + hasGlobalChanges = true; + } + + if (model.BannerColor.IsSpecified && model.BannerColor.Value != BannerColor) + { + BannerColor = model.BannerColor.Value; + hasGlobalChanges = true; + } + + if (model.Banner.IsSpecified && model.Banner.Value != BannerId) + { + BannerId = model.Banner.Value; + hasGlobalChanges = true; + } + return hasGlobalChanges; + } + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) + => UserHelper.ModifyAsync(this, Discord, func, options); + + private string DebuggerDisplay => DiscriminatorValue != 0 + ? $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")}, Self)" + : $"{Username} ({Id}{(IsBot ? ", Bot" : "")}, Self)"; + + internal new SocketSelfUser Clone() => MemberwiseClone() as SocketSelfUser; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketThreadUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketThreadUser.cs new file mode 100644 index 0000000..429dc52 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketThreadUser.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.ThreadMember; + +namespace Discord.WebSocket +{ + /// + /// Represents a thread user received over the gateway. + /// + public class SocketThreadUser : SocketUser, IThreadUser, IGuildUser + { + /// + /// Gets the this user is in. + /// + public SocketThreadChannel Thread { get; private set; } + + /// + public DateTimeOffset ThreadJoinedAt { get; private set; } + + /// + /// Gets the guild this user is in. + /// + public SocketGuild Guild { get; private set; } + + /// + public DateTimeOffset? JoinedAt + => GuildUser.JoinedAt; + + /// + public string DisplayName + => GuildUser.Nickname ?? GuildUser.GlobalName ?? GuildUser.Username; + + /// + public string Nickname + => GuildUser.Nickname; + + /// + public DateTimeOffset? PremiumSince + => GuildUser.PremiumSince; + + /// + public DateTimeOffset? TimedOutUntil + => GuildUser.TimedOutUntil; + + /// + public bool? IsPending + => GuildUser.IsPending; + /// + public int Hierarchy + => GuildUser.Hierarchy; + + /// + public override string AvatarId + { + get => GuildUser.AvatarId; + internal set => GuildUser.AvatarId = value; + } + + /// + public override string GlobalName + { + get => GlobalUser.GlobalName; + internal set => GlobalUser.GlobalName = value; + } + /// + public string DisplayAvatarId => GuildAvatarId ?? AvatarId; + + /// + public string GuildAvatarId + => GuildUser.GuildAvatarId; + + /// + public override ushort DiscriminatorValue + { + get => GuildUser.DiscriminatorValue; + internal set => GuildUser.DiscriminatorValue = value; + } + + /// + public override bool IsBot + { + get => GuildUser.IsBot; + internal set => GuildUser.IsBot = value; + } + + /// + public override bool IsWebhook + => GuildUser.IsWebhook; + + /// + public override string Username + { + get => GuildUser.Username; + internal set => GuildUser.Username = value; + } + + /// + public bool IsDeafened + => GuildUser.IsDeafened; + + /// + public bool IsMuted + => GuildUser.IsMuted; + + /// + public bool IsSelfDeafened + => GuildUser.IsSelfDeafened; + + /// + public bool IsSelfMuted + => GuildUser.IsSelfMuted; + + /// + public bool IsSuppressed + => GuildUser.IsSuppressed; + + /// + public IVoiceChannel VoiceChannel + => GuildUser.VoiceChannel; + + /// + public string VoiceSessionId + => GuildUser.VoiceSessionId; + + /// + public bool IsStreaming + => GuildUser.IsStreaming; + + /// + public bool IsVideoing + => GuildUser.IsVideoing; + + /// + public GuildUserFlags Flags + => GuildUser.Flags; + + /// + public DateTimeOffset? RequestToSpeakTimestamp + => GuildUser.RequestToSpeakTimestamp; + + /// + public SocketGuildUser GuildUser { get; private set; } + + internal SocketThreadUser(SocketGuild guild, SocketThreadChannel thread, ulong userId, SocketGuildUser member = null) + : base(guild.Discord, userId) + { + Thread = thread; + Guild = guild; + if (member is not null) + GuildUser = member; + } + + internal static SocketThreadUser Create(SocketGuild guild, SocketThreadChannel thread, Model model, SocketGuildUser guildUser = null) + { + var entity = new SocketThreadUser(guild, thread, model.UserId.Value, guildUser); + entity.Update(model); + return entity; + } + + internal static SocketThreadUser Create(SocketGuild guild, SocketThreadChannel thread, SocketGuildUser owner) + { + // this is used for creating the owner of the thread. + var entity = new SocketThreadUser(guild, thread, owner.Id, owner); + entity.Update(new Model + { + JoinTimestamp = thread.CreatedAt, + }); + return entity; + } + + internal void Update(Model model) + { + ThreadJoinedAt = model.JoinTimestamp; + if (model.GuildMember.IsSpecified) + GuildUser = Guild.AddOrUpdateUser(model.GuildMember.Value); + } + + /// + public ChannelPermissions GetPermissions(IGuildChannel channel) => GuildUser.GetPermissions(channel); + + /// + public Task KickAsync(string reason = null, RequestOptions options = null) => GuildUser.KickAsync(reason, options); + + /// + public Task ModifyAsync(Action func, RequestOptions options = null) => GuildUser.ModifyAsync(func, options); + + /// + public Task AddRoleAsync(ulong roleId, RequestOptions options = null) => GuildUser.AddRoleAsync(roleId, options); + + /// + public Task AddRoleAsync(IRole role, RequestOptions options = null) => GuildUser.AddRoleAsync(role, options); + + /// + public Task AddRolesAsync(IEnumerable roleIds, RequestOptions options = null) => GuildUser.AddRolesAsync(roleIds, options); + + /// + public Task AddRolesAsync(IEnumerable roles, RequestOptions options = null) => GuildUser.AddRolesAsync(roles, options); + + /// + public Task RemoveRoleAsync(ulong roleId, RequestOptions options = null) => GuildUser.RemoveRoleAsync(roleId, options); + + /// + public Task RemoveRoleAsync(IRole role, RequestOptions options = null) => GuildUser.RemoveRoleAsync(role, options); + + /// + public Task RemoveRolesAsync(IEnumerable roleIds, RequestOptions options = null) => GuildUser.RemoveRolesAsync(roleIds, options); + + /// + public Task RemoveRolesAsync(IEnumerable roles, RequestOptions options = null) => GuildUser.RemoveRolesAsync(roles, options); + /// + public Task SetTimeOutAsync(TimeSpan span, RequestOptions options = null) => GuildUser.SetTimeOutAsync(span, options); + + /// + public Task RemoveTimeOutAsync(RequestOptions options = null) => GuildUser.RemoveTimeOutAsync(options); + + /// + IThreadChannel IThreadUser.Thread => Thread; + + /// + IGuild IThreadUser.Guild => Guild; + + /// + IGuild IGuildUser.Guild => Guild; + + /// + IGuildUser IThreadUser.GuildUser => GuildUser; + + /// + ulong IGuildUser.GuildId => Guild.Id; + + /// + GuildPermissions IGuildUser.GuildPermissions => GuildUser.GuildPermissions; + + /// + IReadOnlyCollection IGuildUser.RoleIds => GuildUser.Roles.Select(x => x.Id).ToImmutableArray(); + + /// + string IGuildUser.GetGuildAvatarUrl(ImageFormat format, ushort size) => GuildUser.GetGuildAvatarUrl(format, size); + + /// + public override string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => GuildUser.GetGuildAvatarUrl() ?? base.GetDisplayAvatarUrl(format, size); + + internal override SocketGlobalUser GlobalUser { get => GuildUser.GlobalUser; set => GuildUser.GlobalUser = value; } + + internal override SocketPresence Presence { get => GuildUser.Presence; set => GuildUser.Presence = value; } + + /// + /// Gets the guild user of this thread user. + /// + /// + public static explicit operator SocketGuildUser(SocketThreadUser user) => user.GuildUser; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketUnknownUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketUnknownUser.cs new file mode 100644 index 0000000..930518b --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketUnknownUser.cs @@ -0,0 +1,53 @@ +using System; +using System.Diagnostics; +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based user that is yet to be recognized by the client. + /// + /// + /// A user may not be recognized due to the user missing from the cache or failed to be recognized properly. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketUnknownUser : SocketUser + { + /// + public override string Username { get; internal set; } + /// + public override ushort DiscriminatorValue { get; internal set; } + /// + public override string AvatarId { get; internal set; } + /// + public override string GlobalName { get; internal set; } + + /// + public override bool IsBot { get; internal set; } + + /// + public override bool IsWebhook => false; + /// + internal override SocketPresence Presence { get { return new SocketPresence(UserStatus.Offline, null, null); } set { } } + /// + /// This field is not supported for an unknown user. + internal override SocketGlobalUser GlobalUser { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + internal SocketUnknownUser(DiscordSocketClient discord, ulong id) + : base(discord, id) + { + } + internal static SocketUnknownUser Create(DiscordSocketClient discord, ClientState state, Model model) + { + var entity = new SocketUnknownUser(discord, model.Id); + entity.Update(state, model); + return entity; + } + + private string DebuggerDisplay => DiscriminatorValue != 0 + ? $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")}, Unknown)" + : $"{Username} ({Id}{(IsBot ? ", Bot" : "")}, Unknown)"; + + internal new SocketUnknownUser Clone() => MemberwiseClone() as SocketUnknownUser; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs new file mode 100644 index 0000000..5705f66 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketUser.cs @@ -0,0 +1,158 @@ +using Discord.Rest; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Model = Discord.API.User; +using PresenceModel = Discord.API.Presence; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public abstract class SocketUser : SocketEntity, IUser + { + /// + public abstract bool IsBot { get; internal set; } + /// + public abstract string Username { get; internal set; } + /// + public abstract ushort DiscriminatorValue { get; internal set; } + /// + public abstract string AvatarId { get; internal set; } + /// + public abstract bool IsWebhook { get; } + /// + public UserProperties? PublicFlags { get; private set; } + internal abstract SocketGlobalUser GlobalUser { get; set; } + internal abstract SocketPresence Presence { get; set; } + + /// + public abstract string GlobalName { get; internal set; } + + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + /// + public string Discriminator => DiscriminatorValue.ToString("D4"); + /// + public string Mention => MentionUtils.MentionUser(Id); + /// + public UserStatus Status => Presence.Status; + /// + public IReadOnlyCollection ActiveClients => Presence.ActiveClients ?? ImmutableHashSet.Empty; + /// + public IReadOnlyCollection Activities => Presence.Activities ?? ImmutableList.Empty; + + /// + public string AvatarDecorationHash { get; private set; } + + /// + public ulong? AvatarDecorationSkuId { get; private set; } + + /// + /// Gets mutual guilds shared with this user. + /// + /// + /// This property will only include guilds in the same . + /// + public IReadOnlyCollection MutualGuilds + => Discord.Guilds.Where(g => g.GetUser(Id) != null).ToImmutableArray(); + + internal SocketUser(DiscordSocketClient discord, ulong id) + : base(discord, id) + { + } + internal virtual bool Update(ClientState state, Model model) + { + Presence ??= new SocketPresence(); + bool hasChanges = false; + if (model.Avatar.IsSpecified && model.Avatar.Value != AvatarId) + { + AvatarId = model.Avatar.Value; + hasChanges = true; + } + if (model.Discriminator.IsSpecified) + { + var newVal = ushort.Parse(model.Discriminator.GetValueOrDefault(null) ?? "0", NumberStyles.None, CultureInfo.InvariantCulture); + if (newVal != DiscriminatorValue) + { + DiscriminatorValue = ushort.Parse(model.Discriminator.GetValueOrDefault(null) ?? "0", NumberStyles.None, CultureInfo.InvariantCulture); + hasChanges = true; + } + } + if (model.Bot.IsSpecified && model.Bot.Value != IsBot) + { + IsBot = model.Bot.Value; + hasChanges = true; + } + if (model.Username.IsSpecified && model.Username.Value != Username) + { + Username = model.Username.Value; + hasChanges = true; + } + if (model.PublicFlags.IsSpecified && model.PublicFlags.Value != PublicFlags) + { + PublicFlags = model.PublicFlags.Value; + hasChanges = true; + } + if (model.GlobalName.IsSpecified && model.GlobalName.Value != GlobalName) + { + GlobalName = model.GlobalName.Value; + hasChanges = true; + } + if (model.AvatarDecoration is { IsSpecified: true, Value: not null } + && (model.AvatarDecoration.Value.Asset != AvatarDecorationHash || model.AvatarDecoration.Value.SkuId != AvatarDecorationSkuId)) + { + AvatarDecorationHash = model.AvatarDecoration.Value?.Asset; + AvatarDecorationSkuId = model.AvatarDecoration.Value?.SkuId; + hasChanges = true; + } + + return hasChanges; + } + + internal virtual void Update(PresenceModel model) + { + Presence ??= new SocketPresence(); + Presence.Update(model); + } + + /// + public async Task CreateDMChannelAsync(RequestOptions options = null) + => await UserHelper.CreateDMChannelAsync(this, Discord, options).ConfigureAwait(false); + + /// + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => CDN.GetUserAvatarUrl(Id, AvatarId, size, format); + + /// + public string GetDefaultAvatarUrl() + => DiscriminatorValue != 0 + ? CDN.GetDefaultUserAvatarUrl(DiscriminatorValue) + : CDN.GetDefaultUserAvatarUrl(Id); + + /// + public virtual string GetDisplayAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => GetAvatarUrl(format, size) ?? GetDefaultAvatarUrl(); + + public string GetAvatarDecorationUrl() + => AvatarDecorationHash is not null + ? CDN.GetAvatarDecorationUrl(AvatarDecorationHash) + : null; + + /// + /// Gets the full name of the user (e.g. Example#0001). + /// + /// + /// The full name of the user. + /// + public override string ToString() => Format.UsernameAndDiscriminator(this, Discord.FormatUsersInBidirectionalUnicode); + private string DebuggerDisplay => $"{Format.UsernameAndDiscriminator(this, Discord.FormatUsersInBidirectionalUnicode)} ({Id}{(IsBot ? ", Bot" : "")})"; + internal SocketUser Clone() => MemberwiseClone() as SocketUser; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketVoiceState.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketVoiceState.cs new file mode 100644 index 0000000..242112a --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketVoiceState.cs @@ -0,0 +1,99 @@ +using System; +using System.Diagnostics; +using Model = Discord.API.VoiceState; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket user's voice connection status. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public struct SocketVoiceState : IVoiceState + { + /// + /// Initializes a default with everything set to or . + /// + public static readonly SocketVoiceState Default = new SocketVoiceState(null, null, null, false, false, false, false, false, false, false); + + [Flags] + private enum Flags : byte + { + Normal = 0x00, + Suppressed = 0x01, + Muted = 0x02, + Deafened = 0x04, + SelfMuted = 0x08, + SelfDeafened = 0x10, + SelfStream = 0x20, + SelfVideo = 0x40, + } + + private readonly Flags _voiceStates; + + /// + /// Gets the voice channel that the user is currently in; or if none. + /// + public SocketVoiceChannel VoiceChannel { get; } + /// + public string VoiceSessionId { get; } + /// + public DateTimeOffset? RequestToSpeakTimestamp { get; private set; } + + /// + public bool IsMuted => (_voiceStates & Flags.Muted) != 0; + /// + public bool IsDeafened => (_voiceStates & Flags.Deafened) != 0; + /// + public bool IsSuppressed => (_voiceStates & Flags.Suppressed) != 0; + /// + public bool IsSelfMuted => (_voiceStates & Flags.SelfMuted) != 0; + /// + public bool IsSelfDeafened => (_voiceStates & Flags.SelfDeafened) != 0; + /// + public bool IsStreaming => (_voiceStates & Flags.SelfStream) != 0; + /// + public bool IsVideoing => (_voiceStates & Flags.SelfVideo) != 0; + + + internal SocketVoiceState(SocketVoiceChannel voiceChannel, DateTimeOffset? requestToSpeak, string sessionId, bool isSelfMuted, bool isSelfDeafened, bool isMuted, bool isDeafened, bool isSuppressed, bool isStream, bool isVideo) + { + VoiceChannel = voiceChannel; + VoiceSessionId = sessionId; + RequestToSpeakTimestamp = requestToSpeak; + + Flags voiceStates = Flags.Normal; + if (isSelfMuted) + voiceStates |= Flags.SelfMuted; + if (isSelfDeafened) + voiceStates |= Flags.SelfDeafened; + if (isMuted) + voiceStates |= Flags.Muted; + if (isDeafened) + voiceStates |= Flags.Deafened; + if (isSuppressed) + voiceStates |= Flags.Suppressed; + if (isStream) + voiceStates |= Flags.SelfStream; + if (isVideo) + voiceStates |= Flags.SelfVideo; + _voiceStates = voiceStates; + } + internal static SocketVoiceState Create(SocketVoiceChannel voiceChannel, Model model) + { + return new SocketVoiceState(voiceChannel, model.RequestToSpeakTimestamp.IsSpecified ? model.RequestToSpeakTimestamp.Value : null, model.SessionId, model.SelfMute, model.SelfDeaf, model.Mute, model.Deaf, model.Suppress, model.SelfStream, model.SelfVideo); + } + + /// + /// Gets the name of this voice channel. + /// + /// + /// A string that resolves to name of this voice channel; otherwise "Unknown". + /// + public override string ToString() => VoiceChannel?.Name ?? "Unknown"; + private string DebuggerDisplay => $"{VoiceChannel?.Name ?? "Unknown"} ({_voiceStates})"; + internal SocketVoiceState Clone() => this; + + /// + IVoiceChannel IVoiceState.VoiceChannel => VoiceChannel; + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs new file mode 100644 index 0000000..2c438e6 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.User; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based webhook user. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketWebhookUser : SocketUser, IWebhookUser + { + #region SocketWebhookUser + /// Gets the guild of this webhook. + public SocketGuild Guild { get; } + /// + public ulong WebhookId { get; } + + /// + public override string Username { get; internal set; } + /// + public override ushort DiscriminatorValue { get; internal set; } + /// + public override string AvatarId { get; internal set; } + /// + public override string GlobalName { get; internal set; } + + /// + public override bool IsBot { get; internal set; } + + /// + public override bool IsWebhook => true; + /// + internal override SocketPresence Presence { get { return new SocketPresence(UserStatus.Offline, null, null); } set { } } + internal override SocketGlobalUser GlobalUser { get => throw new NotImplementedException(); set => throw new NotImplementedException(); } + + internal SocketWebhookUser(SocketGuild guild, ulong id, ulong webhookId) + : base(guild.Discord, id) + { + Guild = guild; + WebhookId = webhookId; + } + internal static SocketWebhookUser Create(SocketGuild guild, ClientState state, Model model, ulong webhookId) + { + var entity = new SocketWebhookUser(guild, model.Id, webhookId); + entity.Update(state, model); + return entity; + } + + private string DebuggerDisplay => DiscriminatorValue != 0 + ? $"{Username}#{Discriminator} ({Id}{(IsBot ? ", Bot" : "")}, Webhook)" + : $"{Username} ({Id}{(IsBot ? ", Bot" : "")}, Webhook)"; + + internal new SocketWebhookUser Clone() => MemberwiseClone() as SocketWebhookUser; + #endregion + + #region IGuildUser + /// + IGuild IGuildUser.Guild => Guild; + /// + ulong IGuildUser.GuildId => Guild.Id; + /// + IReadOnlyCollection IGuildUser.RoleIds => ImmutableArray.Create(); + /// + DateTimeOffset? IGuildUser.JoinedAt => null; + /// + string IGuildUser.DisplayName => null; + /// + string IGuildUser.Nickname => null; + /// + string IGuildUser.DisplayAvatarId => null; + /// + string IGuildUser.GuildAvatarId => null; + /// + string IGuildUser.GetGuildAvatarUrl(ImageFormat format, ushort size) => null; + /// + DateTimeOffset? IGuildUser.PremiumSince => null; + /// + DateTimeOffset? IGuildUser.TimedOutUntil => null; + /// + bool? IGuildUser.IsPending => null; + /// + int IGuildUser.Hierarchy => 0; + /// + GuildPermissions IGuildUser.GuildPermissions => GuildPermissions.Webhook; + /// + GuildUserFlags IGuildUser.Flags => GuildUserFlags.None; + + /// + ChannelPermissions IGuildUser.GetPermissions(IGuildChannel channel) => Permissions.ToChannelPerms(channel, GuildPermissions.Webhook.RawValue); + /// + /// Webhook users cannot be kicked. + Task IGuildUser.KickAsync(string reason, RequestOptions options) => + throw new NotSupportedException("Webhook users cannot be kicked."); + + /// + /// Webhook users cannot be modified. + Task IGuildUser.ModifyAsync(Action func, RequestOptions options) => + throw new NotSupportedException("Webhook users cannot be modified."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.AddRoleAsync(ulong roleId, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.AddRoleAsync(IRole role, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.AddRolesAsync(IEnumerable roleIds, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.AddRolesAsync(IEnumerable roles, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.RemoveRoleAsync(ulong roleId, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.RemoveRoleAsync(IRole role, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.RemoveRolesAsync(IEnumerable roles, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + + /// + /// Roles are not supported on webhook users. + Task IGuildUser.RemoveRolesAsync(IEnumerable roles, RequestOptions options) => + throw new NotSupportedException("Roles are not supported on webhook users."); + /// + /// Timeouts are not supported on webhook users. + Task IGuildUser.SetTimeOutAsync(TimeSpan span, RequestOptions options) => + throw new NotSupportedException("Timeouts are not supported on webhook users."); + /// + /// Timeouts are not supported on webhook users. + Task IGuildUser.RemoveTimeOutAsync(RequestOptions options) => + throw new NotSupportedException("Timeouts are not supported on webhook users."); + #endregion + + #region IVoiceState + /// + bool IVoiceState.IsDeafened => false; + /// + bool IVoiceState.IsMuted => false; + /// + bool IVoiceState.IsSelfDeafened => false; + /// + bool IVoiceState.IsSelfMuted => false; + /// + bool IVoiceState.IsSuppressed => false; + /// + IVoiceChannel IVoiceState.VoiceChannel => null; + /// + string IVoiceState.VoiceSessionId => null; + /// + bool IVoiceState.IsStreaming => false; + /// + bool IVoiceState.IsVideoing => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; + #endregion + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Voice/SocketVoiceServer.cs b/src/Discord.Net.WebSocket/Entities/Voice/SocketVoiceServer.cs new file mode 100644 index 0000000..c5f13b1 --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Voice/SocketVoiceServer.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; + +namespace Discord.WebSocket +{ + /// + /// Represents a WebSocket-based voice server. + /// + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + public class SocketVoiceServer + { + /// + /// Gets the guild associated with the voice server. + /// + /// + /// A cached entity of the guild. + /// + public Cacheable Guild { get; } + /// + /// Gets the endpoint URL of the voice server host. + /// + /// + /// An URL representing the voice server host. + /// + public string Endpoint { get; } + /// + /// Gets the voice connection token. + /// + /// + /// A voice connection token. + /// + public string Token { get; } + + internal SocketVoiceServer(Cacheable guild, string endpoint, string token) + { + Guild = guild; + Endpoint = endpoint; + Token = token; + } + + private string DebuggerDisplay => $"SocketVoiceServer ({Guild.Id})"; + } +} diff --git a/src/Discord.Net.WebSocket/Extensions/EntityExtensions.cs b/src/Discord.Net.WebSocket/Extensions/EntityExtensions.cs new file mode 100644 index 0000000..46f5c1a --- /dev/null +++ b/src/Discord.Net.WebSocket/Extensions/EntityExtensions.cs @@ -0,0 +1,140 @@ +using Discord.Rest; +using System; +using System.Collections.Immutable; +using System.Linq; + +namespace Discord.WebSocket +{ + internal static class EntityExtensions + { + public static IActivity ToEntity(this API.Game model) + { + #region Custom Status Game + if (model.Id.IsSpecified && model.Id.Value == "custom") + { + return new CustomStatusGame() + { + Type = ActivityType.CustomStatus, + Name = model.Name, + State = model.State.IsSpecified ? model.State.Value : null, + Emote = model.Emoji.IsSpecified ? model.Emoji.Value.ToIEmote() : null, + CreatedAt = DateTimeOffset.FromUnixTimeMilliseconds(model.CreatedAt.Value), + }; + } + #endregion + + #region Spotify Game + if (model.SyncId.IsSpecified) + { + var assets = model.Assets.GetValueOrDefault()?.ToEntity(); + string albumText = assets?[1]?.Text; + string albumArtId = assets?[1]?.ImageId?.Replace("spotify:", ""); + var timestamps = model.Timestamps.IsSpecified ? model.Timestamps.Value.ToEntity() : null; + return new SpotifyGame + { + Name = model.Name, + SessionId = model.SessionId.GetValueOrDefault(), + TrackId = model.SyncId.Value, + TrackUrl = CDN.GetSpotifyDirectUrl(model.SyncId.Value), + AlbumTitle = albumText, + TrackTitle = model.Details.GetValueOrDefault(), + Artists = model.State.GetValueOrDefault()?.Split(';').Select(x => x?.Trim()).ToImmutableArray(), + StartedAt = timestamps?.Start, + EndsAt = timestamps?.End, + Duration = timestamps?.End - timestamps?.Start, + AlbumArtUrl = albumArtId != null ? CDN.GetSpotifyAlbumArtUrl(albumArtId) : null, + Type = ActivityType.Listening, + Flags = model.Flags.GetValueOrDefault(), + }; + } + #endregion + + #region Rich Game + if (model.ApplicationId.IsSpecified) + { + ulong appId = model.ApplicationId.Value; + var assets = model.Assets.GetValueOrDefault()?.ToEntity(appId); + return new RichGame + { + ApplicationId = appId, + Name = model.Name, + Details = model.Details.GetValueOrDefault(), + State = model.State.GetValueOrDefault(), + SmallAsset = assets?[0], + LargeAsset = assets?[1], + Party = model.Party.IsSpecified ? model.Party.Value.ToEntity() : null, + Secrets = model.Secrets.IsSpecified ? model.Secrets.Value.ToEntity() : null, + Timestamps = model.Timestamps.IsSpecified ? model.Timestamps.Value.ToEntity() : null, + Flags = model.Flags.GetValueOrDefault() + }; + } + #endregion + + #region Stream Game + if (model.StreamUrl.IsSpecified) + { + return new StreamingGame( + model.Name, + model.StreamUrl.Value) + { + Flags = model.Flags.GetValueOrDefault(), + Details = model.Details.GetValueOrDefault() + }; + } + #endregion + + #region Normal Game + return new Game(model.Name, model.Type.GetValueOrDefault() ?? ActivityType.Playing, + model.Flags.IsSpecified ? model.Flags.Value : ActivityProperties.None, + model.Details.GetValueOrDefault()); + #endregion + } + + // (Small, Large) + public static GameAsset[] ToEntity(this API.GameAssets model, ulong? appId = null) + { + return new GameAsset[] + { + model.SmallImage.IsSpecified ? new GameAsset + { + ApplicationId = appId, + ImageId = model.SmallImage.GetValueOrDefault(), + Text = model.SmallText.GetValueOrDefault() + } : null, + model.LargeImage.IsSpecified ? new GameAsset + { + ApplicationId = appId, + ImageId = model.LargeImage.GetValueOrDefault(), + Text = model.LargeText.GetValueOrDefault() + } : null, + }; + } + + public static GameParty ToEntity(this API.GameParty model) + { + // Discord will probably send bad data since they don't validate anything + long current = 0, cap = 0; + if (model.Size?.Length == 2) + { + current = model.Size[0]; + cap = model.Size[1]; + } + return new GameParty + { + Id = model.Id, + Members = current, + Capacity = cap, + }; + } + + public static GameSecrets ToEntity(this API.GameSecrets model) + { + return new GameSecrets(model.Match, model.Join, model.Spectate); + } + + public static GameTimestamps ToEntity(this API.GameTimestamps model) + { + return new GameTimestamps(model.Start.ToNullable(), model.End.ToNullable()); + } + } +} diff --git a/src/Discord.Net.WebSocket/GatewayReconnectException.cs b/src/Discord.Net.WebSocket/GatewayReconnectException.cs new file mode 100644 index 0000000..c5b15e0 --- /dev/null +++ b/src/Discord.Net.WebSocket/GatewayReconnectException.cs @@ -0,0 +1,19 @@ +using System; + +namespace Discord.WebSocket +{ + /// + /// The exception thrown when the gateway client has been requested to reconnect. + /// + public class GatewayReconnectException : Exception + { + /// + /// Initializes a new instance of the class with the reconnection + /// message. + /// + /// The reason why the gateway has been requested to reconnect. + public GatewayReconnectException(string message) + : base(message) + { } + } +} diff --git a/src/Discord.Net.WebSocket/Interactions/InteractionUtility.cs b/src/Discord.Net.WebSocket/Interactions/InteractionUtility.cs new file mode 100644 index 0000000..9b9eed2 --- /dev/null +++ b/src/Discord.Net.WebSocket/Interactions/InteractionUtility.cs @@ -0,0 +1,113 @@ +using Discord.WebSocket; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Interactions +{ + /// + /// Utility class containing helper methods for interacting with Discord Interactions. + /// + public static class InteractionUtility + { + /// + /// Wait for an Interaction event for a given amount of time as an asynchronous operation. + /// + /// Client that should be listened to for the event. + /// Timeout duration for this operation. + /// Delegate for checking whether an Interaction meets the requirements. + /// Token for canceling the wait operation. + /// + /// A Task representing the asynchronous waiting operation. If the user responded in the given amount of time, Task result contains the user response, + /// otherwise the Task result is . + /// + public static async Task WaitForInteractionAsync(BaseSocketClient client, TimeSpan timeout, + Predicate predicate, CancellationToken cancellationToken = default) + { + var tcs = new TaskCompletionSource(); + + var waitCancelSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task wait = Task.Delay(timeout, waitCancelSource.Token) + .ContinueWith((t) => + { + if (!t.IsCanceled) + tcs.SetResult(null); + }); + + cancellationToken.Register(() => tcs.SetCanceled()); + + client.InteractionCreated += HandleInteraction; + var result = await tcs.Task.ConfigureAwait(false); + client.InteractionCreated -= HandleInteraction; + + return result; + + Task HandleInteraction(SocketInteraction interaction) + { + if (predicate(interaction)) + { + waitCancelSource.Cancel(); + tcs.SetResult(interaction); + } + + return Task.CompletedTask; + } + } + + /// + /// Wait for an Message Component Interaction event for a given amount of time as an asynchronous operation . + /// + /// Client that should be listened to for the event. + /// The message that or should originate from. + /// Timeout duration for this operation. + /// Token for canceling the wait operation. + /// + /// A Task representing the asynchronous waiting operation with a result, + /// the result is null if the process timed out before receiving a valid Interaction. + /// + public static Task WaitForMessageComponentAsync(BaseSocketClient client, IUserMessage fromMessage, TimeSpan timeout, + CancellationToken cancellationToken = default) + { + bool Predicate(SocketInteraction interaction) => interaction is SocketMessageComponent component && + component.Message.Id == fromMessage.Id; + + return WaitForInteractionAsync(client, timeout, Predicate, cancellationToken); + } + + /// + /// Create a confirmation dialog and wait for user input asynchronously. + /// + /// Client that should be listened to for the event. + /// Send the confirmation prompt to this channel. + /// Timeout duration of this operation. + /// Optional custom prompt message. + /// Token for canceling the wait operation. + /// + /// A Task representing the asynchronous waiting operation with a result, + /// the result is if the user declined the prompt or didnt answer in time, if the user confirmed the prompt. + /// + public static async Task ConfirmAsync(BaseSocketClient client, IMessageChannel channel, TimeSpan timeout, string message = null, + CancellationToken cancellationToken = default) + { + message ??= "Would you like to continue?"; + var confirmId = $"confirm"; + var declineId = $"decline"; + + var component = new ComponentBuilder() + .WithButton("Confirm", confirmId, ButtonStyle.Success) + .WithButton("Cancel", declineId, ButtonStyle.Danger) + .Build(); + + var prompt = await channel.SendMessageAsync(message, components: component).ConfigureAwait(false); + + var response = await WaitForMessageComponentAsync(client, prompt, timeout, cancellationToken).ConfigureAwait(false) as SocketMessageComponent; + + await prompt.DeleteAsync().ConfigureAwait(false); + + if (response != null && response.Data.CustomId == confirmId) + return true; + else + return false; + } + } +} diff --git a/src/Discord.Net.WebSocket/Interactions/ShardedInteractionContext.cs b/src/Discord.Net.WebSocket/Interactions/ShardedInteractionContext.cs new file mode 100644 index 0000000..659e43c --- /dev/null +++ b/src/Discord.Net.WebSocket/Interactions/ShardedInteractionContext.cs @@ -0,0 +1,43 @@ +using Discord.WebSocket; + +namespace Discord.Interactions +{ + /// + /// The sharded variant of . + /// + public class ShardedInteractionContext : SocketInteractionContext, IInteractionContext + where TInteraction : SocketInteraction + { + /// + /// Gets the that the command will be executed with. + /// + public new DiscordShardedClient Client { get; } + + /// + /// Initializes a . + /// + /// The underlying client. + /// The underlying interaction. + public ShardedInteractionContext(DiscordShardedClient client, TInteraction interaction) + : base(client.GetShard(GetShardId(client, (interaction.User as SocketGuildUser)?.Guild)), interaction) + { + Client = client; + } + + private static int GetShardId(DiscordShardedClient client, IGuild guild) + => guild == null ? 0 : client.GetShardIdFor(guild); + } + + /// + /// The sharded variant of . + /// + public class ShardedInteractionContext : ShardedInteractionContext + { + /// + /// Initializes a . + /// + /// The underlying client. + /// The underlying interaction. + public ShardedInteractionContext(DiscordShardedClient client, SocketInteraction interaction) : base(client, interaction) { } + } +} diff --git a/src/Discord.Net.WebSocket/Interactions/SocketInteractionContext.cs b/src/Discord.Net.WebSocket/Interactions/SocketInteractionContext.cs new file mode 100644 index 0000000..f3b744c --- /dev/null +++ b/src/Discord.Net.WebSocket/Interactions/SocketInteractionContext.cs @@ -0,0 +1,94 @@ +using Discord.WebSocket; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Discord.Interactions +{ + /// + /// Represents a Web-Socket based context of an . + /// + public class SocketInteractionContext : IInteractionContext, IRouteMatchContainer + where TInteraction : SocketInteraction + { + /// + /// Gets the that the command will be executed with. + /// + public DiscordSocketClient Client { get; } + + /// + /// Gets the the command originated from. + /// + /// + /// Will be null if the command is from a DM Channel. + /// + public SocketGuild Guild { get; } + + /// + /// Gets the the command originated from. + /// + public ISocketMessageChannel Channel { get; } + + /// + /// Gets the who executed the command. + /// + public SocketUser User { get; } + + /// + /// Gets the the command was received with. + /// + public TInteraction Interaction { get; } + + /// + public IReadOnlyCollection SegmentMatches { get; private set; } + + /// + /// Initializes a new . + /// + /// The underlying client. + /// The underlying interaction. + public SocketInteractionContext(DiscordSocketClient client, TInteraction interaction) + { + Client = client; + Channel = interaction.Channel; + Guild = (interaction.User as SocketGuildUser)?.Guild; + User = interaction.User; + Interaction = interaction; + } + + /// + public void SetSegmentMatches(IEnumerable segmentMatches) => SegmentMatches = segmentMatches.ToImmutableArray(); + + //IRouteMatchContainer + /// + IEnumerable IRouteMatchContainer.SegmentMatches => SegmentMatches; + + // IInteractionContext + /// + IDiscordClient IInteractionContext.Client => Client; + + /// + IGuild IInteractionContext.Guild => Guild; + + /// + IMessageChannel IInteractionContext.Channel => Channel; + + /// + IUser IInteractionContext.User => User; + + /// + IDiscordInteraction IInteractionContext.Interaction => Interaction; + } + + /// + /// Represents a Web-Socket based context of an + /// + public class SocketInteractionContext : SocketInteractionContext + { + /// + /// Initializes a new + /// + /// The underlying client + /// The underlying interaction + public SocketInteractionContext(DiscordSocketClient client, SocketInteraction interaction) : base(client, interaction) { } + } +} diff --git a/src/Discord.Net.WebSocket/Net/DefaultUdpSocket.cs b/src/Discord.Net.WebSocket/Net/DefaultUdpSocket.cs new file mode 100644 index 0000000..91bb337 --- /dev/null +++ b/src/Discord.Net.WebSocket/Net/DefaultUdpSocket.cs @@ -0,0 +1,153 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.Udp +{ + internal class DefaultUdpSocket : IUdpSocket, IDisposable + { + public event Func ReceivedDatagram; + + private readonly SemaphoreSlim _lock; + private UdpClient _udp; + private IPEndPoint _destination; + private CancellationTokenSource _stopCancelTokenSource, _cancelTokenSource; + private CancellationToken _cancelToken, _parentToken; + private Task _task; + private bool _isDisposed; + + public ushort Port => (ushort)((_udp?.Client.LocalEndPoint as IPEndPoint)?.Port ?? 0); + + public DefaultUdpSocket() + { + _lock = new SemaphoreSlim(1, 1); + _stopCancelTokenSource = new CancellationTokenSource(); + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + StopInternalAsync(true).GetAwaiter().GetResult(); + _stopCancelTokenSource?.Dispose(); + _cancelTokenSource?.Dispose(); + _lock?.Dispose(); + } + _isDisposed = true; + } + } + public void Dispose() + { + Dispose(true); + } + + + public async Task StartAsync() + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await StartInternalAsync(_cancelToken).ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + public async Task StartInternalAsync(CancellationToken cancelToken) + { + await StopInternalAsync().ConfigureAwait(false); + + _stopCancelTokenSource?.Dispose(); + _cancelTokenSource?.Dispose(); + + _stopCancelTokenSource = new CancellationTokenSource(); + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _stopCancelTokenSource.Token); + _cancelToken = _cancelTokenSource.Token; + + _udp?.Dispose(); + _udp = new UdpClient(0); + + _task = RunAsync(_cancelToken); + } + public async Task StopAsync() + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await StopInternalAsync().ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + public async Task StopInternalAsync(bool isDisposing = false) + { + try + { _stopCancelTokenSource.Cancel(false); } + catch { } + + if (!isDisposing) + await (_task ?? Task.Delay(0)).ConfigureAwait(false); + + if (_udp != null) + { + try + { _udp.Dispose(); } + catch { } + _udp = null; + } + } + + public void SetDestination(string ip, int port) + { + _destination = new IPEndPoint(IPAddress.Parse(ip), port); + } + public void SetCancelToken(CancellationToken cancelToken) + { + _cancelTokenSource?.Dispose(); + + _parentToken = cancelToken; + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _stopCancelTokenSource.Token); + _cancelToken = _cancelTokenSource.Token; + } + + public Task SendAsync(byte[] data, int index, int count) + { + if (index != 0) //Should never happen? + { + var newData = new byte[count]; + Buffer.BlockCopy(data, index, newData, 0, count); + data = newData; + } + return _udp.SendAsync(data, count, _destination); + } + + private async Task RunAsync(CancellationToken cancelToken) + { + var closeTask = Task.Delay(-1, cancelToken); + while (!cancelToken.IsCancellationRequested) + { + var receiveTask = _udp.ReceiveAsync(); + + _ = receiveTask.ContinueWith((receiveResult) => + { + //observe the exception as to not receive as unhandled exception + _ = receiveResult.Exception; + + }, TaskContinuationOptions.OnlyOnFaulted); + + var task = await Task.WhenAny(closeTask, receiveTask).ConfigureAwait(false); + if (task == closeTask) + break; + + var result = receiveTask.Result; + await ReceivedDatagram(result.Buffer, 0, result.Buffer.Length).ConfigureAwait(false); + } + } + } +} diff --git a/src/Discord.Net.WebSocket/Net/DefaultUdpSocketProvider.cs b/src/Discord.Net.WebSocket/Net/DefaultUdpSocketProvider.cs new file mode 100644 index 0000000..28ed0a6 --- /dev/null +++ b/src/Discord.Net.WebSocket/Net/DefaultUdpSocketProvider.cs @@ -0,0 +1,19 @@ +using System; + +namespace Discord.Net.Udp +{ + public static class DefaultUdpSocketProvider + { + public static readonly UdpSocketProvider Instance = () => + { + try + { + return new DefaultUdpSocket(); + } + catch (PlatformNotSupportedException ex) + { + throw new PlatformNotSupportedException("The default UdpSocketProvider is not supported on this platform.", ex); + } + }; + } +} diff --git a/src/Discord.Net.WebSocket/Net/DefaultWebSocketClient.cs b/src/Discord.Net.WebSocket/Net/DefaultWebSocketClient.cs new file mode 100644 index 0000000..bcdbf90 --- /dev/null +++ b/src/Discord.Net.WebSocket/Net/DefaultWebSocketClient.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Net; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Net.WebSockets +{ + internal class DefaultWebSocketClient : IWebSocketClient, IDisposable + { + public const int ReceiveChunkSize = 16 * 1024; //16KB + public const int SendChunkSize = 4 * 1024; //4KB + private const int HR_TIMEOUT = -2147012894; + + public event Func BinaryMessage; + public event Func TextMessage; + public event Func Closed; + + private readonly SemaphoreSlim _lock; + private readonly Dictionary _headers; + private readonly IWebProxy _proxy; + private ClientWebSocket _client; + private Task _task; + private CancellationTokenSource _disconnectTokenSource, _cancelTokenSource; + private CancellationToken _cancelToken, _parentToken; + private bool _isDisposed, _isDisconnecting; + + public DefaultWebSocketClient(IWebProxy proxy = null) + { + _lock = new SemaphoreSlim(1, 1); + _disconnectTokenSource = new CancellationTokenSource(); + _cancelToken = CancellationToken.None; + _parentToken = CancellationToken.None; + _headers = new Dictionary(); + _proxy = proxy; + } + private void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + DisconnectInternalAsync(isDisposing: true).GetAwaiter().GetResult(); + _disconnectTokenSource?.Dispose(); + _cancelTokenSource?.Dispose(); + _lock?.Dispose(); + } + _isDisposed = true; + } + } + public void Dispose() + { + Dispose(true); + } + + public async Task ConnectAsync(string host) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await ConnectInternalAsync(host).ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + private async Task ConnectInternalAsync(string host) + { + await DisconnectInternalAsync().ConfigureAwait(false); + + _disconnectTokenSource?.Dispose(); + _cancelTokenSource?.Dispose(); + + _disconnectTokenSource = new CancellationTokenSource(); + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _disconnectTokenSource.Token); + _cancelToken = _cancelTokenSource.Token; + + _client?.Dispose(); + _client = new ClientWebSocket(); + _client.Options.Proxy = _proxy; + _client.Options.KeepAliveInterval = TimeSpan.Zero; + foreach (var header in _headers) + { + if (header.Value != null) + _client.Options.SetRequestHeader(header.Key, header.Value); + } + + await _client.ConnectAsync(new Uri(host), _cancelToken).ConfigureAwait(false); + _task = RunAsync(_cancelToken); + } + + public async Task DisconnectAsync(int closeCode = 1000) + { + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(closeCode: closeCode).ConfigureAwait(false); + } + finally + { + _lock.Release(); + } + } + private async Task DisconnectInternalAsync(int closeCode = 1000, bool isDisposing = false) + { + _isDisconnecting = true; + + + if (_client != null) + { + if (!isDisposing) + { + var status = (WebSocketCloseStatus)closeCode; + try + { + await _client.CloseOutputAsync(status, "", new CancellationToken()); + } + catch { } + } + + try + { + _client.Dispose(); + } + catch { } + + try + { + _disconnectTokenSource.Cancel(false); + } + catch { } + + _client = null; + } + + try + { + await (_task ?? Task.Delay(0)).ConfigureAwait(false); + _task = null; + } + finally { _isDisconnecting = false; } + } + private async Task OnClosed(Exception ex) + { + if (_isDisconnecting) + return; //Ignore, this disconnect was requested. + + await _lock.WaitAsync().ConfigureAwait(false); + try + { + await DisconnectInternalAsync(isDisposing: false); + } + finally + { + _lock.Release(); + } + await Closed(ex); + } + + public void SetHeader(string key, string value) + { + _headers[key] = value; + } + public void SetCancelToken(CancellationToken cancelToken) + { + _cancelTokenSource?.Dispose(); + + _parentToken = cancelToken; + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_parentToken, _disconnectTokenSource.Token); + _cancelToken = _cancelTokenSource.Token; + } + + public async Task SendAsync(byte[] data, int index, int count, bool isText) + { + try + { + await _lock.WaitAsync(_cancelToken).ConfigureAwait(false); + } + catch (TaskCanceledException) + { + return; + } + try + { + if (_client == null) + return; + + int frameCount = (int)Math.Ceiling((double)count / SendChunkSize); + + for (int i = 0; i < frameCount; i++, index += SendChunkSize) + { + bool isLast = i == (frameCount - 1); + + int frameSize; + if (isLast) + frameSize = count - (i * SendChunkSize); + else + frameSize = SendChunkSize; + + var type = isText ? WebSocketMessageType.Text : WebSocketMessageType.Binary; + await _client.SendAsync(new ArraySegment(data, index, count), type, isLast, _cancelToken).ConfigureAwait(false); + } + } + finally + { + _lock.Release(); + } + } + + private async Task RunAsync(CancellationToken cancelToken) + { + var buffer = new ArraySegment(new byte[ReceiveChunkSize]); + + try + { + while (!cancelToken.IsCancellationRequested) + { + WebSocketReceiveResult socketResult = await _client.ReceiveAsync(buffer, cancelToken).ConfigureAwait(false); + byte[] result; + int resultCount; + + if (socketResult.MessageType == WebSocketMessageType.Close) + throw new WebSocketClosedException((int)socketResult.CloseStatus, socketResult.CloseStatusDescription); + + if (!socketResult.EndOfMessage) + { + //This is a large message (likely just READY), lets create a temporary expandable stream + using (var stream = new MemoryStream()) + { + stream.Write(buffer.Array, 0, socketResult.Count); + do + { + if (cancelToken.IsCancellationRequested) + return; + socketResult = await _client.ReceiveAsync(buffer, cancelToken).ConfigureAwait(false); + stream.Write(buffer.Array, 0, socketResult.Count); + } + while (socketResult == null || !socketResult.EndOfMessage); + + //Use the internal buffer if we can get it + resultCount = (int)stream.Length; + + result = stream.TryGetBuffer(out var streamBuffer) ? streamBuffer.Array : stream.ToArray(); + + } + } + else + { + //Small message + resultCount = socketResult.Count; + result = buffer.Array; + } + + if (socketResult.MessageType == WebSocketMessageType.Text) + { + string text = Encoding.UTF8.GetString(result, 0, resultCount); + await TextMessage(text).ConfigureAwait(false); + } + else + await BinaryMessage(result, 0, resultCount).ConfigureAwait(false); + } + } + catch (Win32Exception ex) when (ex.HResult == HR_TIMEOUT) + { + var _ = OnClosed(new Exception("Connection timed out.", ex)); + } + catch (OperationCanceledException) { } + catch (Exception ex) + { + //This cannot be awaited otherwise we'll deadlock when DiscordApiClient waits for this task to complete. + var _ = OnClosed(ex); + } + } + } +} diff --git a/src/Discord.Net.WebSocket/Net/DefaultWebSocketClientProvider.cs b/src/Discord.Net.WebSocket/Net/DefaultWebSocketClientProvider.cs new file mode 100644 index 0000000..bc580c4 --- /dev/null +++ b/src/Discord.Net.WebSocket/Net/DefaultWebSocketClientProvider.cs @@ -0,0 +1,26 @@ +using System; +using System.Net; + +namespace Discord.Net.WebSockets +{ + public static class DefaultWebSocketProvider + { + public static readonly WebSocketProvider Instance = Create(); + + /// The default WebSocketProvider is not supported on this platform. + public static WebSocketProvider Create(IWebProxy proxy = null) + { + return () => + { + try + { + return new DefaultWebSocketClient(proxy); + } + catch (PlatformNotSupportedException ex) + { + throw new PlatformNotSupportedException("The default WebSocketProvider is not supported on this platform.", ex); + } + }; + } + } +} diff --git a/src/Discord.Net.WebSocket/Program.cs b/src/Discord.Net.WebSocket/Program.cs deleted file mode 100644 index 3751555..0000000 --- a/src/Discord.Net.WebSocket/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/src/Discord.Net.Webhook/AssemblyInfo.cs b/src/Discord.Net.Webhook/AssemblyInfo.cs new file mode 100644 index 0000000..bbbaca3 --- /dev/null +++ b/src/Discord.Net.Webhook/AssemblyInfo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Discord.Net.Tests")] +[assembly: InternalsVisibleTo("Discord.Net.Tests.Unit")] diff --git a/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj b/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj index 74abf5c..9ce1bda 100644 --- a/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj +++ b/src/Discord.Net.Webhook/Discord.Net.Webhook.csproj @@ -1,10 +1,18 @@ - + + - Exe - net6.0 - enable - enable + Discord.Net.Webhook + Discord.Webhook + A core Discord.Net library containing the Webhook client and models. + net6.0;net5.0;netstandard2.0;netstandard2.1 + 5 + True + false + false - + + + + diff --git a/src/Discord.Net.Webhook/DiscordWebhookClient.cs b/src/Discord.Net.Webhook/DiscordWebhookClient.cs new file mode 100644 index 0000000..44beb64 --- /dev/null +++ b/src/Discord.Net.Webhook/DiscordWebhookClient.cs @@ -0,0 +1,245 @@ +using Discord.Logging; +using Discord.Rest; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; + +namespace Discord.Webhook; + +/// +/// A client responsible for connecting as a Webhook. +/// +public class DiscordWebhookClient : IDisposable +{ + public event Func Log + { + add => _logEvent.Add(value); + remove => _logEvent.Remove(value); + } + + internal readonly AsyncEvent> _logEvent = new AsyncEvent>(); + + private readonly ulong _webhookId; + internal IWebhook Webhook; + internal readonly Logger _restLogger; + + internal API.DiscordRestApiClient ApiClient { get; } + internal LogManager LogManager { get; } + + /// + /// Creates a new Webhook Discord client. + /// + public DiscordWebhookClient(IWebhook webhook) + : this(webhook.Id, webhook.Token, new DiscordRestConfig()) { } + + /// + /// Creates a new Webhook Discord client. + /// + public DiscordWebhookClient(ulong webhookId, string webhookToken) + : this(webhookId, webhookToken, new DiscordRestConfig()) { } + + /// + /// Creates a new Webhook Discord client. + /// + public DiscordWebhookClient(string webhookUrl) + : this(webhookUrl, new DiscordRestConfig()) { } + + // regex pattern to match webhook urls + private static Regex WebhookUrlRegex = new Regex(@"^.*(discord|discordapp)\.com\/api\/webhooks\/([\d]+)\/([a-z0-9_-]+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + /// + /// Creates a new Webhook Discord client. + /// + public DiscordWebhookClient(ulong webhookId, string webhookToken, DiscordRestConfig config) + : this(config) + { + _webhookId = webhookId; + ApiClient.LoginAsync(TokenType.Webhook, webhookToken).GetAwaiter().GetResult(); + Webhook = WebhookClientHelper.GetWebhookAsync(this, webhookId).GetAwaiter().GetResult(); + } + + /// + /// Creates a new Webhook Discord client. + /// + public DiscordWebhookClient(IWebhook webhook, DiscordRestConfig config) + : this(config) + { + Webhook = webhook; + _webhookId = Webhook.Id; + } + + /// + /// Creates a new Webhook Discord client. + /// + /// The url of the webhook. + /// The configuration options to use for this client. + /// Thrown if the is an invalid format. + /// Thrown if the is null or whitespace. + public DiscordWebhookClient(string webhookUrl, DiscordRestConfig config) : this(config) + { + ParseWebhookUrl(webhookUrl, out _webhookId, out string token); + ApiClient.LoginAsync(TokenType.Webhook, token).GetAwaiter().GetResult(); + Webhook = WebhookClientHelper.GetWebhookAsync(this, _webhookId).GetAwaiter().GetResult(); + } + + private DiscordWebhookClient(DiscordRestConfig config) + { + ApiClient = CreateApiClient(config); + LogManager = new LogManager(config.LogLevel); + LogManager.Message += async msg => await _logEvent.InvokeAsync(msg).ConfigureAwait(false); + + _restLogger = LogManager.CreateLogger("Rest"); + + ApiClient.RequestQueue.RateLimitTriggered += async (id, info, endpoint) => + { + if (info == null) + await _restLogger.VerboseAsync($"Preemptive Rate limit triggered: {endpoint} {(id.IsHashBucket ? $"(Bucket: {id.BucketHash})" : "")}").ConfigureAwait(false); + else + await _restLogger.WarningAsync($"Rate limit triggered: {endpoint} {(id.IsHashBucket ? $"(Bucket: {id.BucketHash})" : "")}").ConfigureAwait(false); + }; + ApiClient.SentRequest += async (method, endpoint, millis) => await _restLogger.VerboseAsync($"{method} {endpoint}: {millis} ms").ConfigureAwait(false); + } + private static API.DiscordRestApiClient CreateApiClient(DiscordRestConfig config) + => new API.DiscordRestApiClient(config.RestClientProvider, DiscordRestConfig.UserAgent, useSystemClock: config.UseSystemClock, defaultRatelimitCallback: config.DefaultRatelimitCallback); + + /// + /// Sends a message to the channel for this webhook. + /// + /// + /// Returns the ID of the created message. + /// + public Task SendMessageAsync(string text = null, bool isTTS = false, IEnumerable embeds = null, + string username = null, string avatarUrl = null, RequestOptions options = null, AllowedMentions allowedMentions = null, + MessageComponent components = null, MessageFlags flags = MessageFlags.None, ulong? threadId = null, string threadName = null, + ulong[] appliedTags = null) + => WebhookClientHelper.SendMessageAsync(this, text, isTTS, embeds, username, avatarUrl, allowedMentions, options, components, flags, threadId, threadName, appliedTags); + + /// + /// Modifies a message posted using this webhook. + /// + /// + /// This method can only modify messages that were sent using the same webhook. + /// + /// ID of the modified message. + /// A delegate containing the properties to modify the message with. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous modification operation. + /// + public Task ModifyMessageAsync(ulong messageId, Action func, RequestOptions options = null, ulong? threadId = null) + => WebhookClientHelper.ModifyMessageAsync(this, messageId, func, options, threadId); + + /// + /// Deletes a message posted using this webhook. + /// + /// + /// This method can only delete messages that were sent using the same webhook. + /// + /// ID of the deleted message. + /// The options to be used when sending the request. + /// + /// A task that represents the asynchronous deletion operation. + /// + public Task DeleteMessageAsync(ulong messageId, RequestOptions options = null, ulong? threadId = null) + => WebhookClientHelper.DeleteMessageAsync(this, messageId, options, threadId); + + /// + /// Sends a message to the channel for this webhook with an attachment. + /// + /// + /// Returns the ID of the created message. + /// + public Task SendFileAsync(string filePath, string text, bool isTTS = false, + IEnumerable embeds = null, string username = null, string avatarUrl = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageComponent components = null, MessageFlags flags = MessageFlags.None, ulong? threadId = null, + string threadName = null, ulong[] appliedTags = null) + => WebhookClientHelper.SendFileAsync(this, filePath, text, isTTS, embeds, username, avatarUrl, + allowedMentions, options, isSpoiler, components, flags, threadId, threadName, appliedTags); + + /// + /// Sends a message to the channel for this webhook with an attachment. + /// + /// + /// Returns the ID of the created message. + /// + public Task SendFileAsync(Stream stream, string filename, string text, bool isTTS = false, + IEnumerable embeds = null, string username = null, string avatarUrl = null, + RequestOptions options = null, bool isSpoiler = false, AllowedMentions allowedMentions = null, + MessageComponent components = null, MessageFlags flags = MessageFlags.None, ulong? threadId = null, + string threadName = null, ulong[] appliedTags = null) + => WebhookClientHelper.SendFileAsync(this, stream, filename, text, isTTS, embeds, username, + avatarUrl, allowedMentions, options, isSpoiler, components, flags, threadId, threadName, appliedTags); + + /// Sends a message to the channel for this webhook with an attachment. + /// Returns the ID of the created message. + public Task SendFileAsync(FileAttachment attachment, string text, bool isTTS = false, + IEnumerable embeds = null, string username = null, string avatarUrl = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageComponent components = null, + MessageFlags flags = MessageFlags.None, ulong? threadId = null, string threadName = null, ulong[] appliedTags = null) + => WebhookClientHelper.SendFileAsync(this, attachment, text, isTTS, embeds, username, + avatarUrl, allowedMentions, components, options, flags, threadId, threadName, appliedTags); + + /// + /// Sends a message to the channel for this webhook with an attachment. + /// + /// + /// Returns the ID of the created message. + /// + public Task SendFilesAsync(IEnumerable attachments, string text, bool isTTS = false, + IEnumerable embeds = null, string username = null, string avatarUrl = null, + RequestOptions options = null, AllowedMentions allowedMentions = null, MessageComponent components = null, + MessageFlags flags = MessageFlags.None, ulong? threadId = null, string threadName = null, ulong[] appliedTags = null) + => WebhookClientHelper.SendFilesAsync(this, attachments, text, isTTS, embeds, username, avatarUrl, + allowedMentions, components, options, flags, threadId, threadName, appliedTags); + + /// + /// Modifies the properties of this webhook. + /// + public Task ModifyWebhookAsync(Action func, RequestOptions options = null) + => Webhook.ModifyAsync(func, options); + + /// + /// Deletes this webhook from Discord and disposes the client. + /// + public async Task DeleteWebhookAsync(RequestOptions options = null) + { + await Webhook.DeleteAsync(options).ConfigureAwait(false); + Dispose(); + } + + public void Dispose() + { + ApiClient?.Dispose(); + } + + internal static void ParseWebhookUrl(string webhookUrl, out ulong webhookId, out string webhookToken) + { + if (string.IsNullOrWhiteSpace(webhookUrl)) + throw new ArgumentNullException(nameof(webhookUrl), "The given webhook Url cannot be null or whitespace."); + + // thrown when groups are not populated/valid, or when there is no match + ArgumentException ex(string reason = null) + => new ($"The given webhook Url was not in a valid format. {reason}", nameof(webhookUrl)); + + var match = WebhookUrlRegex.Match(webhookUrl); + + if (match != null) + { + // ensure that the first group is a ulong, set the _webhookId + // 0th group is always the entire match, and 1 is the domain; so start at index 2 + if (!(match.Groups[2].Success && ulong.TryParse(match.Groups[2].Value, NumberStyles.None, CultureInfo.InvariantCulture, out webhookId))) + throw ex("The webhook Id could not be parsed."); + + if (!match.Groups[3].Success) + throw ex("The webhook token could not be parsed."); + webhookToken = match.Groups[3].Value; + } + else + throw ex(); + } +} diff --git a/src/Discord.Net.Webhook/Entities/Messages/WebhookMessageProperties.cs b/src/Discord.Net.Webhook/Entities/Messages/WebhookMessageProperties.cs new file mode 100644 index 0000000..6cb15bc --- /dev/null +++ b/src/Discord.Net.Webhook/Entities/Messages/WebhookMessageProperties.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Discord.Webhook +{ + /// + /// Properties that are used to modify an Webhook message with the specified changes. + /// + public class WebhookMessageProperties + { + /// + /// Gets or sets the content of the message. + /// + /// + /// This must be less than the constant defined by . + /// + public Optional Content { get; set; } + /// + /// Gets or sets the embed array that the message should display. + /// + public Optional> Embeds { get; set; } + /// + /// Gets or sets the allowed mentions of the message. + /// + public Optional AllowedMentions { get; set; } + /// + /// Gets or sets the components that the message should display. + /// + public Optional Components { get; set; } + /// + /// Gets or sets the attachments for the message. + /// + public Optional> Attachments { get; set; } + } +} diff --git a/src/Discord.Net.Webhook/Entities/Webhooks/RestInternalWebhook.cs b/src/Discord.Net.Webhook/Entities/Webhooks/RestInternalWebhook.cs new file mode 100644 index 0000000..33b8446 --- /dev/null +++ b/src/Discord.Net.Webhook/Entities/Webhooks/RestInternalWebhook.cs @@ -0,0 +1,74 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Model = Discord.API.Webhook; + +namespace Discord.Webhook +{ + [DebuggerDisplay(@"{DebuggerDisplay,nq}")] + internal class RestInternalWebhook : IWebhook + { + private DiscordWebhookClient _client; + + public ulong Id { get; } + public string Token { get; } + + public ulong? ChannelId { get; private set; } + public string Name { get; private set; } + public string AvatarId { get; private set; } + public ulong? GuildId { get; private set; } + public ulong? ApplicationId { get; private set; } + public WebhookType Type { get; private set; } + + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + internal RestInternalWebhook(DiscordWebhookClient apiClient, Model model) + { + _client = apiClient; + Id = model.Id; + ChannelId = model.Id; + Token = model.Token.GetValueOrDefault(null); + } + internal static RestInternalWebhook Create(DiscordWebhookClient client, Model model) + { + var entity = new RestInternalWebhook(client, model); + entity.Update(model); + return entity; + } + + internal void Update(Model model) + { + if (ChannelId != model.ChannelId) + ChannelId = model.ChannelId; + if (model.Avatar.IsSpecified) + AvatarId = model.Avatar.Value; + if (model.GuildId.IsSpecified) + GuildId = model.GuildId.Value; + if (model.Name.IsSpecified) + Name = model.Name.Value; + + Type = model.Type; + + ApplicationId = model.ApplicationId; + } + + public string GetAvatarUrl(ImageFormat format = ImageFormat.Auto, ushort size = 128) + => CDN.GetUserAvatarUrl(Id, AvatarId, size, format); + + public async Task ModifyAsync(Action func, RequestOptions options = null) + { + var model = await WebhookClientHelper.ModifyAsync(_client, func, options).ConfigureAwait(false); + Update(model); + } + + public Task DeleteAsync(RequestOptions options = null) + => WebhookClientHelper.DeleteAsync(_client, options); + + public override string ToString() => $"Webhook: {Name}:{Id}"; + private string DebuggerDisplay => $"Webhook: {Name} ({Id})"; + + IUser IWebhook.Creator => null; + IIntegrationChannel IWebhook.Channel => null; + IGuild IWebhook.Guild => null; + } +} diff --git a/src/Discord.Net.Webhook/Program.cs b/src/Discord.Net.Webhook/Program.cs deleted file mode 100644 index 3751555..0000000 --- a/src/Discord.Net.Webhook/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/src/Discord.Net.Webhook/WebhookClientHelper.cs b/src/Discord.Net.Webhook/WebhookClientHelper.cs new file mode 100644 index 0000000..ea04987 --- /dev/null +++ b/src/Discord.Net.Webhook/WebhookClientHelper.cs @@ -0,0 +1,230 @@ +using Discord.API.Rest; +using Discord.Rest; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using ImageModel = Discord.API.Image; +using WebhookModel = Discord.API.Webhook; + +namespace Discord.Webhook +{ + internal static class WebhookClientHelper + { + /// Could not find a webhook with the supplied credentials. + public static async Task GetWebhookAsync(DiscordWebhookClient client, ulong webhookId) + { + var model = await client.ApiClient.GetWebhookAsync(webhookId).ConfigureAwait(false); + if (model == null) + throw new InvalidOperationException("Could not find a webhook with the supplied credentials."); + return RestInternalWebhook.Create(client, model); + } + + public static async Task SendMessageAsync(DiscordWebhookClient client, + string text, bool isTTS, IEnumerable embeds, string username, string avatarUrl, + AllowedMentions allowedMentions, RequestOptions options, MessageComponent components, + MessageFlags flags, ulong? threadId = null, string threadName = null, ulong[] appliedTags = null) + { + var args = new CreateWebhookMessageParams + { + Content = text, + IsTTS = isTTS, + Flags = flags + }; + + Preconditions.WebhookMessageAtLeastOneOf(text, components, embeds?.ToArray()); + + if (embeds != null) + args.Embeds = embeds.Select(x => x.ToModel()).ToArray(); + if (username != null) + args.Username = username; + if (avatarUrl != null) + args.AvatarUrl = avatarUrl; + if (allowedMentions != null) + args.AllowedMentions = allowedMentions.ToModel(); + if (components != null) + args.Components = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray(); + if (threadName is not null) + args.ThreadName = threadName; + if (appliedTags != null) + args.AppliedTags = appliedTags; + + if (flags is not MessageFlags.None and not MessageFlags.SuppressEmbeds) + throw new ArgumentException("The only valid MessageFlags are SuppressEmbeds and none.", nameof(flags)); + + var model = await client.ApiClient.CreateWebhookMessageAsync(client.Webhook.Id, args, options: options, threadId: threadId).ConfigureAwait(false); + return model.Id; + } + + public static Task ModifyMessageAsync(DiscordWebhookClient client, ulong messageId, + Action func, RequestOptions options, ulong? threadId) + { + var args = new WebhookMessageProperties(); + func(args); + + if (args.AllowedMentions.IsSpecified) + { + var allowedMentions = args.AllowedMentions.Value; + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), + "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), + "A max of 100 user Ids are allowed."); + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions?.AllowedTypes != null) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", + nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", + nameof(allowedMentions)); + } + } + } + + if (!args.Attachments.IsSpecified) + { + var apiArgs = new ModifyWebhookMessageParams + { + Content = args.Content.IsSpecified ? args.Content.Value : Optional.Create(), + Embeds = + args.Embeds.IsSpecified + ? args.Embeds.Value.Select(embed => embed.ToModel()).ToArray() + : Optional.Create(), + AllowedMentions = args.AllowedMentions.IsSpecified + ? args.AllowedMentions.Value.ToModel() + : Optional.Create(), + Components = args.Components.IsSpecified ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() : Optional.Unspecified, + }; + + return client.ApiClient.ModifyWebhookMessageAsync(client.Webhook.Id, messageId, apiArgs, options, threadId); + } + else + { + var attachments = args.Attachments.Value?.ToArray() ?? Array.Empty(); + + var apiArgs = new UploadWebhookFileParams(attachments) + { + Content = args.Content.IsSpecified ? args.Content.Value : Optional.Create(), + Embeds = + args.Embeds.IsSpecified + ? args.Embeds.Value.Select(embed => embed.ToModel()).ToArray() + : Optional.Create(), + AllowedMentions = args.AllowedMentions.IsSpecified + ? args.AllowedMentions.Value.ToModel() + : Optional.Create(), + MessageComponents = args.Components.IsSpecified ? args.Components.Value?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() : Optional.Unspecified, + }; + + return client.ApiClient.ModifyWebhookMessageAsync(client.Webhook.Id, messageId, apiArgs, options, threadId); + } + } + + public static Task DeleteMessageAsync(DiscordWebhookClient client, ulong messageId, RequestOptions options, ulong? threadId) + => client.ApiClient.DeleteWebhookMessageAsync(client.Webhook.Id, messageId, options, threadId); + + public static async Task SendFileAsync(DiscordWebhookClient client, string filePath, string text, bool isTTS, + IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, RequestOptions options, + bool isSpoiler, MessageComponent components, MessageFlags flags = MessageFlags.None, ulong? threadId = null, string threadName = null, + ulong[] appliedTags = null) + { + string filename = Path.GetFileName(filePath); + using (var file = File.OpenRead(filePath)) + return await SendFileAsync(client, file, filename, text, isTTS, embeds, username, avatarUrl, allowedMentions, options, isSpoiler, components, flags, threadId, threadName, appliedTags).ConfigureAwait(false); + } + + public static Task SendFileAsync(DiscordWebhookClient client, Stream stream, string filename, string text, bool isTTS, + IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, RequestOptions options, bool isSpoiler, + MessageComponent components, MessageFlags flags, ulong? threadId, string threadName = null, ulong[] appliedTags = null) + => SendFileAsync(client, new FileAttachment(stream, filename, isSpoiler: isSpoiler), text, isTTS, embeds, username, avatarUrl, allowedMentions, components, options, flags, threadId, threadName, appliedTags); + + public static Task SendFileAsync(DiscordWebhookClient client, FileAttachment attachment, string text, bool isTTS, + IEnumerable embeds, string username, string avatarUrl, AllowedMentions allowedMentions, + MessageComponent components, RequestOptions options, MessageFlags flags, ulong? threadId, string threadName = null, + ulong[] appliedTags = null) + => SendFilesAsync(client, new FileAttachment[] { attachment }, text, isTTS, embeds, username, avatarUrl, allowedMentions, components, options, flags, threadId, threadName, appliedTags); + + public static async Task SendFilesAsync(DiscordWebhookClient client, + IEnumerable attachments, string text, bool isTTS, IEnumerable embeds, string username, + string avatarUrl, AllowedMentions allowedMentions, MessageComponent components, RequestOptions options, + MessageFlags flags, ulong? threadId, string threadName = null, ulong[] appliedTags = null) + { + embeds ??= Array.Empty(); + + Preconditions.AtMost(allowedMentions?.RoleIds?.Count ?? 0, 100, nameof(allowedMentions.RoleIds), "A max of 100 role Ids are allowed."); + Preconditions.AtMost(allowedMentions?.UserIds?.Count ?? 0, 100, nameof(allowedMentions.UserIds), "A max of 100 user Ids are allowed."); + Preconditions.AtMost(embeds.Count(), DiscordConfig.MaxEmbedsPerMessage, nameof(embeds), $"A max of {DiscordConfig.MaxEmbedsPerMessage} Embeds are allowed."); + + Preconditions.WebhookMessageAtLeastOneOf(text, components, embeds.ToArray(), attachments); + + foreach (var attachment in attachments) + { + Preconditions.NotNullOrEmpty(attachment.FileName, nameof(attachment.FileName), "File Name must not be empty or null"); + } + + // check that user flag and user Id list are exclusive, same with role flag and role Id list + if (allowedMentions != null && allowedMentions.AllowedTypes.HasValue) + { + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Users) && + allowedMentions.UserIds != null && allowedMentions.UserIds.Count > 0) + { + throw new ArgumentException("The Users flag is mutually exclusive with the list of User Ids.", nameof(allowedMentions)); + } + + if (allowedMentions.AllowedTypes.Value.HasFlag(AllowedMentionTypes.Roles) && + allowedMentions.RoleIds != null && allowedMentions.RoleIds.Count > 0) + { + throw new ArgumentException("The Roles flag is mutually exclusive with the list of Role Ids.", nameof(allowedMentions)); + } + } + + if (flags is not MessageFlags.None and not MessageFlags.SuppressEmbeds and not MessageFlags.SuppressNotification) + throw new ArgumentException("The only valid MessageFlags are SuppressEmbeds, SuppressNotification and none.", nameof(flags)); + + var args = new UploadWebhookFileParams(attachments.ToArray()) + { + AvatarUrl = avatarUrl, + Username = username, + Content = text, + IsTTS = isTTS, + Embeds = embeds.Any() ? embeds.Select(x => x.ToModel()).ToArray() : Optional.Unspecified, + AllowedMentions = allowedMentions?.ToModel() ?? Optional.Unspecified, + MessageComponents = components?.Components.Select(x => new API.ActionRowComponent(x)).ToArray() ?? Optional.Unspecified, + Flags = flags, + ThreadName = threadName, + AppliedTags = appliedTags + }; + var msg = await client.ApiClient.UploadWebhookFileAsync(client.Webhook.Id, args, options, threadId).ConfigureAwait(false); + return msg.Id; + } + + public static Task ModifyAsync(DiscordWebhookClient client, Action func, RequestOptions options) + { + var args = new WebhookProperties(); + func(args); + var apiArgs = new ModifyWebhookParams + { + Avatar = args.Image.IsSpecified ? args.Image.Value?.ToModel() : Optional.Create(), + Name = args.Name + }; + + if (!apiArgs.Avatar.IsSpecified && client.Webhook.AvatarId != null) + apiArgs.Avatar = new ImageModel(client.Webhook.AvatarId); + + return client.ApiClient.ModifyWebhookAsync(client.Webhook.Id, apiArgs, options); + } + + public static Task DeleteAsync(DiscordWebhookClient client, RequestOptions options) + => client.ApiClient.DeleteWebhookAsync(client.Webhook.Id, options); + } +} diff --git a/src/Discord.Net/Discord.Net.csproj b/src/Discord.Net/Discord.Net.csproj new file mode 100644 index 0000000..be2c32f --- /dev/null +++ b/src/Discord.Net/Discord.Net.csproj @@ -0,0 +1,16 @@ + + + net6.0 + False + EllieBot Team + + + + + + + + + + + diff --git a/src/Discord.Net/Discord.Net.nuspec b/src/Discord.Net/Discord.Net.nuspec new file mode 100644 index 0000000..8f8bbff --- /dev/null +++ b/src/Discord.Net/Discord.Net.nuspec @@ -0,0 +1,63 @@ + + + + Discord.Net + 3.14.1$suffix$ + Discord.Net + Discord.Net Contributors + foxbot + An asynchronous API wrapper for Discord. This metapackage includes all of the optional Discord.Net components. + discord;discordapp + https://github.com/discord-net/Discord.Net + MIT + false + PackageLogo.png + NUGET_README.md + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +