This commit is contained in:
Finite Reality
2016-08-03 15:34:53 +01:00
19 changed files with 295 additions and 61 deletions

File diff suppressed because one or more lines are too long

View File

@@ -60,3 +60,42 @@ In the constructor of your module, any parameters will be filled in by the @Disc
>If you accept `CommandService` or `IDependencyMap` as a parameter in your constructor, these parameters will be filled by the CommandService the module was loaded from, and the DependencyMap passed into it, respectively. >If you accept `CommandService` or `IDependencyMap` as a parameter in your constructor, these parameters will be filled by the CommandService the module was loaded from, and the DependencyMap passed into it, respectively.
[!code-csharp[DependencyMap in Modules](samples/dependency_module.cs)] [!code-csharp[DependencyMap in Modules](samples/dependency_module.cs)]
## Type Readers
Type Readers allow you to parse different types of arguments in your commands.
By default, the following Types are supported arguments:
- string
- sbyte/byte
- ushort/short
- uint/int
- ulong/long
- float, double, decimal
- DateTime/DateTimeOffset
- IUser/IGuildUser
- IChannel/IGuildChannel/ITextChannel/IVoiceChannel/IGroupChannel
- IRole
- IMessage
### Creating a Type Readers
To create a TypeReader, create a new class that imports @Discord and @Discord.Commands . Ensure your class inherits from @Discord.Commands.TypeReader
Next, satisfy the `TypeReader` class by overriding `Task<TypeReaderResult> Read(IMessage context, string input)`.
>[!NOTE]
>In many cases, Visual Stuido can fill this in for you, using the "Implement Abstract Class" IntelliSense hint.
Inside this task, add whatever logic you need to parse the input string.
Finally, return a `TypeReaderResult`. If you were able to successfully parse the input, return `TypeReaderResult.FromSuccess(parsedInput)`. Otherwise, return `TypeReaderResult.FromError`.
#### Sample
[!code-csharp[TypeReaders](samples/typereader.cs)]
### Installing TypeReaders
TypeReaders are not automatically discovered by the Command Service, and must be explicitly added. To install a TypeReader, invoke [CommandService.AddTypeReader](xref:Discord.Commands.CommandService#Discord_Commands_CommandService_AddTypeReader__1_Discord_Commands_TypeReader_).

28
docs/guides/events.md Normal file
View File

@@ -0,0 +1,28 @@
---
title: Events
---
# Events
Messages from Discord are exposed via events, and follow a pattern of `Func<[event params], Task>`, which allows you to easily create either async or sync event handlers.
To hook into events, you must be using the @Discord.WebSocket.DiscordSocketClient, which provides WebSocket capabilities, necessary for receiving events.
>[!NOTE]
>The gateway will wait for all registered handlers of an event to finish before raising the next event. As a result of this, it is reccomended that if you need to perform any heavy work in an event handler, it is done on its own thread or Task.
**For further documentation of all events**, it is reccomended to look at the [Events Section](xref:Discord.WebSocket.DiscordSocketClient#events) on the API documentation of @Discord.WebSocket.DiscordSocketClient
## Connection State
Connection Events will be raised when the Connection State of your client changes.
[DiscordSocketClient.Connected](xref:Discord.WebSocket.DiscordSocketClient#Discord_WebSocket_DiscordSocketClient_Connected) and [Disconnected](Discord_WebSocket_DiscordSocketClient_Disconnected) are raised when the Gateway Socket connects or disconnects, respectively.
>[!WARNING]
>You should not use DiscordClient.Connected to run code when your client first connects to Discord. The client has not received and parsed the READY event and guild stream yet, and will have an incomplete or empty cache.
[DiscordSocketClient.Ready](xref:Discord.WebSocket.DiscordSocketClient#Discord_WebSocket_DiscordSocketClient_Ready) is raised when the `READY` packet is parsed and received from Discord.
>[!NOTE]
>The [DiscordSocketClient.ConnectAsync](xref:Discord.WebSocket.DiscordSocketClient#Discord_WebSocket_DiscordSocketClient_ConnectAsync_System_Boolean_) method will not return until the READY packet has been processed. By default, it also will not return until the guild stream has finished. This means it is safe to run bot code directly after awaiting the ConnectAsync method.

View File

@@ -1,8 +1,8 @@
--- ---
title: Frequently Asked Questions title: Samples
--- ---
# Frequently Asked Questions # Samples
>[!NOTE] >[!NOTE]
>All of these samples assume you have `_client` defined as a `DiscordSocketClient`. >All of these samples assume you have `_client` defined as a `DiscordSocketClient`.

View File

@@ -0,0 +1,15 @@
// Create an IAudioClient, and store it for later use
private IAudioClient _audio;
// Create a Join command, that will join the parameter or the user's current voice channel
[Command("join")]
public async Task JoinChannel(IMessage msg,
IVoiceChannel channel = null)
{
// Get the audio channel
channel = channel ?? (msg.Author as IGuildUser)?.VoiceChannel;
if (channel == null) { await msg.Channel.SendMessageAsync("User must be in a voice channel, or a voice channel must be passed as an argument."); return; }
// Get the IAudioClient by calling the JoinAsync method
_audio = await channel.JoinAsync();
}

View File

@@ -0,0 +1,14 @@
using Discord;
using Discord.Commands;
public class BooleanTypeReader : TypeReader
{
public override Task<TypeReaderResult> Read(IMessage context, string input)
{
bool result;
if (bool.TryParse(input, out result))
return Task.FromResult(TypeReaderResult.FromSuccess(result));
return Task.FromResult(TypeReaderResult.FromError(CommandError.ParseFailed, "Input could not be parsed as a boolean."))
}
}

View File

@@ -7,5 +7,9 @@
href: logging.md href: logging.md
- name: Commands - name: Commands
href: commands.md href: commands.md
- name: FAQ - name: Voice
href: faq.md href: voice.md
- name: Events
href: events.md
- name: Code Samples
href: samples.md

28
docs/guides/voice.md Normal file
View File

@@ -0,0 +1,28 @@
# Voice
**Information on this page is subject to change!**
>[!WARNING]
>Audio in 1.0 is incomplete. Most of the below documentation is untested.
## Installation
To use Audio, you must first configure your `DiscordSocketClient` with Audio support.
In your @Discord.DiscordSocketConfig, set `AudioMode` to the appropriate @Discord.Audio.AudioMode for your bot. For most bots, you will only need to use `AudioMode.Outgoing`.
### Dependencies
Audio requires two native libraries, `libsodium` and `opus`. Both of these libraries must be placed in the runtime directory of your bot (for .NET 4.6, the directory where your exe is located; for .NET core, directory where your project.json is located)
For Windows Users, precompiled binaries are available for your convienence [here](https://discord.foxbot.me/binaries/)
For Linux Users, you will need to compile from source. [Sodium Source Code](https://download.libsodium.org/libsodium/releases/), [Opus Source Code](http://downloads.xiph.org/releases/opus/).
## Joining a Channel
Joining Voice Channels is relatively straight-forward, and is a requirement for sending or receiving audio. This will also allow us to create an @Discord.Audio.IAudioClient, which will be used later to send or receive audio.
[!code-csharp[Joining a Channel](samples/joining_audio.cs)]
The client will sustain a connection to this channel until it is kicked, disconnected from Discord, or told to disconnect.

View File

@@ -357,6 +357,12 @@ namespace Discord.API
await SendAsync("DELETE", $"channels/{channelId}/pins/{messageId}", options: options).ConfigureAwait(false); await SendAsync("DELETE", $"channels/{channelId}/pins/{messageId}", options: options).ConfigureAwait(false);
} }
public async Task<IReadOnlyCollection<Message>> GetPinsAsync(ulong channelId, RequestOptions options = null)
{
Preconditions.NotEqual(channelId, 0, nameof(channelId));
return await SendAsync<IReadOnlyCollection<Message>>("GET", $"channels/{channelId}/pins", options: options).ConfigureAwait(false);
}
//Channel Recipients //Channel Recipients
public async Task AddGroupRecipientAsync(ulong channelId, ulong userId, RequestOptions options = null) public async Task AddGroupRecipientAsync(ulong channelId, ulong userId, RequestOptions options = null)
@@ -810,7 +816,7 @@ namespace Discord.API
{ {
return CreateMessageInternalAsync(0, channelId, args); return CreateMessageInternalAsync(0, channelId, args);
} }
public async Task<Message> CreateMessageInternalAsync(ulong guildId, ulong channelId, CreateMessageParams args, RequestOptions options = null) private async Task<Message> CreateMessageInternalAsync(ulong guildId, ulong channelId, CreateMessageParams args, RequestOptions options = null)
{ {
Preconditions.NotEqual(channelId, 0, nameof(channelId)); Preconditions.NotEqual(channelId, 0, nameof(channelId));
Preconditions.NotNull(args, nameof(args)); Preconditions.NotNull(args, nameof(args));
@@ -1010,7 +1016,7 @@ namespace Discord.API
public async Task ModifyMyNickAsync(ulong guildId, ModifyCurrentUserNickParams args, RequestOptions options = null) public async Task ModifyMyNickAsync(ulong guildId, ModifyCurrentUserNickParams args, RequestOptions options = null)
{ {
Preconditions.NotNull(args, nameof(args)); Preconditions.NotNull(args, nameof(args));
Preconditions.NotEmpty(args.Nickname, nameof(args.Nickname)); Preconditions.NotNull(args.Nickname, nameof(args.Nickname));
await SendAsync("PATCH", $"guilds/{guildId}/members/@me/nick", args, options: options).ConfigureAwait(false); await SendAsync("PATCH", $"guilds/{guildId}/members/@me/nick", args, options: options).ConfigureAwait(false);
} }

View File

@@ -23,6 +23,8 @@ namespace Discord
Task<IReadOnlyCollection<IMessage>> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch); Task<IReadOnlyCollection<IMessage>> GetMessagesAsync(int limit = DiscordConfig.MaxMessagesPerBatch);
/// <summary> Gets a collection of messages in this channel. </summary> /// <summary> Gets a collection of messages in this channel. </summary>
Task<IReadOnlyCollection<IMessage>> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch); Task<IReadOnlyCollection<IMessage>> GetMessagesAsync(ulong fromMessageId, Direction dir, int limit = DiscordConfig.MaxMessagesPerBatch);
/// <summary> Gets a collection of pinned messages in this channel. </summary>
Task<IReadOnlyCollection<IMessage>> GetPinnedMessagesAsync();
/// <summary> Bulk deletes multiple messages. </summary> /// <summary> Bulk deletes multiple messages. </summary>
Task DeleteMessagesAsync(IEnumerable<IMessage> messages); Task DeleteMessagesAsync(IEnumerable<IMessage> messages);

View File

@@ -108,6 +108,11 @@ namespace Discord
{ {
await Discord.ApiClient.DeleteDMMessagesAsync(Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); await Discord.ApiClient.DeleteDMMessagesAsync(Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false);
} }
public async Task<IReadOnlyCollection<IMessage>> GetPinnedMessagesAsync()
{
var models = await Discord.ApiClient.GetPinsAsync(Id);
return models.Select(x => new Message(this, new User(x.Author.Value), x)).ToImmutableArray();
}
public async Task TriggerTypingAsync() public async Task TriggerTypingAsync()
{ {

View File

@@ -133,6 +133,11 @@ namespace Discord
{ {
await Discord.ApiClient.DeleteDMMessagesAsync(Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); await Discord.ApiClient.DeleteDMMessagesAsync(Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false);
} }
public async Task<IReadOnlyCollection<IMessage>> GetPinnedMessagesAsync()
{
var models = await Discord.ApiClient.GetPinsAsync(Id);
return models.Select(x => new Message(this, new User(x.Author.Value), x)).ToImmutableArray();
}
public async Task TriggerTypingAsync() public async Task TriggerTypingAsync()
{ {

View File

@@ -102,6 +102,11 @@ namespace Discord
{ {
await Discord.ApiClient.DeleteMessagesAsync(Guild.Id, Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false); await Discord.ApiClient.DeleteMessagesAsync(Guild.Id, Id, new DeleteMessagesParams { MessageIds = messages.Select(x => x.Id) }).ConfigureAwait(false);
} }
public async Task<IReadOnlyCollection<IMessage>> GetPinnedMessagesAsync()
{
var models = await Discord.ApiClient.GetPinsAsync(Id);
return models.Select(x => new Message(this, new User(x.Author.Value), x)).ToImmutableArray();
}
public async Task TriggerTypingAsync() public async Task TriggerTypingAsync()
{ {

View File

@@ -115,6 +115,7 @@ namespace Discord.Rpc
/// <inheritdoc /> /// <inheritdoc />
public async Task DisconnectAsync() public async Task DisconnectAsync()
{ {
if (_connectTask?.TrySetCanceled() ?? false) return;
await _connectionLock.WaitAsync().ConfigureAwait(false); await _connectionLock.WaitAsync().ConfigureAwait(false);
try try
{ {
@@ -122,16 +123,6 @@ namespace Discord.Rpc
} }
finally { _connectionLock.Release(); } finally { _connectionLock.Release(); }
} }
private async Task DisconnectAsync(Exception ex, bool isReconnecting)
{
if (_connectTask?.TrySetException(ex) ?? false) return;
await _connectionLock.WaitAsync().ConfigureAwait(false);
try
{
await DisconnectInternalAsync(ex, isReconnecting).ConfigureAwait(false);
}
finally { _connectionLock.Release(); }
}
private async Task DisconnectInternalAsync(Exception ex, bool isReconnecting) private async Task DisconnectInternalAsync(Exception ex, bool isReconnecting)
{ {
if (!isReconnecting) if (!isReconnecting)
@@ -173,7 +164,14 @@ namespace Discord.Rpc
} }
private async Task ReconnectInternalAsync(Exception ex, CancellationToken cancelToken) private async Task ReconnectInternalAsync(Exception ex, CancellationToken cancelToken)
{ {
await DisconnectAsync(null, true).ConfigureAwait(false); if (ex == null)
{
if (_connectTask?.TrySetCanceled() ?? false) return;
}
else
{
if (_connectTask?.TrySetException(ex) ?? false) return;
}
try try
{ {

View File

@@ -191,16 +191,6 @@ namespace Discord.WebSocket
} }
finally { _connectionLock.Release(); } finally { _connectionLock.Release(); }
} }
private async Task DisconnectAsync(Exception ex, bool isReconnecting)
{
if (_connectTask?.TrySetException(ex) ?? false) return;
await _connectionLock.WaitAsync().ConfigureAwait(false);
try
{
await DisconnectInternalAsync(ex, isReconnecting).ConfigureAwait(false);
}
finally { _connectionLock.Release(); }
}
private async Task DisconnectInternalAsync(Exception ex, bool isReconnecting) private async Task DisconnectInternalAsync(Exception ex, bool isReconnecting)
{ {
if (!isReconnecting) if (!isReconnecting)
@@ -270,7 +260,14 @@ namespace Discord.WebSocket
} }
private async Task ReconnectInternalAsync(Exception ex, CancellationToken cancelToken) private async Task ReconnectInternalAsync(Exception ex, CancellationToken cancelToken)
{ {
await DisconnectAsync(null, true).ConfigureAwait(false); if (ex == null)
{
if (_connectTask?.TrySetCanceled() ?? false) return;
}
else
{
if (_connectTask?.TrySetException(ex) ?? false) return;
}
try try
{ {
@@ -580,7 +577,7 @@ namespace Discord.WebSocket
} }
catch (Exception ex) catch (Exception ex)
{ {
await DisconnectAsync(new Exception("Processing READY failed", ex), false); _connectTask.TrySetException(new Exception("Processing READY failed", ex));
return; return;
} }
@@ -1402,12 +1399,17 @@ namespace Discord.WebSocket
{ {
before = guild.GetVoiceState(data.UserId)?.Clone() ?? new VoiceState(null, null, false, false, false); before = guild.GetVoiceState(data.UserId)?.Clone() ?? new VoiceState(null, null, false, false, false);
after = guild.AddOrUpdateVoiceState(data, DataStore); after = guild.AddOrUpdateVoiceState(data, DataStore);
if (data.UserId == _currentUser.Id)
{
var _ = guild.FinishJoinAudioChannel().ConfigureAwait(false);
}
} }
else else
{ {
before = guild.RemoveVoiceState(data.UserId) ?? new VoiceState(null, null, false, false, false); before = guild.RemoveVoiceState(data.UserId) ?? new VoiceState(null, null, false, false, false);
after = new VoiceState(null, data); after = new VoiceState(null, data);
} }
user = guild.GetUser(data.UserId); user = guild.GetUser(data.UserId);
} }
else else
@@ -1460,7 +1462,7 @@ namespace Discord.WebSocket
if (guild != null) if (guild != null)
{ {
string endpoint = data.Endpoint.Substring(0, data.Endpoint.LastIndexOf(':')); string endpoint = data.Endpoint.Substring(0, data.Endpoint.LastIndexOf(':'));
var _ = guild.ConnectAudio(_nextAudioId++, endpoint, data.Token).ConfigureAwait(false); var _ = guild.FinishConnectAudio(_nextAudioId++, endpoint, data.Token).ConfigureAwait(false);
} }
else else
{ {

View File

@@ -42,11 +42,9 @@ namespace Discord
if (audioMode == AudioMode.Disabled) if (audioMode == AudioMode.Disabled)
throw new InvalidOperationException($"Audio is not enabled on this client, {nameof(DiscordSocketConfig.AudioMode)} in {nameof(DiscordSocketConfig)} must be set."); throw new InvalidOperationException($"Audio is not enabled on this client, {nameof(DiscordSocketConfig.AudioMode)} in {nameof(DiscordSocketConfig)} must be set.");
await Discord.ApiClient.SendVoiceStateUpdateAsync(Guild.Id, Id, return await Guild.ConnectAudioAsync(Id,
(audioMode & AudioMode.Incoming) == 0, (audioMode & AudioMode.Incoming) == 0,
(audioMode & AudioMode.Outgoing) == 0).ConfigureAwait(false); (audioMode & AudioMode.Outgoing) == 0).ConfigureAwait(false);
return null;
//TODO: Block and return
} }
public SocketVoiceChannel Clone() => MemberwiseClone() as SocketVoiceChannel; public SocketVoiceChannel Clone() => MemberwiseClone() as SocketVoiceChannel;

View File

@@ -25,6 +25,7 @@ namespace Discord
private readonly SemaphoreSlim _audioLock; private readonly SemaphoreSlim _audioLock;
private TaskCompletionSource<bool> _syncPromise, _downloaderPromise; private TaskCompletionSource<bool> _syncPromise, _downloaderPromise;
private TaskCompletionSource<AudioClient> _audioConnectPromise;
private ConcurrentHashSet<ulong> _channels; private ConcurrentHashSet<ulong> _channels;
private ConcurrentDictionary<ulong, SocketGuildUser> _members; private ConcurrentDictionary<ulong, SocketGuildUser> _members;
private ConcurrentDictionary<ulong, VoiceState> _voiceStates; private ConcurrentDictionary<ulong, VoiceState> _voiceStates;
@@ -260,38 +261,99 @@ namespace Discord
return null; return null;
} }
public async Task ConnectAudio(int id, string url, string token) public async Task<IAudioClient> ConnectAudioAsync(ulong channelId, bool selfDeaf, bool selfMute)
{ {
AudioClient audioClient;
await _audioLock.WaitAsync().ConfigureAwait(false);
var voiceState = GetVoiceState(CurrentUser.Id).Value;
try try
{ {
audioClient = AudioClient; TaskCompletionSource<AudioClient> promise;
if (audioClient == null)
await _audioLock.WaitAsync().ConfigureAwait(false);
try
{ {
audioClient = new AudioClient(this, id); await DisconnectAudioInternalAsync().ConfigureAwait(false);
promise = new TaskCompletionSource<AudioClient>();
_audioConnectPromise = promise;
await Discord.ApiClient.SendVoiceStateUpdateAsync(Id, channelId, selfDeaf, selfMute).ConfigureAwait(false);
}
finally
{
_audioLock.Release();
}
var timeoutTask = Task.Delay(15000);
if (await Task.WhenAny(promise.Task, timeoutTask) == timeoutTask)
throw new TimeoutException();
return await promise.Task.ConfigureAwait(false);
}
catch (Exception)
{
await DisconnectAudioInternalAsync().ConfigureAwait(false);
throw;
}
}
public async Task DisconnectAudioAsync(AudioClient client = null)
{
await _audioLock.WaitAsync().ConfigureAwait(false);
try
{
await DisconnectAudioInternalAsync(client).ConfigureAwait(false);
}
finally
{
_audioLock.Release();
}
}
private async Task DisconnectAudioInternalAsync(AudioClient client = null)
{
var oldClient = AudioClient;
if (oldClient != null)
{
if (client == null || oldClient == client)
{
_audioConnectPromise?.TrySetCanceledAsync(); //Cancel any previous audio connection
_audioConnectPromise = null;
}
if (oldClient == client)
{
AudioClient = null;
await oldClient.DisconnectAsync().ConfigureAwait(false);
}
}
}
public async Task FinishConnectAudio(int id, string url, string token)
{
var voiceState = GetVoiceState(CurrentUser.Id).Value;
await _audioLock.WaitAsync().ConfigureAwait(false);
try
{
if (AudioClient == null)
{
var audioClient = new AudioClient(this, id);
audioClient.Disconnected += async ex => audioClient.Disconnected += async ex =>
{ {
await _audioLock.WaitAsync().ConfigureAwait(false); await _audioLock.WaitAsync().ConfigureAwait(false);
try try
{ {
if (ex != null) if (AudioClient == audioClient) //Only reconnect if we're still assigned as this guild's audio client
{ {
//Reconnect if we still have channel info. if (ex != null)
//TODO: Is this threadsafe? Could channel data be deleted before we access it?
var voiceState2 = GetVoiceState(CurrentUser.Id);
if (voiceState2.HasValue)
{ {
var voiceChannelId = voiceState2.Value.VoiceChannel?.Id; //Reconnect if we still have channel info.
if (voiceChannelId != null) //TODO: Is this threadsafe? Could channel data be deleted before we access it?
await Discord.ApiClient.SendVoiceStateUpdateAsync(Id, voiceChannelId, voiceState2.Value.IsSelfDeafened, voiceState2.Value.IsSelfMuted); var voiceState2 = GetVoiceState(CurrentUser.Id);
if (voiceState2.HasValue)
{
var voiceChannelId = voiceState2.Value.VoiceChannel?.Id;
if (voiceChannelId != null)
await Discord.ApiClient.SendVoiceStateUpdateAsync(Id, voiceChannelId, voiceState2.Value.IsSelfDeafened, voiceState2.Value.IsSelfMuted);
}
}
else
{
try { AudioClient.Dispose(); } catch { }
AudioClient = null;
} }
}
else
{
try { AudioClient.Dispose(); } catch { }
AudioClient = null;
} }
} }
finally finally
@@ -301,12 +363,35 @@ namespace Discord
}; };
AudioClient = audioClient; AudioClient = audioClient;
} }
await AudioClient.ConnectAsync(url, CurrentUser.Id, voiceState.VoiceSessionId, token).ConfigureAwait(false);
await _audioConnectPromise.TrySetResultAsync(AudioClient).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
await DisconnectAudioAsync();
}
catch (Exception e)
{
await _audioConnectPromise.SetExceptionAsync(e).ConfigureAwait(false);
await DisconnectAudioAsync();
}
finally
{
_audioLock.Release();
}
}
public async Task FinishJoinAudioChannel()
{
await _audioLock.WaitAsync().ConfigureAwait(false);
try
{
if (AudioClient != null)
await _audioConnectPromise.TrySetResultAsync(AudioClient).ConfigureAwait(false);
} }
finally finally
{ {
_audioLock.Release(); _audioLock.Release();
} }
await audioClient.ConnectAsync(url, CurrentUser.Id, voiceState.VoiceSessionId, token).ConfigureAwait(false);
} }
public SocketGuild Clone() => MemberwiseClone() as SocketGuild; public SocketGuild Clone() => MemberwiseClone() as SocketGuild;

View File

@@ -1,7 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace Discord.WebSocket.Extensions namespace Discord.WebSocket
{ {
public static class ChannelExtensions public static class ChannelExtensions
{ {

View File

@@ -2,7 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace Discord.WebSocket.Extensions namespace Discord.WebSocket
{ {
// Todo: Docstrings // Todo: Docstrings
public static class GuildExtensions public static class GuildExtensions