diff --git a/src/Discord.Net.Core/Discord.Net.Core.xml b/src/Discord.Net.Core/Discord.Net.Core.xml index 72820de90..ca9e9696f 100644 --- a/src/Discord.Net.Core/Discord.Net.Core.xml +++ b/src/Discord.Net.Core/Discord.Net.Core.xml @@ -1914,6 +1914,70 @@ A read-only collection of users that can access this channel. + + + Represents a generic Stage Channel. + + + + + Gets the topic of the Stage instance. + + + If the stage isn't live then this property will be set to . + + + + + The of the current stage. + + + If the stage isn't live then this property will be set to . + + + + + if stage discovery is disabled, otherwise . + + + + + when the stage is live, otherwise . + + + If the stage isn't live then this property will be set to . + + + + + 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. + + + + + 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. + + + + + Stops the stage, deleting the stage instance. + + The options to be used when sending the request. + + A task that represents the asynchronous stop operation. + + Represents a generic channel in a guild that can send and receive messages. @@ -2202,6 +2266,21 @@ Sets the ID of the channel to apply this position to. Sets the new zero-based position of this channel. + + + Represents properties to use when modifying a stage instance. + + + + + Gets or sets the topic of the stage. + + + + + Gets or sets the privacy level of the stage. + + Provides properties that are used to modify an with the specified changes. @@ -3344,6 +3423,29 @@ with the specified ; if none is found. + + + 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. + + + + + 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. + + Gets the AFK voice channel in this guild. @@ -9780,6 +9882,11 @@ true if the user is streaming; otherwise false. + + + Gets the time on which the user requested to speak. + + Represents a Webhook Discord user. 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 000000000..6f517d100 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/IStageChannel.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + /// + /// Represents a generic Stage Channel. + /// + public interface IStageChannel : IVoiceChannel + { + /// + /// Gets the topic of the Stage instance. + /// + /// + /// If the stage isn't live then this property will be set to . + /// + string Topic { get; } + + /// + /// The of the current stage. + /// + /// + /// If the stage isn't live then this property will be set to . + /// + StagePrivacyLevel? PrivacyLevel { get; } + + /// + /// if stage discovery is disabled, otherwise . + /// + bool? DiscoverableDisabled { get; } + + /// + /// when the stage is live, otherwise . + /// + /// + /// If the stage isn't live then this property will be set to . + /// + bool Live { 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); + + Task RequestToSpeak(RequestOptions options = null); + } +} 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 000000000..ad539adc3 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/StageInstanceProperties.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +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 000000000..6a51ab4ac --- /dev/null +++ b/src/Discord.Net.Core/Entities/Channels/StagePrivacyLevel.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Discord +{ + public enum StagePrivacyLevel + { + Public = 1, + GuildOnly = 2, + } +} diff --git a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs index e0be51cf5..ad2e0317d 100644 --- a/src/Discord.Net.Core/Entities/Guilds/IGuild.cs +++ b/src/Discord.Net.Core/Entities/Guilds/IGuild.cs @@ -530,6 +530,27 @@ namespace Discord /// 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. diff --git a/src/Discord.Net.Core/Entities/Users/IVoiceState.cs b/src/Discord.Net.Core/Entities/Users/IVoiceState.cs index a9b347003..c9a22761f 100644 --- a/src/Discord.Net.Core/Entities/Users/IVoiceState.cs +++ b/src/Discord.Net.Core/Entities/Users/IVoiceState.cs @@ -1,3 +1,5 @@ +using System; + namespace Discord { /// @@ -62,5 +64,9 @@ namespace Discord /// true if the user is streaming; otherwise false. /// bool IsStreaming { get; } + /// + /// Gets the time on which the user requested to speak. + /// + DateTimeOffset? RequestToSpeakTimestamp { get; } } } 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 000000000..4cb5f5823 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/StageInstance.cs @@ -0,0 +1,30 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +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/VoiceState.cs b/src/Discord.Net.Rest/API/Common/VoiceState.cs index c7a571ed0..27aacb6a4 100644 --- a/src/Discord.Net.Rest/API/Common/VoiceState.cs +++ b/src/Discord.Net.Rest/API/Common/VoiceState.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 using Newtonsoft.Json; +using System; namespace Discord.API { @@ -28,5 +29,7 @@ namespace Discord.API public bool Suppress { get; set; } [JsonProperty("self_stream")] public bool SelfStream { get; set; } + [JsonProperty("request_to_speak_timestamp")] + public Optional RequestToSpeakTimestamp { 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 000000000..294f9e1a5 --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/CreateStageInstanceParams.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.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/ModifyStageInstanceParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyStageInstanceParams.cs new file mode 100644 index 000000000..df73954de --- /dev/null +++ b/src/Discord.Net.Rest/API/Rest/ModifyStageInstanceParams.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.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/ModifyVoiceStateParams.cs b/src/Discord.Net.Rest/API/Rest/ModifyVoiceStateParams.cs new file mode 100644 index 000000000..1ff0f3e08 --- /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/Discord.Net.Rest.xml b/src/Discord.Net.Rest/Discord.Net.Rest.xml index 3aaa2c6a2..c8865f786 100644 --- a/src/Discord.Net.Rest/Discord.Net.Rest.xml +++ b/src/Discord.Net.Rest/Discord.Net.Rest.xml @@ -2281,6 +2281,38 @@ Represents a REST-based news channel in a guild that has the same properties as a . + + + Represents a REST-based stage channel in a guild. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Represents a REST-based channel in a guild that can send and receive messages. @@ -3130,6 +3162,28 @@ voice channels found within this guild. + + + 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. + + + + + 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. + + Gets a collection of all category channels in this guild. @@ -3496,6 +3550,12 @@ + + + + + + @@ -4365,6 +4425,9 @@ + + + Represents a REST-based guild user. @@ -4456,6 +4519,9 @@ + + + Represents the logged-in REST-based user. @@ -4679,6 +4745,9 @@ + + + diff --git a/src/Discord.Net.Rest/DiscordRestApiClient.cs b/src/Discord.Net.Rest/DiscordRestApiClient.cs index d34a6a4b9..99c2dadad 100644 --- a/src/Discord.Net.Rest/DiscordRestApiClient.cs +++ b/src/Discord.Net.Rest/DiscordRestApiClient.cs @@ -577,6 +577,70 @@ namespace Discord.API return await SendAsync("GET", () => $"channels/{channelId}/users/@me/threads/archived/private{query}", bucket, options: options); } + // stage + public async Task CreateStageInstanceAsync(CreateStageInstanceParams args, RequestOptions options = null) + { + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(); + + return await SendJsonAsync("POST", () => $"stage-instances", args, bucket, options: options).ConfigureAwait(false); + } + + public async 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 await SendJsonAsync("PATCH", () => $"stage-instances/{channelId}", args, bucket, options: options).ConfigureAwait(false); + } + + public async Task DeleteStageInstanceAsync(ulong channelId, RequestOptions options = null) + { + Preconditions.NotEqual(channelId, 0, nameof(channelId)); + + options = RequestOptions.CreateOrClone(options); + + try + { + await SendAsync("DELETE", $"stage-instances/{channelId}", 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 async Task ModifyMyVoiceState(ulong guildId, ModifyVoiceStateParams args, RequestOptions options = null) + { + Preconditions.NotEqual(guildId, 0, nameof(guildId)); + + options = RequestOptions.CreateOrClone(options); + + var bucket = new BucketIds(); + + await SendJsonAsync("PATCH", () => $"guilds/{guildId}/voice-states/@me", args, bucket, options: options).ConfigureAwait(false); + } + // roles public async Task AddRoleAsync(ulong guildId, ulong userId, ulong roleId, RequestOptions options = null) { diff --git a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs index f69c010f2..98715bfce 100644 --- a/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs +++ b/src/Discord.Net.Rest/Entities/Channels/ChannelHelper.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using Model = Discord.API.Channel; +using StageInstance = Discord.API.StageInstance; namespace Discord.Rest { @@ -92,6 +93,21 @@ namespace Discord.Rest return await client.ApiClient.ModifyGuildChannelAsync(channel.Id, apiArgs, options).ConfigureAwait(false); } + public static async 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 await client.ApiClient.ModifyStageInstanceAsync(channel.Id, apiArgs, options); + } + //Invites public static async Task> GetInvitesAsync(IGuildChannel channel, BaseDiscordClient client, RequestOptions options) diff --git a/src/Discord.Net.Rest/Entities/Channels/RestGuildChannel.cs b/src/Discord.Net.Rest/Entities/Channels/RestGuildChannel.cs index 27d6cd1a6..f0150aeb2 100644 --- a/src/Discord.Net.Rest/Entities/Channels/RestGuildChannel.cs +++ b/src/Discord.Net.Rest/Entities/Channels/RestGuildChannel.cs @@ -40,6 +40,8 @@ namespace Discord.Rest return RestTextChannel.Create(discord, guild, model); case ChannelType.Voice: return RestVoiceChannel.Create(discord, guild, model); + case ChannelType.Stage: + return RestStageChannel.Create(discord, guild, model); case ChannelType.Category: return RestCategoryChannel.Create(discord, guild, model); case ChannelType.PublicThread or ChannelType.PrivateThread or ChannelType.NewsThread: 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 000000000..883a080f1 --- /dev/null +++ b/src/Discord.Net.Rest/Entities/Channels/RestStageChannel.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Model = Discord.API.Channel; +using StageInstance = Discord.API.StageInstance; + +namespace Discord.Rest +{ + /// + /// Represents a REST-based stage channel in a guild. + /// + public class RestStageChannel : RestVoiceChannel, IStageChannel + { + /// + public string Topic { get; private set; } + + /// + public StagePrivacyLevel? PrivacyLevel { get; private set; } + + /// + public bool? DiscoverableDisabled { get; private set; } + + /// + public bool Live { get; private set; } + internal RestStageChannel(BaseDiscordClient discord, IGuild guild, ulong id) + : base(discord, guild, id) + { + + } + + internal static new RestStageChannel Create(BaseDiscordClient discord, IGuild guild, Model model) + { + var entity = new RestStageChannel(discord, guild, model.Id); + entity.Update(model); + return entity; + } + + internal void Update(StageInstance model, bool isLive = false) + { + this.Live = isLive; + if(isLive) + { + this.Topic = model.Topic; + this.PrivacyLevel = model.PrivacyLevel; + this.DiscoverableDisabled = model.DiscoverableDisabled; + } + else + { + this.Topic = null; + this.PrivacyLevel = null; + this.DiscoverableDisabled = 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 API.Rest.CreateStageInstanceParams() + { + ChannelId = this.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(this.Id, options); + + Update(null, false); + } + + /// + public override async Task UpdateAsync(RequestOptions options = null) + { + await base.UpdateAsync(options); + + var model = await Discord.ApiClient.GetStageInstanceAsync(this.Id, options); + + Update(model, model != null); + } + + /// + public Task RequestToSpeak(RequestOptions options = null) + { + var args = new API.Rest.ModifyVoiceStateParams() + { + ChannelId = this.Id, + RequestToSpeakTimestamp = DateTimeOffset.UtcNow + }; + return Discord.ApiClient.ModifyMyVoiceState(this.Guild.Id, args, options); + } + } +} diff --git a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs index a184f1290..2fab63347 100644 --- a/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs +++ b/src/Discord.Net.Rest/Entities/Guilds/RestGuild.cs @@ -451,6 +451,35 @@ namespace Discord.Rest 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 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. + /// + 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. @@ -952,6 +981,22 @@ namespace Discord.Rest 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) @@ -1109,5 +1154,6 @@ namespace Discord.Rest /// async Task> IGuild.GetWebhooksAsync(RequestOptions options) => await GetWebhooksAsync(options).ConfigureAwait(false); + } } diff --git a/src/Discord.Net.Rest/Entities/Users/RestGroupUser.cs b/src/Discord.Net.Rest/Entities/Users/RestGroupUser.cs index 55e9843eb..63b89035b 100644 --- a/src/Discord.Net.Rest/Entities/Users/RestGroupUser.cs +++ b/src/Discord.Net.Rest/Entities/Users/RestGroupUser.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; using Model = Discord.API.User; @@ -37,5 +38,7 @@ namespace Discord.Rest string IVoiceState.VoiceSessionId => null; /// bool IVoiceState.IsStreaming => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; } } diff --git a/src/Discord.Net.Rest/Entities/Users/RestGuildUser.cs b/src/Discord.Net.Rest/Entities/Users/RestGuildUser.cs index 6e6bbe09c..d094be618 100644 --- a/src/Discord.Net.Rest/Entities/Users/RestGuildUser.cs +++ b/src/Discord.Net.Rest/Entities/Users/RestGuildUser.cs @@ -169,5 +169,7 @@ namespace Discord.Rest string IVoiceState.VoiceSessionId => null; /// bool IVoiceState.IsStreaming => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; } } diff --git a/src/Discord.Net.Rest/Entities/Users/RestWebhookUser.cs b/src/Discord.Net.Rest/Entities/Users/RestWebhookUser.cs index 2131fec93..9297f9af9 100644 --- a/src/Discord.Net.Rest/Entities/Users/RestWebhookUser.cs +++ b/src/Discord.Net.Rest/Entities/Users/RestWebhookUser.cs @@ -107,5 +107,7 @@ namespace Discord.Rest string IVoiceState.VoiceSessionId => null; /// bool IVoiceState.IsStreaming => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; } } diff --git a/src/Discord.Net.WebSocket/API/Gateway/GuildMemberUpdateEvent.cs b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberUpdateEvent.cs index a234d6da5..744d2d502 100644 --- a/src/Discord.Net.WebSocket/API/Gateway/GuildMemberUpdateEvent.cs +++ b/src/Discord.Net.WebSocket/API/Gateway/GuildMemberUpdateEvent.cs @@ -1,10 +1,14 @@ -#pragma warning disable CS1591 +#pragma warning disable CS1591 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/BaseSocketClient.Events.cs b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs index 32f79f3d1..4982655d7 100644 --- a/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs +++ b/src/Discord.Net.WebSocket/BaseSocketClient.Events.cs @@ -592,5 +592,64 @@ namespace Discord.WebSocket } 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>(); } } diff --git a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj index 3f1f63db3..35aabb010 100644 --- a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj +++ b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.csproj @@ -1,4 +1,4 @@ - + @@ -17,6 +17,9 @@ ..\Discord.Net.WebSocket\Discord.Net.WebSocket.xml + + DEBUG;TRACE;DEBUG_LIMITS + diff --git a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.xml b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.xml index 01d919d2b..93873217f 100644 --- a/src/Discord.Net.WebSocket/Discord.Net.WebSocket.xml +++ b/src/Discord.Net.WebSocket/Discord.Net.WebSocket.xml @@ -781,6 +781,36 @@ Fired when a user leaves a thread + + + Fired when a stage is started. + + + + + Fired when a stage ends. + + + + + Fired when a stage is updated. + + + + + Fired when a user requests to speak within a stage channel. + + + + + Fired when a speaker is added in a stage channel. + + + + + Fired when a speaker is removed from a stage channel. + + @@ -2142,6 +2172,40 @@ + + + Represents a stage channel recieved over the gateway. + + + + + + + + + + + + + + + + + Gets a collection of users who are speakers within the stage. + + + + + + + + + + + + + + Represents a WebSocket-based channel in a guild that can send and receive messages. @@ -2901,6 +2965,14 @@ A read-only collection of voice channels found within this guild. + + + Gets a collection of all stage channels in this guild. + + + A read-only collection of stage channels found within this guild. + + Gets a collection of all category channels in this guild. @@ -3076,6 +3148,15 @@ A voice channel associated with the specified ; if none is found. + + + 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. + + Gets a category channel in this guild. @@ -3417,6 +3498,12 @@ + + + + + + @@ -4358,6 +4445,9 @@ + + + Represents a WebSocket-based guild user. @@ -4407,6 +4497,9 @@ + + + @@ -4654,6 +4747,9 @@ + + + @@ -4819,6 +4915,9 @@ + + + @@ -4968,6 +5067,9 @@ + + + Represents a WebSocket-based voice server. diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.Events.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.Events.cs index c9e679669..a9e6e311d 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.Events.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.Events.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading.Tasks; namespace Discord.WebSocket @@ -35,4 +35,4 @@ namespace Discord.WebSocket } private readonly AsyncEvent> _shardLatencyUpdatedEvent = new AsyncEvent>(); } -} \ No newline at end of file +} diff --git a/src/Discord.Net.WebSocket/DiscordShardedClient.cs b/src/Discord.Net.WebSocket/DiscordShardedClient.cs index 18272eef2..6847d8580 100644 --- a/src/Discord.Net.WebSocket/DiscordShardedClient.cs +++ b/src/Discord.Net.WebSocket/DiscordShardedClient.cs @@ -386,6 +386,13 @@ namespace Discord.WebSocket 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); } //IDiscordClient diff --git a/src/Discord.Net.WebSocket/DiscordSocketClient.cs b/src/Discord.Net.WebSocket/DiscordSocketClient.cs index 14a9bbbe8..3b283a004 100644 --- a/src/Discord.Net.WebSocket/DiscordSocketClient.cs +++ b/src/Discord.Net.WebSocket/DiscordSocketClient.cs @@ -1770,6 +1770,29 @@ namespace Discord.WebSocket } } + 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; @@ -2181,6 +2204,47 @@ namespace Discord.WebSocket break; + 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" ? true : type == "STAGE_INSTANCE_DELETE" ? false : false); + + switch (type) + { + case "STAGE_INSTANCE_CREATE": + await TimedInvokeAsync(_stageStarted, nameof(StageStarted), stageChannel); + return; + case "STAGE_INSTANCE_DELETE": + await TimedInvokeAsync(_stageEnded, nameof(StageEnded), stageChannel); + return; + case "STAGE_INSTANCE_UPDATE": + await TimedInvokeAsync(_stageUpdated, nameof(StageUpdated), before, stageChannel); + return; + } + } + break; + //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/Channels/SocketGuildChannel.cs b/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs index 7187c9771..196f3c558 100644 --- a/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketGuildChannel.cs @@ -58,6 +58,8 @@ namespace Discord.WebSocket return SocketCategoryChannel.Create(guild, state, model); case ChannelType.PrivateThread or ChannelType.PublicThread or ChannelType.NewsThread: return SocketThreadChannel.Create(guild, state, model); + case ChannelType.Stage: + return SocketStageChannel.Create(guild, state, model); default: return new SocketGuildChannel(guild.Discord, model.Id, guild); } 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 000000000..15e49901e --- /dev/null +++ b/src/Discord.Net.WebSocket/Entities/Channels/SocketStageChannel.cs @@ -0,0 +1,116 @@ +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.Channel; +using StageInstance = Discord.API.StageInstance; + +namespace Discord.WebSocket +{ + /// + /// Represents a stage channel recieved over the gateway. + /// + public class SocketStageChannel : SocketVoiceChannel, IStageChannel + { + /// + public string Topic { get; private set; } + + /// + public StagePrivacyLevel? PrivacyLevel { get; private set; } + + /// + public bool? DiscoverableDisabled { get; private set; } + + /// + public bool Live { get; private set; } = false; + + /// + /// Gets a collection of users who are speakers within the stage. + /// + public IReadOnlyCollection Speakers + => this.Users.Where(x => !x.IsSuppressed).ToImmutableArray(); + + 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 override void Update(ClientState state, Model model) + { + base.Update(state, model); + } + + internal void Update(StageInstance model, bool isLive = false) + { + this.Live = isLive; + if (isLive) + { + this.Topic = model.Topic; + this.PrivacyLevel = model.PrivacyLevel; + this.DiscoverableDisabled = model.DiscoverableDisabled; + } + else + { + this.Topic = null; + this.PrivacyLevel = null; + this.DiscoverableDisabled = null; + } + } + + /// + public async Task StartStageAsync(string topic, StagePrivacyLevel privacyLevel = StagePrivacyLevel.GuildOnly, RequestOptions options = null) + { + var args = new API.Rest.CreateStageInstanceParams() + { + ChannelId = this.Id, + Topic = topic, + PrivacyLevel = privacyLevel, + }; + + var model = await Discord.ApiClient.CreateStageInstanceAsync(args, options).ConfigureAwait(false); + + this.Update(model, true); + } + + /// + public async Task ModifyInstanceAsync(Action func, RequestOptions options = null) + { + var model = await ChannelHelper.ModifyAsync(this, Discord, func, options); + + this.Update(model, true); + } + + /// + public async Task StopStageAsync(RequestOptions options = null) + { + await Discord.ApiClient.DeleteStageInstanceAsync(this.Id, options); + + Update(null, false); + } + + /// + public Task RequestToSpeak(RequestOptions options = null) + { + var args = new API.Rest.ModifyVoiceStateParams() + { + ChannelId = this.Id, + RequestToSpeakTimestamp = DateTimeOffset.UtcNow + }; + return Discord.ApiClient.ModifyMyVoiceState(this.Guild.Id, args, options); + } + } +} diff --git a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs index 33c266beb..c32bb3f49 100644 --- a/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs +++ b/src/Discord.Net.WebSocket/Entities/Guilds/SocketGuild.cs @@ -271,6 +271,14 @@ namespace Discord.WebSocket 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. /// /// @@ -652,6 +660,15 @@ namespace Discord.WebSocket 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. @@ -1354,6 +1371,12 @@ namespace Discord.WebSocket Task IGuild.GetVoiceChannelAsync(ulong id, CacheMode mode, RequestOptions options) => Task.FromResult(GetVoiceChannel(id)); /// + Task IGuild.GetStageChannelAsync(ulong id, CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + => Task.FromResult(GetStageChannel(id)); + /// + Task> IGuild.GetStageChannelsAsync(CacheMode mode = CacheMode.AllowDownload, RequestOptions options = null) + => Task.FromResult>(StageChannels); + /// Task IGuild.GetAFKChannelAsync(CacheMode mode, RequestOptions options) => Task.FromResult(AFKChannel); /// @@ -1462,5 +1485,7 @@ namespace Discord.WebSocket _audioLock?.Dispose(); _audioClient?.Dispose(); } + + } } diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGroupUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGroupUser.cs index 8545e492b..805a88110 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketGroupUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGroupUser.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; using Model = Discord.API.User; @@ -66,5 +67,7 @@ namespace Discord.WebSocket string IVoiceState.VoiceSessionId => null; /// bool IVoiceState.IsStreaming => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; } } diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs index 444c76ffa..f79fc7afe 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketGuildUser.cs @@ -57,7 +57,11 @@ namespace Discord.WebSocket /// public bool IsStreaming => VoiceState?.IsStreaming ?? false; /// + public DateTimeOffset? RequestToSpeakTimestamp => VoiceState?.RequestToSpeakTimestamp ?? null; + /// public bool? IsPending { get; private set; } + + /// public DateTimeOffset? JoinedAt => DateTimeUtils.FromTicks(_joinedAtTicks); /// diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketThreadUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketThreadUser.cs index df9194d5b..d1237d598 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketThreadUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketThreadUser.cs @@ -110,6 +110,10 @@ namespace Discord.WebSocket public bool IsStreaming => GuildUser.IsStreaming; + /// + public DateTimeOffset? RequestToSpeakTimestamp + => GuildUser.RequestToSpeakTimestamp; + private SocketGuildUser GuildUser { get; set; } internal SocketThreadUser(SocketGuild guild, SocketThreadChannel thread, SocketGuildUser member) diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketVoiceState.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketVoiceState.cs index 5bf36e796..816a839fc 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketVoiceState.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketVoiceState.cs @@ -13,7 +13,7 @@ namespace Discord.WebSocket /// /// Initializes a default with everything set to null or false. /// - public static readonly SocketVoiceState Default = new SocketVoiceState(null, null, false, false, false, false, false, false); + public static readonly SocketVoiceState Default = new SocketVoiceState(null, null, null, false, false, false, false, false, false); [Flags] private enum Flags : byte @@ -35,6 +35,8 @@ namespace Discord.WebSocket public SocketVoiceChannel VoiceChannel { get; } /// public string VoiceSessionId { get; } + /// + public DateTimeOffset? RequestToSpeakTimestamp { get; private set; } /// public bool IsMuted => (_voiceStates & Flags.Muted) != 0; @@ -48,11 +50,13 @@ namespace Discord.WebSocket public bool IsSelfDeafened => (_voiceStates & Flags.SelfDeafened) != 0; /// public bool IsStreaming => (_voiceStates & Flags.SelfStream) != 0; + - internal SocketVoiceState(SocketVoiceChannel voiceChannel, string sessionId, bool isSelfMuted, bool isSelfDeafened, bool isMuted, bool isDeafened, bool isSuppressed, bool isStream) + internal SocketVoiceState(SocketVoiceChannel voiceChannel, DateTimeOffset? requestToSpeak, string sessionId, bool isSelfMuted, bool isSelfDeafened, bool isMuted, bool isDeafened, bool isSuppressed, bool isStream) { VoiceChannel = voiceChannel; VoiceSessionId = sessionId; + RequestToSpeakTimestamp = requestToSpeak; Flags voiceStates = Flags.Normal; if (isSelfMuted) @@ -71,7 +75,7 @@ namespace Discord.WebSocket } internal static SocketVoiceState Create(SocketVoiceChannel voiceChannel, Model model) { - return new SocketVoiceState(voiceChannel, model.SessionId, model.SelfMute, model.SelfDeaf, model.Mute, model.Deaf, model.Suppress, model.SelfStream); + 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); } /// diff --git a/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs b/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs index 404ab116d..2b0ecbb19 100644 --- a/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs +++ b/src/Discord.Net.WebSocket/Entities/Users/SocketWebhookUser.cs @@ -138,5 +138,7 @@ namespace Discord.WebSocket string IVoiceState.VoiceSessionId => null; /// bool IVoiceState.IsStreaming => false; + /// + DateTimeOffset? IVoiceState.RequestToSpeakTimestamp => null; } }