angelobreuer / Lavalink4NET

Lavalink4NET is a Lavalink wrapper with node clustering, caching and custom players for .NET with support for Discord.Net, DSharpPlus, Remora, and NetCord.
https://lavalink4net.angelobreuer.de/
MIT License
150 stars 27 forks source link

An additional client wrapper for DSharpPlus' DiscordShardedClient. #72

Closed waylaa closed 3 years ago

waylaa commented 3 years ago

I noticed i couldn't use DiscordClientWrapper together with DSharpPlus' DiscordShardedClient, so i tried to make my own wrapper using IDiscordClientWrapper but i ran into problems along the way. In the end, i couldn't make it work and for now i am stuck with using DSharpPlus' DiscordClient.

It would be a good idea to include a sharded client wrapper (For example: DiscordShardedClientWrapper) because it would allow people who use sharded clients for their bots to use this library.

angelobreuer commented 3 years ago

Hello @Whatareyoulaughingat, thank you for your issue request!

As far as I know, there was some time where the class did support sharded and non-sharded clients, but I think due to external changes this was probably dropped. I've made some changes to the DiscordClientWrapper class for DSharpPlus to support purely sharded clients.

Could you try out if the following works for you? You can just copy and paste it to a file in your workspace and use the implementation when you create the lavalink node/cluster.

It basically does the same as the old implementation but it redirects the request to the correct client. I did not test it, but it *should* work.

/*
 *  File:   DiscordShardedClientWrapper.cs
 *  Author: Angelo Breuer
 *
 *  The MIT License (MIT)
 *
 *  Copyright (c) Angelo Breuer 2021
 *
 *  Permission is hereby granted, free of charge, to any person obtaining a copy
 *  of this software and associated documentation files (the "Software"), to deal
 *  in the Software without restriction, including without limitation the rights
 *  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 *  copies of the Software, and to permit persons to whom the Software is
 *  furnished to do so, subject to the following conditions:
 *
 *  The above copyright notice and this permission notice shall be included in
 *  all copies or substantial portions of the Software.
 *
 *  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 *  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 *  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 *  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 *  THE SOFTWARE.
 */

namespace Lavalink4NET.DSharpPlus
{
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using global::DSharpPlus;
    using global::DSharpPlus.EventArgs;
    using Lavalink4NET.Events;
    using Newtonsoft.Json;
    using Newtonsoft.Json.Linq;

    /// <summary>
    ///     A wrapper for the discord sharded client from the "DSharpPlus" discord client library. (https://github.com/DSharpPlus/DSharpPlus)
    /// </summary>
    public sealed class DiscordShardedClientWrapper : IDiscordClientWrapper, IDisposable
    {
        private readonly DiscordShardedClient _client;
        private bool _disposed;

        /// <summary>
        ///     Initializes a new instance of the <see cref="DiscordClientWrapper"/> class.
        /// </summary>
        /// <param name="client">the sharded discord client</param>
        /// <exception cref="ArgumentNullException">
        ///     thrown if the specified <paramref name="client"/> is <see langword="null"/>.
        /// </exception>
        public DiscordShardedClientWrapper(DiscordShardedClient client)
        {
            _client = client ?? throw new ArgumentNullException(nameof(client));

            _client.VoiceStateUpdated += OnVoiceStateUpdated;
            _client.VoiceServerUpdated += OnVoiceServerUpdated;
        }

        /// <inheritdoc/>
        public event AsyncEventHandler<VoiceServer>? VoiceServerUpdated;

        /// <inheritdoc/>
        public event AsyncEventHandler<Events.VoiceStateUpdateEventArgs>? VoiceStateUpdated;

        /// <inheritdoc/>
        public ulong CurrentUserId
        {
            get
            {
                EnsureNotDisposed();
                return _client.CurrentUser.Id;
            }
        }

        /// <inheritdoc/>
        public int ShardCount
        {
            get
            {
                EnsureNotDisposed();
                return _client.ShardClients.Count;
            }
        }

        /// <inheritdoc/>
        public void Dispose()
        {
            if (_disposed)
            {
                return;
            }

            _disposed = true;

            _client.VoiceStateUpdated -= OnVoiceStateUpdated;
            _client.VoiceServerUpdated -= OnVoiceServerUpdated;
        }

        /// <inheritdoc/>
        /// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
        public async Task<IEnumerable<ulong>> GetChannelUsersAsync(ulong guildId, ulong voiceChannelId)
        {
            EnsureNotDisposed();

            var shard = _client.GetShard(guildId)
                ?? throw new InvalidOperationException("Shard is not served by this client.");

            var guild = await shard.GetGuildAsync(guildId).ConfigureAwait(false)
                ?? throw new ArgumentException("Invalid or inaccessible guild: " + guildId, nameof(guildId));

            var channel = guild.GetChannel(voiceChannelId)
                ?? throw new ArgumentException("Invalid or inaccessible voice channel: " + voiceChannelId, nameof(voiceChannelId));

            return channel.Users.Select(s => s.Id);
        }

        /// <inheritdoc/>
        /// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
        public async Task InitializeAsync()
        {
            EnsureNotDisposed();

            var startTime = DateTimeOffset.UtcNow;

            // await until current user arrived
            while (_client.CurrentUser is null)
            {
                await Task.Delay(10).ConfigureAwait(false);

                // timeout exceeded
                if (DateTimeOffset.UtcNow - startTime > TimeSpan.FromSeconds(10))
                {
                    throw new TimeoutException("Waited 10 seconds for current user to arrive! Make sure you start " +
                        "the discord client, before initializing the discord wrapper!");
                }
            }
        }

        /// <inheritdoc/>
        /// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
        public async Task SendVoiceUpdateAsync(ulong guildId, ulong? voiceChannelId, bool selfDeaf = false, bool selfMute = false)
        {
            EnsureNotDisposed();

            var payload = new JObject();
            var data = new VoiceStateUpdatePayload(guildId, voiceChannelId, selfMute, selfDeaf);

            payload.Add("op", 4);
            payload.Add("d", JObject.FromObject(data));

            var message = JsonConvert.SerializeObject(payload, Formatting.None);

            var shard = _client.GetShard(guildId)
                ?? throw new InvalidOperationException("Shard is not served by this client.");

            await shard.GetWebSocketClient().SendMessageAsync(message).ConfigureAwait(false);
        }

        /// <summary>
        ///     Throws an <see cref="ObjectDisposedException"/> if the <see
        ///     cref="DiscordClientWrapper"/> is disposed.
        /// </summary>
        /// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
        private void EnsureNotDisposed()
        {
            if (_disposed)
            {
                throw new ObjectDisposedException(nameof(DiscordClientWrapper));
            }
        }

        /// <inheritdoc/>
        /// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
        private Task OnVoiceServerUpdated(DiscordClient _, VoiceServerUpdateEventArgs voiceServer)
        {
            EnsureNotDisposed();

            var args = new VoiceServer(voiceServer.Guild.Id, voiceServer.GetVoiceToken(), voiceServer.Endpoint);
            return VoiceServerUpdated.InvokeAsync(this, args);
        }

        /// <inheritdoc/>
        /// <exception cref="ObjectDisposedException">thrown if the instance is disposed</exception>
        private Task OnVoiceStateUpdated(DiscordClient _, global::DSharpPlus.EventArgs.VoiceStateUpdateEventArgs eventArgs)
        {
            EnsureNotDisposed();

            // 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 = eventArgs.Before?.GetSessionId() ?? eventArgs.After.GetSessionId();

            // create voice state
            var voiceState = new VoiceState(
                voiceChannelId: eventArgs.After?.Channel?.Id,
                guildId: eventArgs.Guild.Id,
                voiceSessionId: sessionId);

            // invoke event
            return VoiceStateUpdated.InvokeAsync(this,
                new Events.VoiceStateUpdateEventArgs(eventArgs.User.Id, voiceState));
        }
    }
}

You can see the diff here: https://www.diffchecker.com/cNIDzkmK

waylaa commented 3 years ago

I tested it and it works pretty good. I made some changes such as adding GC.SuppressFinalize inside the Dispose method. Also, i made my own VoiceStateUpdatePayload class (which contains the same code as yours) because yours is sealed and internal and i couldn't access it so, it would be nice to have the VoiceStateUpdatePayload class public or at least access it one way or another.

Lastly, are there any plans to implement this wrapper in this library any time soon?

angelobreuer commented 3 years ago

Hello @Whatareyoulaughingat,

I tested it and it works pretty good.

That's nice to hear! Thanks for trying out.

I made some changes such as adding GC.SuppressFinalize inside the Dispose method.

I did not add it in the past, as the class does not have the need for a class finalizer, and it was made sealed to avoid inheritors that could implement a Dispose method. In the commit above, I added the GC.SuppressFinalize() call in the dispose logic of the DiscordClientWrapper implementations for DSharpPlus and unsealed them.

Also, i made my own VoiceStateUpdatePayload class (which contains the same code as yours) because yours is sealed and internal and i couldn't access it so, it would be nice to have the VoiceStateUpdatePayload class public or at least access it one way or another.

The VoiceStateUpdatePayload class is currently not public because it was considered for internal usage, and most users will not have any advantage from exposing the type. The class was intended to be used by the (old) DiscordClientWrapper class to construct the payload sent to the discord gateway to send the voice state update. Do you have a use case that would benefit from exposing the type?

Lastly, are there any plans to implement this wrapper in this library any time soon?

I implemented a shared version of the client wrapper using an abstract type, as both of the wrapper share very similar code. Could you take a look at https://github.com/angelobreuer/Lavalink4NET/compare/patch-72?

Thank you!

waylaa commented 3 years ago

I took a look at those changes and everything looked great. Since you implemented the sharded client wrapper, there is no need to make the VoiceStateUpdatePayload class public because there's no need to make my own client wrapper now.

One thing left is updating the nuget package i guess.

angelobreuer commented 3 years ago

Great! Thank you, I will push a release in a few minutes.