diff --git a/src/Discord.Net.Core/CDN.cs b/src/Discord.Net.Core/CDN.cs index ecce668f0..16cdf30c0 100644 --- a/src/Discord.Net.Core/CDN.cs +++ b/src/Discord.Net.Core/CDN.cs @@ -29,6 +29,16 @@ public static string GetTeamIconUrl(ulong teamId, string iconId) public static string GetApplicationIconUrl(ulong appId, string iconId) => iconId != null ? $"{DiscordConfig.CDNUrl}app-icons/{appId}/{iconId}.jpg" : null; + /// + /// Returns a soundboard sound URL. + /// + /// The sound identifier. + /// + /// A URL pointing to the soundboard sound. + /// + public static string GetSoundboardSoundUrl(ulong soundId) + => $"{DiscordConfig.CDNUrl}soundboard-sounds/{soundId}.mp3"; + /// /// Returns a user avatar URL. /// diff --git a/src/Discord.Net.Core/Entities/AnimationType.cs b/src/Discord.Net.Core/Entities/AnimationType.cs new file mode 100644 index 000000000..be2dccd57 --- /dev/null +++ b/src/Discord.Net.Core/Entities/AnimationType.cs @@ -0,0 +1,14 @@ +namespace Discord; + +public enum AnimationType +{ + /// + /// A fun animation, sent by a Nitro subscriber. + /// + Premium = 0, + + /// + /// The standard animation. + /// + Basic = 1, +} diff --git a/src/Discord.Net.Core/Entities/Guilds/SoundboardSound.cs b/src/Discord.Net.Core/Entities/Guilds/SoundboardSound.cs new file mode 100644 index 000000000..72a94fcab --- /dev/null +++ b/src/Discord.Net.Core/Entities/Guilds/SoundboardSound.cs @@ -0,0 +1,92 @@ +using System; +using System.Diagnostics; + +namespace Discord; + +/// +/// Represents a soundboard sound. +/// +[DebuggerDisplay(@"{DebuggerDisplay,nq}")] +public class SoundboardSound : ISnowflakeEntity +{ + /// + public ulong Id => SoundId; + + /// + /// Gets the Id of the sound. + /// + public ulong SoundId { get; internal set; } + + /// + /// + /// May be inaccurate for default sounds. + /// + public DateTimeOffset CreatedAt => SnowflakeUtils.FromSnowflake(Id); + + /// + /// Gets the name of the sound. + /// + public string Name { get; internal set; } + + /// + /// Gets the Id of the author of the sound. + /// + public ulong AuthorId { get; internal set; } + + /// + /// Gets the author of the sound. + /// + public IUser Author { get; internal set; } + + /// + /// Gets the icon of the sound. + /// + /// + /// Custom emojis will only have Id property filled due to limited data returned by discord. + /// + public IEmote Emoji { get; internal set; } + + /// + /// Gets the volume of the sound. + /// + public double Volume { get; internal set; } + + /// + /// Gets whether the sound is available or not. + /// + public bool? IsAvailable { get; internal set; } + + /// + /// Gets the Id of the guild this sound belongs to. if not available. + /// + public ulong? GuildId { get; internal set; } + + internal SoundboardSound(ulong soundId, string name, ulong authorId, double volume, ulong? guildId = null, + string emojiName = null, ulong? emojiId = null, IUser author = null, bool? isAvailable = null) + { + GuildId = guildId; + SoundId = soundId; + Name = name; + AuthorId = authorId; + Author = author; + IsAvailable = isAvailable; + Volume = volume; + + if (emojiId is not null) + Emoji = new Emote(emojiId.Value, emojiName, false); + else if (!string.IsNullOrWhiteSpace(emojiName)) + Emoji = new Emoji(emojiName); + else + Emoji = null; + } + + private string DebuggerDisplay => $"{Name} ({SoundId})"; + + internal SoundboardSound Clone() => (SoundboardSound)MemberwiseClone(); + + /// + /// Gets the url for the sound. + /// + public string GetUrl() + => CDN.GetSoundboardSoundUrl(SoundId); +} diff --git a/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs b/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs index 72b491702..59e1ce8df 100644 --- a/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs +++ b/src/Discord.Net.Core/Entities/Permissions/ChannelPermission.cs @@ -177,6 +177,11 @@ public enum ChannelPermission : ulong /// Allows members to edit and cancel events in this channel. /// CreateEvents = 1L << 44, + + /// + /// Allows members to use sounds from other servers. + /// + UseExternalSounds = 1L << 45, /// /// Allows sending voice messages. diff --git a/src/Discord.Net.Core/Entities/Permissions/ChannelPermissions.cs b/src/Discord.Net.Core/Entities/Permissions/ChannelPermissions.cs index 4b527e348..06c3c3af1 100644 --- a/src/Discord.Net.Core/Entities/Permissions/ChannelPermissions.cs +++ b/src/Discord.Net.Core/Entities/Permissions/ChannelPermissions.cs @@ -23,7 +23,7 @@ public struct ChannelPermissions /// /// Gets a that grants all permissions for voice channels. /// - public static readonly ChannelPermissions Voice = new(0b1_110001_001010_001010_110011_111101_111111_111101_010001); + public static readonly ChannelPermissions Voice = new(0b1_111001_001010_001010_110011_111101_111111_111101_010001); /// /// Gets a that grants all permissions for stage channels. @@ -152,6 +152,8 @@ public static ChannelPermissions All(IChannel 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); + /// If , a user can use sounds from other servers. + public bool UseExternalSounds => Permissions.GetValue(RawValue, ChannelPermission.UseExternalSounds); /// Creates a new with the provided packed value. public ChannelPermissions(ulong rawValue) { RawValue = rawValue; } @@ -191,7 +193,8 @@ private ChannelPermissions(ulong initialValue, bool? createEvents = null, bool? sendVoiceMessages = null, bool? useClydeAI = null, - bool? setVoiceChannelStatus = null) + bool? setVoiceChannelStatus = null, + bool? useExternalSounds = null) { ulong value = initialValue; @@ -230,6 +233,7 @@ private ChannelPermissions(ulong initialValue, Permissions.SetValue(ref value, sendVoiceMessages, ChannelPermission.SendVoiceMessages); Permissions.SetValue(ref value, useClydeAI, ChannelPermission.UseClydeAI); Permissions.SetValue(ref value, setVoiceChannelStatus, ChannelPermission.SetVoiceChannelStatus); + Permissions.SetValue(ref value, useExternalSounds, ChannelPermission.UseExternalSounds); RawValue = value; } @@ -270,12 +274,13 @@ public ChannelPermissions( bool createEvents = false, bool sendVoiceMessages = false, bool useClydeAI = false, - bool setVoiceChannelStatus = false) + bool setVoiceChannelStatus = false, + bool useExternalSounds = 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) + startEmbeddedActivities, useSoundboard, createEvents, sendVoiceMessages, useClydeAI, setVoiceChannelStatus, useExternalSounds) { } /// Creates a new from this one, changing the provided non-null permissions. @@ -314,7 +319,8 @@ public ChannelPermissions Modify( bool? createEvents = null, bool? sendVoiceMessages = null, bool? useClydeAI = null, - bool? setVoiceChannelStatus = null) + bool? setVoiceChannelStatus = null, + bool? useExternalSounds = null) => new ChannelPermissions(RawValue, createInstantInvite, manageChannel, @@ -350,7 +356,8 @@ public ChannelPermissions Modify( createEvents, sendVoiceMessages, useClydeAI, - setVoiceChannelStatus); + setVoiceChannelStatus, + useExternalSounds); public bool Has(ChannelPermission permission) => Permissions.GetValue(RawValue, permission); diff --git a/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs b/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs index 978161d75..8c8a65068 100644 --- a/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs +++ b/src/Discord.Net.Core/Entities/Permissions/GuildPermission.cs @@ -263,6 +263,11 @@ public enum GuildPermission : ulong /// UseSoundboard = 1L << 42, + /// + /// Allows members to use sounds from other servers. + /// + UseExternalSounds = 1L << 45, + /// /// Allows for creating emojis, stickers, and soundboard sounds, and editing and deleting those created by the current user. /// diff --git a/src/Discord.Net.Core/Entities/Permissions/GuildPermissions.cs b/src/Discord.Net.Core/Entities/Permissions/GuildPermissions.cs index 90e41dd20..79ca3f997 100644 --- a/src/Discord.Net.Core/Entities/Permissions/GuildPermissions.cs +++ b/src/Discord.Net.Core/Entities/Permissions/GuildPermissions.cs @@ -116,6 +116,8 @@ public struct GuildPermissions 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); + /// If , a user can use sounds from other servers. + public bool UseExternalSounds => Permissions.GetValue(RawValue, GuildPermission.UseExternalSounds); /// Creates a new with the provided packed value. public GuildPermissions(ulong rawValue) { RawValue = rawValue; } @@ -170,7 +172,8 @@ private GuildPermissions(ulong initialValue, bool? sendVoiceMessages = null, bool? useClydeAI = null, bool? createGuildExpressions = null, - bool? setVoiceChannelStatus = null) + bool? setVoiceChannelStatus = null, + bool? useExternalSounds = null) { ulong value = initialValue; @@ -221,6 +224,7 @@ private GuildPermissions(ulong initialValue, Permissions.SetValue(ref value, useClydeAI, GuildPermission.UseClydeAI); Permissions.SetValue(ref value, createGuildExpressions, GuildPermission.CreateGuildExpressions); Permissions.SetValue(ref value, setVoiceChannelStatus, GuildPermission.SetVoiceChannelStatus); + Permissions.SetValue(ref value, useExternalSounds, GuildPermission.UseExternalSounds); RawValue = value; } @@ -273,7 +277,8 @@ public GuildPermissions( bool sendVoiceMessages = false, bool useClydeAI = false, bool createGuildExpressions = false, - bool setVoiceChannelStatus = false) + bool setVoiceChannelStatus = false, + bool useExternalSounds = false) : this(0, createInstantInvite: createInstantInvite, manageRoles: manageRoles, @@ -321,7 +326,8 @@ public GuildPermissions( sendVoiceMessages: sendVoiceMessages, useClydeAI: useClydeAI, createGuildExpressions: createGuildExpressions, - setVoiceChannelStatus: setVoiceChannelStatus) + setVoiceChannelStatus: setVoiceChannelStatus, + useExternalSounds: useExternalSounds) { } /// Creates a new from this one, changing the provided non-null permissions. @@ -372,13 +378,15 @@ public GuildPermissions Modify( bool? sendVoiceMessages = null, bool? useClydeAI = null, bool? createGuildExpressions = null, - bool? setVoiceChannelStatus = null) + bool? setVoiceChannelStatus = null, + bool? useExternalSounds = 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); + startEmbeddedActivities, moderateMembers, useSoundboard, viewMonetizationAnalytics, sendVoiceMessages, useClydeAI, createGuildExpressions, setVoiceChannelStatus, + useExternalSounds); /// /// Returns a value that indicates if a specific is enabled diff --git a/src/Discord.Net.Core/Entities/Sound.cs b/src/Discord.Net.Core/Entities/Sound.cs new file mode 100644 index 000000000..9a3ce6db3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Sound.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; + +namespace Discord +{ + /// + /// An sound that will be uploaded to Discord. + /// + public struct Sound : 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 sound with a . + /// + /// + /// The to create the sound with. Note that this must be some type of stream + /// with the contents of a file in it. + /// + public Sound(Stream stream) + { + _isDisposed = false; + Stream = stream; + } + + /// + /// Create the sound 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 null. + /// + /// 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 Sound(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/IDiscordClient.cs b/src/Discord.Net.Core/IDiscordClient.cs index a87a25c7a..1b995a5f0 100644 --- a/src/Discord.Net.Core/IDiscordClient.cs +++ b/src/Discord.Net.Core/IDiscordClient.cs @@ -349,5 +349,10 @@ IAsyncEnumerable> GetEntitlementsAsync(int? li /// Returns all SKUs for a given application. /// Task> GetSKUsAsync(RequestOptions options = null); + + /// + /// Returns all default soundboard sounds. + /// + Task> GetDefaultSoundboardSoundsAsync(RequestOptions options = null); } } diff --git a/src/Discord.Net.Rest/API/Common/SoundboardSound.cs b/src/Discord.Net.Rest/API/Common/SoundboardSound.cs new file mode 100644 index 000000000..046b84171 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/SoundboardSound.cs @@ -0,0 +1,33 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class SoundboardSound +{ + [JsonProperty("sound_id")] + public ulong SoundId { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("volume")] + public double Volume { get; set; } + + [JsonProperty("emoji_id")] + public Optional EmojiId { get; set; } + + [JsonProperty("emoji_name")] + public Optional EmojiName { get; set; } + + [JsonProperty("guild_id")] + public Optional GuildId { get; set; } + + [JsonProperty("user_id")] + public ulong UserId { get; set; } + + [JsonProperty("user")] + public Optional User { get; set; } + + [JsonProperty("available")] + public Optional IsAvailable { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Rest/CreateSoundboardSoundParams.cs b/src/Discord.Net.Rest/API/Rest/CreateSoundboardSoundParams.cs new file mode 100644 index 000000000..90cf205aa --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateSoundboardSoundParams.cs @@ -0,0 +1,21 @@ +using Newtonsoft.Json; + +namespace Discord.API.Rest; + +internal class CreateSoundboardSoundParams +{ + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("emoji_id")] + public Optional EmojiId { get; set; } + + [JsonProperty("emoji_name")] + public Optional EmojiName { get; set; } + + [JsonProperty("volume")] + public double Volume { get; set; } + + [JsonProperty("sound")] + public Sound Sound { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Sound.cs b/src/Discord.Net.Rest/API/Sound.cs new file mode 100644 index 000000000..b29093616 --- /dev/null +++ b/src/Discord.Net.Rest/API/Sound.cs @@ -0,0 +1,20 @@ +using System.IO; + +namespace Discord.API; + +internal class Sound +{ + public Stream Stream { get; } + public string Hash { get; } + + public Sound(Stream stream) + { + Stream = stream; + Hash = null; + } + public Sound(string hash) + { + Stream = null; + Hash = hash; + } +} diff --git a/src/Discord.Net.Rest/BaseDiscordClient.cs b/src/Discord.Net.Rest/BaseDiscordClient.cs index 67a5baa72..135a43a53 100644 --- a/src/Discord.Net.Rest/BaseDiscordClient.cs +++ b/src/Discord.Net.Rest/BaseDiscordClient.cs @@ -283,6 +283,10 @@ IAsyncEnumerable> IDiscordClient.GetEntitlemen /// Task> IDiscordClient.GetSKUsAsync(RequestOptions options) => Task.FromResult>(Array.Empty()); + /// + Task> IDiscordClient.GetDefaultSoundboardSoundsAsync(RequestOptions options) + => Task.FromResult>(ImmutableArray.Create()); + #endregion } } diff --git a/src/Discord.Net.Rest/ClientHelper.cs b/src/Discord.Net.Rest/ClientHelper.cs index 7d0c7b48e..1643fde9e 100644 --- a/src/Discord.Net.Rest/ClientHelper.cs +++ b/src/Discord.Net.Rest/ClientHelper.cs @@ -440,5 +440,16 @@ public static async Task> ListSKUsAsync(BaseDiscordClie } #endregion + + #region Soundboard + + public static async Task> GetDefaultSoundboardSoundsAsync(BaseDiscordClient client, RequestOptions options = null) + { + var models = await client.ApiClient.GetDefaultSoundboardSoundsAsync(options).ConfigureAwait(false); + + return models.Select(x => x.ToEntity(discord: client)).ToImmutableArray(); + } + + #endregion } } diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index 4f9d644d9..70ebbd039 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -2811,5 +2811,27 @@ public Task ListSKUsAsync(RequestOptions options = null) => SendAsync("GET", () => $"applications/{CurrentApplicationId}/skus", new BucketIds(), options: options); #endregion + + #region Soundboard + + public Task CreateSoundboardSoundAsync(ulong guildId, CreateSoundboardSoundParams 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("POST", () => $"guilds/{guildId}/soundboard-sounds", args, ids, options: options); + } + + public Task GetDefaultSoundboardSoundsAsync(RequestOptions options = null) + { + var ids = new BucketIds(); + + return SendAsync("GET", () => "soundboard-default-sounds", ids: ids, options: options); + } + + #endregion } } diff --git a/src/Discord.Net.Rest/DiscordRestClient.cs b/src/Discord.Net.Rest/DiscordRestClient.cs index 4d78d6789..76e4cc29f 100644 --- a/src/Discord.Net.Rest/DiscordRestClient.cs +++ b/src/Discord.Net.Rest/DiscordRestClient.cs @@ -296,6 +296,10 @@ public IAsyncEnumerable> GetEntitlementsAsync( public Task> GetSKUsAsync(RequestOptions options = null) => ClientHelper.ListSKUsAsync(this, options); + /// + public Task> GetDefaultSoundboardSoundsAsync(RequestOptions options = null) + => ClientHelper.GetDefaultSoundboardSoundsAsync(this, options); + #endregion #region IDiscordClient diff --git a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs index 7558ee4cc..b0fa8238f 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/GuildHelper.cs @@ -1392,5 +1392,25 @@ public static Task ModifyGuildOnboardingAsync(IGuild guild, Act } #endregion + + #region Soundboard + + public static async Task CreateSoundboardSoundAsync(BaseDiscordClient client, IGuild guild, Sound sound, string name, double volume, string emojiName = null, ulong? emojiId = null, RequestOptions options = null) + { + var args = new CreateSoundboardSoundParams() + { + Name = name, + Volume = volume, + EmojiName = emojiName ?? Optional.Unspecified, + EmojiId = emojiId ?? Optional.Unspecified, + Sound = sound.ToModel() + }; + + var model = await client.ApiClient.CreateSoundboardSoundAsync(guild.Id, args, options); + + return model.ToEntity(discord: client); + } + + #endregion } } diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index 114d37b07..2562faa13 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -1761,5 +1761,22 @@ async Task IGuild.ModifyOnboardingAsync(Action await ModifyOnboardingAsync(props, options); #endregion + + #region Soundboard + + public Task CreateSoundboardSoundAsync(Sound sound, string name, IEmote emoji = null, double volume = 1.0, RequestOptions options = null) + { + string emojiName = null; + ulong? emojiId = null; + + if (emoji is Emoji emj) + emojiName = emj.Name; + else if (emoji is Emote emt) + emojiId = emt.Id; + + return GuildHelper.CreateSoundboardSoundAsync(Discord, this, sound, name, volume, emojiName, emojiId, options); + } + + #endregion } } diff --git a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs index 34d279ecd..110917586 100644 --- a/src/Discord.Net.Rest/Extensions/EntityExtensions.cs +++ b/src/Discord.Net.Rest/Extensions/EntityExtensions.cs @@ -169,6 +169,11 @@ public static API.Image ToModel(this Image entity) return new API.Image(entity.Stream); } + public static API.Sound ToModel(this Sound entity) + { + return new API.Sound(entity.Stream); + } + public static Overwrite ToEntity(this API.Overwrite model) { return new Overwrite(model.TargetId, model.TargetType, new OverwritePermissions(model.Allow, model.Deny)); @@ -217,5 +222,20 @@ public static API.Message ToMessage(this API.InteractionResponse model, IDiscord Id = interaction.Id, }; } + + public static SoundboardSound ToEntity(this API.SoundboardSound model, IUser cachedAuthor = null, BaseDiscordClient discord = null) + => new(model.SoundId, + model.Name, + model.UserId, + model.Volume, + model.GuildId.IsSpecified + ? model.GuildId.Value + : null, + model.EmojiName.GetValueOrDefault(null), + model.EmojiId.GetValueOrDefault(null), + cachedAuthor ?? (model.User.IsSpecified + ? RestUser.Create(discord, model.User.Value) + : null), + model.IsAvailable.GetValueOrDefault(false)); } } diff --git a/src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs b/src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs index 8de2c0268..cb7871fc3 100644 --- a/src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs +++ b/src/Discord.Net.Rest/Net/Converters/DiscordContractResolver.cs @@ -85,6 +85,8 @@ private static JsonConverter GetConverter(JsonProperty property, PropertyInfo pr //Special if (type == typeof(API.Image)) return ImageConverter.Instance; + if (type == typeof(API.Sound)) + return SoundConverter.Instance; if (typeof(IMessageComponent).IsAssignableFrom(type)) return MessageComponentConverter.Instance; if (type == typeof(API.Interaction)) diff --git a/src/Discord.Net.Rest/Net/Converters/SoundConverter.cs b/src/Discord.Net.Rest/Net/Converters/SoundConverter.cs new file mode 100644 index 000000000..cbc7f23f8 --- /dev/null +++ b/src/Discord.Net.Rest/Net/Converters/SoundConverter.cs @@ -0,0 +1,56 @@ +using Newtonsoft.Json; + +using System; +using System.IO; +using Model = Discord.API.Sound; + +namespace Discord.Net.Converters; + +internal class SoundConverter : JsonConverter +{ + public static readonly SoundConverter Instance = new (); + + public override bool CanConvert(Type objectType) => true; + public override bool CanRead => true; + public override bool CanWrite => true; + + /// Cannot read from sound. + 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 sound = (Model)value; + + if (sound.Stream != null) + { + byte[] bytes; + int length; + if (sound.Stream.CanSeek) + { + bytes = new byte[sound.Stream.Length - sound.Stream.Position]; + length = sound.Stream.Read(bytes, 0, bytes.Length); + } + else + { + using (var cloneStream = new MemoryStream()) + { + sound.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:audio/mp3;base64,{base64}"); + } + else if (sound.Hash != null) + writer.WriteValue(sound.Hash); + } + +} + diff --git a/src/Discord.Net.WebSocket/API/Gateway/ExtendedGuild.cs b/src/Discord.Net.WebSocket/API/Gateway/ExtendedGuild.cs index 04ee38c0b..d98992f28 100644 --- a/src/Discord.Net.WebSocket/API/Gateway/ExtendedGuild.cs +++ b/src/Discord.Net.WebSocket/API/Gateway/ExtendedGuild.cs @@ -31,5 +31,8 @@ internal class ExtendedGuild : Guild [JsonProperty("guild_scheduled_events")] public GuildScheduledEvent[] GuildScheduledEvents { get; set; } + + [JsonProperty("soundboard_sounds")] + public SoundboardSound[] SoundboardSounds { get; set; } } } diff --git a/src/Discord.Net.WebSocket/API/Gateway/SoundboardSoundDeletedEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/SoundboardSoundDeletedEvent.cs new file mode 100644 index 000000000..67aa31dd7 --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/SoundboardSoundDeletedEvent.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; + +namespace Discord.API.Gateway; + +internal class SoundboardSoundDeletedEvent +{ + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("sound_id")] + public ulong SoundId { get; set; } +} diff --git a/src/Discord.Net.WebSocket/API/Gateway/VoiceChannelEffectSend.cs b/src/Discord.Net.WebSocket/API/Gateway/VoiceChannelEffectSend.cs new file mode 100644 index 000000000..f6b71625a --- /dev/null +++ b/src/Discord.Net.WebSocket/API/Gateway/VoiceChannelEffectSend.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; + +namespace Discord.WebSocket; + +internal class VoiceChannelEffectSend +{ + [JsonProperty("channel_id")] + public ulong ChannelId { get; set; } + + [JsonProperty("guild_id")] + public ulong GuildId { get; set; } + + [JsonProperty("user_id")] + public ulong UserId { get; set; } + + [JsonProperty("emoji")] + public Optional Emoji { get; set; } + + [JsonProperty("animation_type")] + public Optional AnimationType { get; set; } + + [JsonProperty("animation_id")] + public Optional AnimationId { get; set; } +} diff --git a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs index 88b601dc3..9c426a15c 100644 --- a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs +++ b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs @@ -1007,6 +1007,54 @@ public event Func, Task> EntitlementDeleted internal readonly AsyncEvent, Task>> _entitlementDeleted = new(); + #endregion + + #region Voice + + /// + /// Fired when a voice channel effect is sent. + /// + public event Func, Task> VoiceChannelEffectSend + { + add => _voiceChannelEffectSend.Add(value); + remove => _voiceChannelEffectSend.Remove(value); + } + internal readonly AsyncEvent, Task>> _voiceChannelEffectSend = new(); + + #endregion + + #region SoundBoard + + /// + /// Fired when a soundboard sound is created. + /// + public event Func SoundboardSoundCreated + { + add => _soundboardSoundCreated.Add(value); + remove => _soundboardSoundCreated.Remove(value); + } + internal readonly AsyncEvent> _soundboardSoundCreated = new(); + + /// + /// Fired when a soundboard sound is updated. + /// + public event Func, SoundboardSound, Task> SoundboardSoundUpdated + { + add => _soundboardSoundUpdated.Add(value); + remove => _soundboardSoundUpdated.Remove(value); + } + internal readonly AsyncEvent, SoundboardSound, Task>> _soundboardSoundUpdated = new(); + + /// + /// Fired when a soundboard sound is deleted. + /// + public event Func, Task> SoundboardSoundDeleted + { + add => _soundboardSoundDeleted.Add(value); + remove => _soundboardSoundDeleted.Remove(value); + } + internal readonly AsyncEvent, Task>> _soundboardSoundDeleted = new(); + #endregion } } diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs index 155d1fb14..233450753 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -521,6 +521,10 @@ private void RegisterEvents(DiscordSocketClient client, bool isPrimary) client.EntitlementCreated += (arg1) => _entitlementCreated.InvokeAsync(arg1); client.EntitlementUpdated += (arg1, arg2) => _entitlementUpdated.InvokeAsync(arg1, arg2); client.EntitlementDeleted += (arg1) => _entitlementDeleted.InvokeAsync(arg1); + + client.SoundboardSoundCreated += (arg1, arg2) => _soundboardSoundCreated.InvokeAsync(arg1, arg2); + client.SoundboardSoundUpdated += (arg1, arg2, arg3) => _soundboardSoundUpdated.InvokeAsync(arg1, arg2, arg3); + client.SoundboardSoundDeleted += (arg1, arg2) => _soundboardSoundDeleted.InvokeAsync(arg1, arg2); } public async Task CreateGlobalApplicationCommandAsync(ApplicationCommandProperties properties, RequestOptions options = null) diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 9641145bc..639ef579c 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -447,6 +447,10 @@ public IAsyncEnumerable> GetEntitlementsAsync( public Task> GetSKUsAsync(RequestOptions options = null) => ClientHelper.ListSKUsAsync(this, options); + /// + public Task> GetDefaultSoundboardSoundsAsync(RequestOptions options = null) + => ClientHelper.GetDefaultSoundboardSoundsAsync(this, options); + /// /// Gets entitlements from cache. /// @@ -2355,6 +2359,13 @@ private async Task ProcessMessageAsync(GatewayOpCode opCode, int? seq, string ty await TimedInvokeAsync(_voiceChannelStatusUpdated, nameof(VoiceChannelStatusUpdated), channelCacheable, before, after); } break; + + case "VOICE_CHANNEL_EFFECT_SEND": + { + + } + break; + #endregion #region Invites @@ -3161,6 +3172,53 @@ private async Task ProcessMessageAsync(GatewayOpCode opCode, int? seq, string ty #endregion + #region SoundBoard + + case "GUILD_SOUNDBOARD_SOUND_CREATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_SOUNDBOARD_SOUND_CREATE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var guild = GetGuild(data.GuildId.Value); + + var sound = guild.AddOrUpdateSoundboardSound(data); + + await TimedInvokeAsync(_soundboardSoundCreated, nameof(SoundboardSoundCreated), guild, sound); + } + break; + + case "GUILD_SOUNDBOARD_SOUND_UPDATE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_SOUNDBOARD_SOUND_UPDATE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var guild = GetGuild(data.GuildId.Value); + + var before = guild.GetSoundboardSound(data.SoundId)?.Clone(); + var beforeCacheable = new Cacheable(before, data.SoundId, before is not null, () => null); + + var after = guild.AddOrUpdateSoundboardSound(data); + + await TimedInvokeAsync(_soundboardSoundUpdated, nameof(SoundboardSoundUpdated), guild, beforeCacheable, after); + } + break; + + case "GUILD_SOUNDBOARD_SOUND_DELETE": + { + await _gatewayLogger.DebugAsync("Received Dispatch (GUILD_SOUNDBOARD_SOUND_DELETE)").ConfigureAwait(false); + var data = (payload as JToken).ToObject(_serializer); + + var guild = GetGuild(data.GuildId); + + var sound = guild.RemoveSoundboardSound(data.SoundId); + var soundCacheable = new Cacheable(sound, data.SoundId, sound is not null, () => null); + + await TimedInvokeAsync(_soundboardSoundDeleted, nameof(SoundboardSoundDeleted), guild, soundCacheable); + } + break; + + #endregion + #region Ignored (User only) case "CHANNEL_PINS_ACK": await _gatewayLogger.DebugAsync("Ignored Dispatch (CHANNEL_PINS_ACK)").ConfigureAwait(false); diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index 84c39d475..9e5f9caa1 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -26,6 +26,7 @@ using StickerModel = Discord.API.Sticker; using UserModel = Discord.API.User; using VoiceStateModel = Discord.API.VoiceState; +using SoundboardSoundModel = Discord.API.SoundboardSound; namespace Discord.WebSocket { @@ -47,6 +48,7 @@ public class SocketGuild : SocketEntity, IGuild, IDisposable private ConcurrentDictionary _stickers; private ConcurrentDictionary _events; private ConcurrentDictionary _automodRules; + private ConcurrentDictionary _soundboardSounds; private ImmutableArray _emotes; private readonly AuditLogCache _auditLogs; @@ -425,6 +427,14 @@ public IReadOnlyCollection Stickers /// public IReadOnlyCollection Events => _events.ToReadOnlyCollection(); + /// + /// Gets a collection of all soundboard sounds in this guild. + /// + /// + /// A read-only collection of soundboard sounds found within this guild. + /// + public IReadOnlyCollection SoundboardSounds => _soundboardSounds.ToReadOnlyCollection(); + internal SocketGuild(DiscordSocketClient client, ulong id) : base(client, id) { @@ -452,6 +462,8 @@ internal void Update(ClientState state, ExtendedModel model) _members = new ConcurrentDictionary(); if (_roles == null) _roles = new ConcurrentDictionary(); + if (_soundboardSounds == null) + _soundboardSounds = new ConcurrentDictionary(); /*if (Emojis == null) _emojis = ImmutableArray.Create(); if (Features == null) @@ -524,6 +536,15 @@ internal void Update(ClientState state, ExtendedModel model) } _events = events; + var soundboardSounds = new ConcurrentDictionary(ConcurrentHashSet.DefaultConcurrencyLevel, (int)(model.SoundboardSounds.Length * 1.05)); + { + for (var i = 0; i < model.SoundboardSounds.Length; i++) + { + var sound = model.SoundboardSounds[i].ToEntity(GetUser(model.SoundboardSounds[i].UserId), Discord); + soundboardSounds.TryAdd(sound.SoundId, sound); + } + } + _soundboardSounds = soundboardSounds; _syncPromise = new TaskCompletionSource(); _downloaderPromise = new TaskCompletionSource(); @@ -2011,6 +2032,45 @@ public async Task ModifyOnboardingAsync(Action + /// Gets a cached soundboard sound from this guild. + /// + public SoundboardSound GetSoundboardSound(ulong id) + => _soundboardSounds.TryGetValue(id, out var sound) ? sound : null; + + internal SoundboardSound RemoveSoundboardSound(ulong id) + => _soundboardSounds.TryRemove(id, out var value) ? value : null; + + internal SoundboardSound AddOrUpdateSoundboardSound(SoundboardSoundModel model) + { + if (_soundboardSounds.TryGetValue(model.SoundId, out var value)) + { + value.Volume = model.Volume; + value.Name = model.Name; + value.IsAvailable = model.IsAvailable.GetValueOrDefault(false); + value.Author = (IUser)GetUser(model.UserId) ?? (model.User.IsSpecified + ? RestUser.Create(Discord, model.User.Value) + : null); + + if (model.EmojiId is { IsSpecified: true, Value: not null }) + value.Emoji = new Emote(model.EmojiId.Value.Value, model.EmojiName.GetValueOrDefault(null), false); + else if (!string.IsNullOrWhiteSpace(model.EmojiName.GetValueOrDefault(null))) + value.Emoji = new Emoji(model.EmojiName.GetValueOrDefault(null)); + else + value.Emoji = null; + } + else + { + value = model.ToEntity(GetUser(model.UserId), Discord); + _soundboardSounds[model.SoundId] = value; + } + return value; + } + + #endregion + #region IGuild /// ulong? IGuild.AFKChannelId => AFKChannelId; diff --git a/test/Discord.Net.Tests.Unit/ChannelPermissionsTests.cs b/test/Discord.Net.Tests.Unit/ChannelPermissionsTests.cs index de7d0ae01..b81673df8 100644 --- a/test/Discord.Net.Tests.Unit/ChannelPermissionsTests.cs +++ b/test/Discord.Net.Tests.Unit/ChannelPermissionsTests.cs @@ -92,6 +92,7 @@ void AssertFlag(Func cstr, ChannelPermission flag) AssertFlag(() => new ChannelPermissions(startEmbeddedActivities: true), ChannelPermission.StartEmbeddedActivities); AssertFlag(() => new ChannelPermissions(useSoundboard: true), ChannelPermission.UseSoundboard); AssertFlag(() => new ChannelPermissions(createEvents: true), ChannelPermission.CreateEvents); + AssertFlag(() => new ChannelPermissions(useExternalSounds: true), ChannelPermission.UseExternalSounds); AssertFlag(() => new ChannelPermissions(sendVoiceMessages: true), ChannelPermission.SendVoiceMessages); AssertFlag(() => new ChannelPermissions(useClydeAI: true), ChannelPermission.UseClydeAI); AssertFlag(() => new ChannelPermissions(setVoiceChannelStatus: true), ChannelPermission.SetVoiceChannelStatus); @@ -162,6 +163,7 @@ void AssertUtil(ChannelPermission permission, AssertUtil(ChannelPermission.SendVoiceMessages, x => x.SendVoiceMessages, (p, enable) => p.Modify(sendVoiceMessages: enable)); AssertUtil(ChannelPermission.UseClydeAI, x => x.UseClydeAI, (p, enable) => p.Modify(useClydeAI: enable)); AssertUtil(ChannelPermission.SetVoiceChannelStatus, x => x.SetVoiceChannelStatus, (p, enable) => p.Modify(setVoiceChannelStatus: enable)); + AssertUtil(ChannelPermission.UseExternalSounds, x => x.UseExternalSounds, (p, enable) => p.Modify(useExternalSounds: enable)); } /// diff --git a/test/Discord.Net.Tests.Unit/GuildPermissionsTests.cs b/test/Discord.Net.Tests.Unit/GuildPermissionsTests.cs index 102860c8a..85f7ce57a 100644 --- a/test/Discord.Net.Tests.Unit/GuildPermissionsTests.cs +++ b/test/Discord.Net.Tests.Unit/GuildPermissionsTests.cs @@ -106,10 +106,11 @@ void AssertFlag(Func cstr, GuildPermission flag) AssertFlag(() => new GuildPermissions(useClydeAI: true), GuildPermission.UseClydeAI); AssertFlag(() => new GuildPermissions(createGuildExpressions: true), GuildPermission.CreateGuildExpressions); AssertFlag(() => new GuildPermissions(setVoiceChannelStatus: true), GuildPermission.SetVoiceChannelStatus); + AssertFlag(() => new GuildPermissions(useExternalSounds: true), GuildPermission.UseExternalSounds); } /// - /// Tests the behavior of + /// Tests the behavior of /// with each of the parameters. /// [Fact] @@ -190,6 +191,7 @@ void AssertUtil(GuildPermission permission, AssertUtil(GuildPermission.UseClydeAI, x => x.UseClydeAI, (p, enable) => p.Modify(useClydeAI: enable)); AssertUtil(GuildPermission.CreateGuildExpressions, x => x.CreateGuildExpressions, (p, enable) => p.Modify(createGuildExpressions: enable)); AssertUtil(GuildPermission.SetVoiceChannelStatus, x => x.SetVoiceChannelStatus, (p, enable) => p.Modify(setVoiceChannelStatus: enable)); + AssertUtil(GuildPermission.UseExternalSounds, x => x.UseExternalSounds, (p, enable) => p.Modify(useExternalSounds: enable)); } } }