Skip to content

Commit

Permalink
Merge pull request #159 from NycroV/dev
Browse files Browse the repository at this point in the history
Add Lavalink4NET.DSharpPlus.Nightly
  • Loading branch information
angelobreuer authored Jul 10, 2024
2 parents 4d082fc + 59cc7ca commit b809d54
Show file tree
Hide file tree
Showing 6 changed files with 367 additions and 4 deletions.
57 changes: 57 additions & 0 deletions src/Lavalink4NET.DSharpPlus.Nightly/DSharpPlusUtilities.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace Lavalink4NET.DSharpPlus;

using System;
using System.Collections.Concurrent;
using System.Reflection;
using global::DSharpPlus;
using global::DSharpPlus.AsyncEvents;

/// <summary>
/// An utility for getting internal / private fields from DSharpPlus WebSocket Gateway Payloads.
/// </summary>
public static partial class DSharpPlusUtilities
{
/// <summary>
/// The internal "events" property info in <see cref="DiscordClient"/>.
/// </summary>
// 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)!;

/// <summary>
/// Gets the internal "events" property value of the specified <paramref name="client"/>.
/// </summary>
/// <param name="client">the instance</param>
/// <returns>the "events" value</returns>
public static ConcurrentDictionary<Type, AsyncEvent> GetEvents(this DiscordClient client)
=> (ConcurrentDictionary<Type, AsyncEvent>)eventsField.GetValue(client)!;

/// <summary>
/// The internal "errorHandler" property info in <see cref="DiscordClient"/>.
/// </summary>
// 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)!;

/// <summary>
/// Gets the internal "errorHandler" property value of the specified <paramref name="client"/>.
/// </summary>
/// <param name="client">the instance</param>
/// <returns>the "errorHandler" value</returns>
public static IClientErrorHandler GetErrorHandler(this DiscordClient client)
=> (IClientErrorHandler)errorHandlerField.GetValue(client)!;

/// <summary>
/// The internal "Register" method info in <see cref="DiscordClient"/>.
/// </summary>
// 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)])!;

/// <summary>
/// Calls the internal "Register" method of the spedificed <paramref name="asyncEvent"/>
/// </summary>
/// <param name="asyncEvent">the instance</param>
/// <param name="delegate">the event to register</param>
public static void Register(this AsyncEvent asyncEvent, Delegate @delegate) => asyncEventRegisterMethod.Invoke(asyncEvent, [@delegate]);
}
241 changes: 241 additions & 0 deletions src/Lavalink4NET.DSharpPlus.Nightly/DiscordClientWrapper.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Wraps a <see cref="DiscordClient"/> or <see cref="DiscordShardedClient"/> instance.
/// </summary>
public sealed class DiscordClientWrapper : IDiscordClientWrapper
{
/// <inheritdoc/>
public event AsyncEventHandler<L4N.VoiceServerUpdatedEventArgs>? VoiceServerUpdated;

/// <inheritdoc/>
public event AsyncEventHandler<L4N.VoiceStateUpdatedEventArgs>? VoiceStateUpdated;

private readonly object _client; // either DiscordShardedClient or DiscordClient
private readonly ILogger<DiscordClientWrapper> _logger;
private readonly TaskCompletionSource<ClientInformation> _readyTaskCompletionSource;
private bool _disposed;

private DiscordClientWrapper(object discordClient, ILogger<DiscordClientWrapper> logger)
{
ArgumentNullException.ThrowIfNull(discordClient);
ArgumentNullException.ThrowIfNull(logger);

_client = discordClient;
_logger = logger;

_readyTaskCompletionSource = new TaskCompletionSource<ClientInformation>(TaskCreationOptions.RunContinuationsAsynchronously);
}

/// <summary>
/// Creates a new instance of <see cref="DiscordClientWrapper"/>.
/// </summary>
/// <param name="discordClient">The Discord Client to wrap.</param>
/// <param name="logger">a logger associated with this wrapper.</param>
public DiscordClientWrapper(DiscordClient discordClient, ILogger<DiscordClientWrapper> logger)
: this((object)discordClient, logger)
{
ArgumentNullException.ThrowIfNull(discordClient);

void AddEventHandler(Type eventArgsType, Delegate eventHandler)
{
IClientErrorHandler errorHandler = discordClient.GetErrorHandler();
ConcurrentDictionary<Type, AsyncEvent> 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<DiscordClient, VoiceStateUpdatedEventArgs>(OnVoiceStateUpdated));
AddEventHandler(typeof(VoiceServerUpdatedEventArgs), new AsyncEventHandler<DiscordClient, VoiceServerUpdatedEventArgs>(OnVoiceServerUpdated));
AddEventHandler(typeof(GuildDownloadCompletedEventArgs), new AsyncEventHandler<DiscordClient, GuildDownloadCompletedEventArgs>(OnGuildDownloadCompleted));
}

/// <summary>
/// Creates a new instance of <see cref="DiscordClientWrapper"/>.
/// </summary>
/// <param name="shardedDiscordClient">The Sharded Discord Client to wrap.</param>
/// <param name="logger">a logger associated with this wrapper.</param>
public DiscordClientWrapper(DiscordShardedClient shardedDiscordClient, ILogger<DiscordClientWrapper> logger)
: this((object)shardedDiscordClient, logger)
{
ArgumentNullException.ThrowIfNull(shardedDiscordClient);

shardedDiscordClient.VoiceStateUpdated += OnVoiceStateUpdated;
shardedDiscordClient.VoiceServerUpdated += OnVoiceServerUpdated;
shardedDiscordClient.GuildDownloadCompleted += OnGuildDownloadCompleted;
}

/// <inheritdoc/>
/// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
public async ValueTask<ImmutableArray<ulong>> 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<ulong>.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<ulong>.Empty;
}

var filteredUsers = ImmutableArray.CreateBuilder<ulong>(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();
}

/// <inheritdoc/>
/// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
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.
}

/// <inheritdoc/>
public ValueTask<ClientInformation> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFrameworks>net8.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<!-- Package Description -->
<Description>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.</Description>
<PackageTags>lavalink,lavalink-wrapper,discord,discord-music,discord-music-bot,dsharpplus</PackageTags>
<!-- Documentation -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackAsTool>False</PackAsTool>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DSharpPlus" Version="5.0.0-nightly-02304" />
<ProjectReference Include="../Lavalink4NET/Lavalink4NET.csproj" />
</ItemGroup>
<Import Project="../Lavalink4NET.targets" />
</Project>
22 changes: 22 additions & 0 deletions src/Lavalink4NET.DSharpPlus.Nightly/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Lavalink4NET.Extensions;

using System;
using Lavalink4NET.DSharpPlus;
using Microsoft.Extensions.DependencyInjection;

/// <summary>
/// A collection of extension methods for <see cref="IServiceCollection"/>.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds the Lavalink4NET DSharpPlus extension to the service collection.
/// </summary>
/// <param name="services">The service collection to add the extension to.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddLavalink(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
return services.AddLavalink<DiscordClientWrapper>();
}
}
18 changes: 18 additions & 0 deletions src/Lavalink4NET.DSharpPlus.Nightly/VoiceStateUpdatePayload.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
Loading

0 comments on commit b809d54

Please sign in to comment.