diff --git a/src/Lavalink4NET.DSharpPlus.Nightly/DSharpPlusUtilities.cs b/src/Lavalink4NET.DSharpPlus.Nightly/DSharpPlusUtilities.cs new file mode 100644 index 00000000..1085b93e --- /dev/null +++ b/src/Lavalink4NET.DSharpPlus.Nightly/DSharpPlusUtilities.cs @@ -0,0 +1,57 @@ +namespace Lavalink4NET.DSharpPlus; + +using System; +using System.Collections.Concurrent; +using System.Reflection; +using global::DSharpPlus; +using global::DSharpPlus.AsyncEvents; + +/// +/// An utility for getting internal / private fields from DSharpPlus WebSocket Gateway Payloads. +/// +public static partial class DSharpPlusUtilities +{ + /// + /// The internal "events" property info in . + /// + // https://github.com/DSharpPlus/DSharpPlus/blob/master/DSharpPlus/Clients/DiscordClient.cs#L37 + private static readonly FieldInfo eventsField = + typeof(DiscordClient).GetField("events", BindingFlags.NonPublic | BindingFlags.Instance)!; + + /// + /// Gets the internal "events" property value of the specified . + /// + /// the instance + /// the "events" value + public static ConcurrentDictionary GetEvents(this DiscordClient client) + => (ConcurrentDictionary)eventsField.GetValue(client)!; + + /// + /// The internal "errorHandler" property info in . + /// + // https://github.com/DSharpPlus/DSharpPlus/blob/master/DSharpPlus/Clients/DiscordClient.cs#L41 + private static readonly FieldInfo errorHandlerField = + typeof(DiscordClient).GetField("errorHandler", BindingFlags.NonPublic | BindingFlags.Instance)!; + + /// + /// Gets the internal "errorHandler" property value of the specified . + /// + /// the instance + /// the "errorHandler" value + public static IClientErrorHandler GetErrorHandler(this DiscordClient client) + => (IClientErrorHandler)errorHandlerField.GetValue(client)!; + + /// + /// The internal "Register" method info in . + /// + // https://github.com/DSharpPlus/DSharpPlus/blob/master/DSharpPlus/AsyncEvents/AsyncEvent.cs#L14 + private static readonly MethodInfo asyncEventRegisterMethod = + typeof(AsyncEvent).GetMethod("Register", BindingFlags.NonPublic | BindingFlags.Instance, [typeof(Delegate)])!; + + /// + /// Calls the internal "Register" method of the spedificed + /// + /// the instance + /// the event to register + public static void Register(this AsyncEvent asyncEvent, Delegate @delegate) => asyncEventRegisterMethod.Invoke(asyncEvent, [@delegate]); +} diff --git a/src/Lavalink4NET.DSharpPlus.Nightly/DiscordClientWrapper.cs b/src/Lavalink4NET.DSharpPlus.Nightly/DiscordClientWrapper.cs new file mode 100644 index 00000000..57428a5c --- /dev/null +++ b/src/Lavalink4NET.DSharpPlus.Nightly/DiscordClientWrapper.cs @@ -0,0 +1,241 @@ +namespace Lavalink4NET.DSharpPlus; + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using global::DSharpPlus; +using global::DSharpPlus.AsyncEvents; +using global::DSharpPlus.Entities; +using global::DSharpPlus.EventArgs; +using global::DSharpPlus.Exceptions; +using global::DSharpPlus.Net.Abstractions; +using Lavalink4NET.Clients; +using L4N = Clients.Events; +using Lavalink4NET.Events; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; + +/// +/// Wraps a or instance. +/// +public sealed class DiscordClientWrapper : IDiscordClientWrapper +{ + /// + public event AsyncEventHandler? VoiceServerUpdated; + + /// + public event AsyncEventHandler? VoiceStateUpdated; + + private readonly object _client; // either DiscordShardedClient or DiscordClient + private readonly ILogger _logger; + private readonly TaskCompletionSource _readyTaskCompletionSource; + private bool _disposed; + + private DiscordClientWrapper(object discordClient, ILogger logger) + { + ArgumentNullException.ThrowIfNull(discordClient); + ArgumentNullException.ThrowIfNull(logger); + + _client = discordClient; + _logger = logger; + + _readyTaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + /// + /// Creates a new instance of . + /// + /// The Discord Client to wrap. + /// a logger associated with this wrapper. + public DiscordClientWrapper(DiscordClient discordClient, ILogger logger) + : this((object)discordClient, logger) + { + ArgumentNullException.ThrowIfNull(discordClient); + + void AddEventHandler(Type eventArgsType, Delegate eventHandler) + { + IClientErrorHandler errorHandler = discordClient.GetErrorHandler(); + ConcurrentDictionary events = discordClient.GetEvents(); + + Type asyncEventType = typeof(AsyncEvent<,>).MakeGenericType(discordClient.GetType(), eventArgsType); + AsyncEvent asyncEvent = events.GetOrAdd(eventArgsType, _ => (AsyncEvent)Activator.CreateInstance + ( + type: asyncEventType, + args: [errorHandler] + )!); + + asyncEvent.Register(eventHandler); + } + + AddEventHandler(typeof(VoiceStateUpdatedEventArgs), new AsyncEventHandler(OnVoiceStateUpdated)); + AddEventHandler(typeof(VoiceServerUpdatedEventArgs), new AsyncEventHandler(OnVoiceServerUpdated)); + AddEventHandler(typeof(GuildDownloadCompletedEventArgs), new AsyncEventHandler(OnGuildDownloadCompleted)); + } + + /// + /// Creates a new instance of . + /// + /// The Sharded Discord Client to wrap. + /// a logger associated with this wrapper. + public DiscordClientWrapper(DiscordShardedClient shardedDiscordClient, ILogger logger) + : this((object)shardedDiscordClient, logger) + { + ArgumentNullException.ThrowIfNull(shardedDiscordClient); + + shardedDiscordClient.VoiceStateUpdated += OnVoiceStateUpdated; + shardedDiscordClient.VoiceServerUpdated += OnVoiceServerUpdated; + shardedDiscordClient.GuildDownloadCompleted += OnGuildDownloadCompleted; + } + + /// + /// thrown if the instance is disposed + public async ValueTask> GetChannelUsersAsync( + ulong guildId, + ulong voiceChannelId, + bool includeBots = false, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + DiscordChannel channel; + try + { + channel = await GetClientForGuild(guildId) + .GetChannelAsync(voiceChannelId) + .ConfigureAwait(false); + + if (channel is null) + { + return ImmutableArray.Empty; + } + } + catch (DiscordException exception) + { + _logger.LogWarning( + exception, "An error occurred while retrieving the users for voice channel '{VoiceChannelId}' of the guild '{GuildId}'.", + voiceChannelId, guildId); + + return ImmutableArray.Empty; + } + + var filteredUsers = ImmutableArray.CreateBuilder(channel.Users.Count); + + foreach (var member in channel.Users) + { + // Always skip the current user. + // If we're not including bots and the member is a bot, skip them. + if (!member.IsCurrent || includeBots || !member.IsBot) + { + filteredUsers.Add(member.Id); + } + } + + return filteredUsers.ToImmutable(); + } + + /// + /// thrown if the instance is disposed + public async ValueTask SendVoiceUpdateAsync( + ulong guildId, + ulong? voiceChannelId, + bool selfDeaf = false, + bool selfMute = false, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var client = GetClientForGuild(guildId); + + var payload = new VoiceStateUpdatePayload + { + GuildId = guildId, + ChannelId = voiceChannelId, + IsSelfMuted = selfMute, + IsSelfDeafened = selfDeaf, + }; + +#pragma warning disable CS0618 // This method should not be used unless you know what you're doing. Instead, look towards the other explicitly implemented methods which come with client-side validation. + // Jan 23, 2024, OoLunar: We're telling Discord that we're joining a voice channel. + // At the time of writing, both DSharpPlus.VoiceNext and DSharpPlus.VoiceLink™ + // use this method to send voice state updates. + await client + .SendPayloadAsync(GatewayOpCode.VoiceStateUpdate, payload) + .ConfigureAwait(false); +#pragma warning restore CS0618 // This method should not be used unless you know what you're doing. Instead, look towards the other explicitly implemented methods which come with client-side validation. + } + + /// + public ValueTask WaitForReadyAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return new(_readyTaskCompletionSource.Task.WaitAsync(cancellationToken)); + } + + private DiscordClient GetClientForGuild(ulong guildId) => _client is DiscordClient discordClient + ? discordClient + : ((DiscordShardedClient)_client).GetShard(guildId); + + private Task OnGuildDownloadCompleted(DiscordClient discordClient, GuildDownloadCompletedEventArgs eventArgs) + { + ArgumentNullException.ThrowIfNull(discordClient); + ArgumentNullException.ThrowIfNull(eventArgs); + + var clientInformation = new ClientInformation( + Label: "DSharpPlus", + CurrentUserId: discordClient.CurrentUser.Id, + ShardCount: discordClient.ShardCount); + + _readyTaskCompletionSource.TrySetResult(clientInformation); + return Task.CompletedTask; + } + + private async Task OnVoiceServerUpdated(DiscordClient discordClient, VoiceServerUpdatedEventArgs voiceServerUpdateEventArgs) + { + ArgumentNullException.ThrowIfNull(discordClient); + ArgumentNullException.ThrowIfNull(voiceServerUpdateEventArgs); + + var server = new VoiceServer( + Token: voiceServerUpdateEventArgs.VoiceToken, + Endpoint: voiceServerUpdateEventArgs.Endpoint); + + var eventArgs = new L4N.VoiceServerUpdatedEventArgs( + guildId: voiceServerUpdateEventArgs.Guild.Id, + voiceServer: server); + + await VoiceServerUpdated + .InvokeAsync(this, eventArgs) + .ConfigureAwait(false); + } + + private async Task OnVoiceStateUpdated(DiscordClient discordClient, VoiceStateUpdatedEventArgs voiceStateUpdateEventArgs) + { + ArgumentNullException.ThrowIfNull(discordClient); + ArgumentNullException.ThrowIfNull(voiceStateUpdateEventArgs); + + // session id is the same as the resume key so DSharpPlus should be able to give us the + // session key in either before or after voice state + var sessionId = voiceStateUpdateEventArgs.Before?.SessionId ?? voiceStateUpdateEventArgs.After.SessionId; + + // create voice state + var voiceState = new VoiceState( + VoiceChannelId: voiceStateUpdateEventArgs.After?.Channel?.Id, + SessionId: sessionId); + + var oldVoiceState = new VoiceState( + VoiceChannelId: voiceStateUpdateEventArgs.Before?.Channel?.Id, + SessionId: sessionId); + + // invoke event + var eventArgs = new L4N.VoiceStateUpdatedEventArgs( + guildId: voiceStateUpdateEventArgs.Guild.Id, + userId: voiceStateUpdateEventArgs.User.Id, + isCurrentUser: voiceStateUpdateEventArgs.User.Id == discordClient.CurrentUser.Id, + oldVoiceState: oldVoiceState, + voiceState: voiceState); + + await VoiceStateUpdated + .InvokeAsync(this, eventArgs) + .ConfigureAwait(false); + } +} diff --git a/src/Lavalink4NET.DSharpPlus.Nightly/Lavalink4NET.DSharpPlus.Nightly.csproj b/src/Lavalink4NET.DSharpPlus.Nightly/Lavalink4NET.DSharpPlus.Nightly.csproj new file mode 100644 index 00000000..c561a925 --- /dev/null +++ b/src/Lavalink4NET.DSharpPlus.Nightly/Lavalink4NET.DSharpPlus.Nightly.csproj @@ -0,0 +1,18 @@ + + + Library + net8.0 + latest + + High performance Lavalink wrapper for .NET | Add powerful audio playback to your DSharpPlus-nightly-based applications with this integration for Lavalink4NET. Suitable for end users developing with DSharpPlus Nightly builds. + lavalink,lavalink-wrapper,discord,discord-music,discord-music-bot,dsharpplus + + true + False + + + + + + + \ No newline at end of file diff --git a/src/Lavalink4NET.DSharpPlus.Nightly/ServiceCollectionExtensions.cs b/src/Lavalink4NET.DSharpPlus.Nightly/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f127068f --- /dev/null +++ b/src/Lavalink4NET.DSharpPlus.Nightly/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +namespace Lavalink4NET.Extensions; + +using System; +using Lavalink4NET.DSharpPlus; +using Microsoft.Extensions.DependencyInjection; + +/// +/// A collection of extension methods for . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds the Lavalink4NET DSharpPlus extension to the service collection. + /// + /// The service collection to add the extension to. + /// The service collection for chaining. + public static IServiceCollection AddLavalink(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + return services.AddLavalink(); + } +} diff --git a/src/Lavalink4NET.DSharpPlus.Nightly/VoiceStateUpdatePayload.cs b/src/Lavalink4NET.DSharpPlus.Nightly/VoiceStateUpdatePayload.cs new file mode 100644 index 00000000..49e329ab --- /dev/null +++ b/src/Lavalink4NET.DSharpPlus.Nightly/VoiceStateUpdatePayload.cs @@ -0,0 +1,18 @@ +namespace Lavalink4NET.DSharpPlus; + +using Newtonsoft.Json; + +internal sealed class VoiceStateUpdatePayload +{ + [JsonProperty("guild_id")] + public ulong GuildId { get; init; } + + [JsonProperty("channel_id")] + public ulong? ChannelId { get; init; } + + [JsonProperty("self_mute")] + public bool IsSelfMuted { get; init; } + + [JsonProperty("self_deaf")] + public bool IsSelfDeafened { get; init; } +} diff --git a/src/Lavalink4NET.sln b/src/Lavalink4NET.sln index 4aab41d1..035963e4 100644 --- a/src/Lavalink4NET.sln +++ b/src/Lavalink4NET.sln @@ -88,13 +88,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.L EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.Lavasrc.Tests", "..\tests\Lavalink4NET.Integrations.Lavasrc.Tests\Lavalink4NET.Integrations.Lavasrc.Tests.csproj", "{5779F765-5F0D-422C-984A-7E44EAE737C8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.NetCord", "Lavalink4NET.NetCord\Lavalink4NET.NetCord.csproj", "{8587F98B-CFE1-4559-9614-ED3B2B0C4F4E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.NetCord", "Lavalink4NET.NetCord\Lavalink4NET.NetCord.csproj", "{8587F98B-CFE1-4559-9614-ED3B2B0C4F4E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Samples.NetCord", "..\samples\Lavalink4NET.Samples.NetCord\Lavalink4NET.Samples.NetCord.csproj", "{02FE863F-D979-439A-9A51-C4EA69D58D29}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Samples.NetCord", "..\samples\Lavalink4NET.Samples.NetCord\Lavalink4NET.Samples.NetCord.csproj", "{02FE863F-D979-439A-9A51-C4EA69D58D29}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Integrations.LyricsJava", "Lavalink4NET.Integrations.LyricsJava\Lavalink4NET.Integrations.LyricsJava.csproj", "{9A30E985-6D67-41D4-A12F-F1ADCD2ED0FE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.LyricsJava", "Lavalink4NET.Integrations.LyricsJava\Lavalink4NET.Integrations.LyricsJava.csproj", "{9A30E985-6D67-41D4-A12F-F1ADCD2ED0FE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Integrations.LyricsJava.Tests", "Lavalink4NET.Integrations.LyricsJava.Tests\Lavalink4NET.Integrations.LyricsJava.Tests.csproj", "{176B0345-DF57-42B4-A8FD-4E6436D9554C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.LyricsJava.Tests", "Lavalink4NET.Integrations.LyricsJava.Tests\Lavalink4NET.Integrations.LyricsJava.Tests.csproj", "{176B0345-DF57-42B4-A8FD-4E6436D9554C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.DSharpPlus.Nightly", "Lavalink4NET.DSharpPlus.Nightly\Lavalink4NET.DSharpPlus.Nightly.csproj", "{1A30629A-399B-4293-B5F4-B3909C1772D0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -258,6 +260,10 @@ Global {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Debug|Any CPU.Build.0 = Debug|Any CPU {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Release|Any CPU.ActiveCfg = Release|Any CPU {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Release|Any CPU.Build.0 = Release|Any CPU + {1A30629A-399B-4293-B5F4-B3909C1772D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1A30629A-399B-4293-B5F4-B3909C1772D0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1A30629A-399B-4293-B5F4-B3909C1772D0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1A30629A-399B-4293-B5F4-B3909C1772D0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -286,6 +292,7 @@ Global {02FE863F-D979-439A-9A51-C4EA69D58D29} = {B9402D29-5B12-4672-97B8-570A60C0F878} {9A30E985-6D67-41D4-A12F-F1ADCD2ED0FE} = {48ECDC71-B9E3-4086-8194-DA81B4667CA6} {176B0345-DF57-42B4-A8FD-4E6436D9554C} = {48ECDC71-B9E3-4086-8194-DA81B4667CA6} + {1A30629A-399B-4293-B5F4-B3909C1772D0} = {5FAEC63E-9752-48C4-8BC9-B101E0DBDBD3} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {466A619D-5C4B-4A8F-9852-7A5322F160A2}