Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Lavalink4NET.DSharpPlus.Nightly #159

Merged
merged 3 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading