diff --git a/.gitignore b/.gitignore index d72e0b5eac..a4e47d2647 100644 --- a/.gitignore +++ b/.gitignore @@ -206,4 +206,6 @@ docs/api/\.manifest \.idea/ # Codealike UID -codealike.json \ No newline at end of file +codealike.json + +*.ignore diff --git a/Discord.Net.sln b/Discord.Net.sln new file mode 100644 index 0000000000..ea814bbc97 --- /dev/null +++ b/Discord.Net.sln @@ -0,0 +1,73 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26124.0 +MinimumVisualStudioVersion = 15.0.26124.0 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5DAC796B-0B77-4F84-B790-83DB78C6DFFE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Net", "src\Discord.Net\Discord.Net.csproj", "{3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{4795640A-030C-4A9A-A9B0-20C56AF4DA3F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "idn", "sample\idn\idn.csproj", "{5BE5DE89-53B7-4243-AEA8-FD8A6420908A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{68EE1EAC-F487-4BAC-917B-233370B3AEA1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Discord.Tests.Unit", "test\Discord.Tests.Unit\Discord.Tests.Unit.csproj", "{6AD4FF67-D45E-4E7E-8853-990390D35C9F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Debug|x64.ActiveCfg = Debug|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Debug|x64.Build.0 = Debug|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Debug|x86.ActiveCfg = Debug|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Debug|x86.Build.0 = Debug|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Release|Any CPU.Build.0 = Release|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Release|x64.ActiveCfg = Release|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Release|x64.Build.0 = Release|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Release|x86.ActiveCfg = Release|Any CPU + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370}.Release|x86.Build.0 = Release|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Debug|x64.ActiveCfg = Debug|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Debug|x64.Build.0 = Debug|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Debug|x86.ActiveCfg = Debug|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Debug|x86.Build.0 = Debug|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|Any CPU.Build.0 = Release|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|x64.ActiveCfg = Release|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|x64.Build.0 = Release|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|x86.ActiveCfg = Release|Any CPU + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A}.Release|x86.Build.0 = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x64.ActiveCfg = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x64.Build.0 = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x86.ActiveCfg = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Debug|x86.Build.0 = Debug|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|Any CPU.Build.0 = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x64.ActiveCfg = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x64.Build.0 = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x86.ActiveCfg = Release|Any CPU + {6AD4FF67-D45E-4E7E-8853-990390D35C9F}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {3194F5DC-C0AF-4459-AAA3-91CB8FB8C370} = {5DAC796B-0B77-4F84-B790-83DB78C6DFFE} + {5BE5DE89-53B7-4243-AEA8-FD8A6420908A} = {4795640A-030C-4A9A-A9B0-20C56AF4DA3F} + {6AD4FF67-D45E-4E7E-8853-990390D35C9F} = {68EE1EAC-F487-4BAC-917B-233370B3AEA1} + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE index 3f78126e54..e8427ec2c0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015-2017 Discord.Net Contributors +Copyright (c) 2015-2020 Discord.Net Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 7dc8cd7886..32ce5107f3 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,3 @@ -# Discord.Net -[![NuGet](https://img.shields.io/nuget/vpre/Discord.Net.svg?maxAge=2592000?style=plastic)](https://www.nuget.org/packages/Discord.Net) -[![MyGet](https://img.shields.io/myget/discord-net/vpre/Discord.Net.svg)](https://www.myget.org/feed/Packages/discord-net) -[![Build status](https://ci.appveyor.com/api/projects/status/5sb7n8a09w9clute/branch/dev?svg=true)](https://ci.appveyor.com/project/RogueException/discord-net/branch/dev) -[![Discord](https://discordapp.com/api/guilds/81384788765712384/widget.png)](https://discord.gg/jkrBmQR) +# Discord.Net 2020 -An unofficial .NET API Wrapper for the Discord client (http://discordapp.com). - -Check out the [documentation](https://discord.foxbot.me/docs/) or join the [Discord API Chat](https://discord.gg/jkrBmQR). - -## Installation -### Stable (NuGet) -Our stable builds available from NuGet through the Discord.Net metapackage: -- [Discord.Net](https://www.nuget.org/packages/Discord.Net/) - -The individual components may also be installed from NuGet: -- [Discord.Net.Commands](https://www.nuget.org/packages/Discord.Net.Commands/) -- [Discord.Net.Rest](https://www.nuget.org/packages/Discord.Net.Rest/) -- [Discord.Net.WebSocket](https://www.nuget.org/packages/Discord.Net.WebSocket/) -- [Discord.Net.Webhook](https://www.nuget.org/packages/Discord.Net.Webhook/) - -### Unstable (MyGet) -Nightly builds are available through our MyGet feed (`https://www.myget.org/F/discord-net/api/v3/index.json`). - -## Compiling -In order to compile Discord.Net, you require the following: - -### Using Visual Studio -- [Visual Studio 2017](https://www.microsoft.com/net/core#windowsvs2017) -- [.NET Core SDK](https://www.microsoft.com/net/download/core) - -The .NET Core workload must be selected during Visual Studio installation. - -### Using Command Line -- [.NET Core SDK](https://www.microsoft.com/net/download/core) - -## Known Issues - -### WebSockets (Win7 and earlier) -.NET Core 1.1 does not support WebSockets on Win7 and earlier. This issue has been fixed since the release of .NET Core 2.1. It is recommended to target .NET Core 2.1 or above for your project if you wish to run your bot on legacy platforms; alternatively, you may choose to install the [Discord.Net.Providers.WS4Net](https://www.nuget.org/packages/Discord.Net.Providers.WS4Net/) package. +Rewrite branch, work in progress. diff --git a/TODO b/TODO new file mode 100644 index 0000000000..06301253f0 --- /dev/null +++ b/TODO @@ -0,0 +1,37 @@ +- REST + - Models + - Preconditions + - Endpoints + - Channel + - Emoji + - Guild + - Invite + - User + - Voice + - Webhook + - Ratelimiter with refit +- Gateway + - Models + - Client + - Socket + - use token + * Receive + * Compression + - Voice (long) +- Core + - CDN + - Datastore + - Entities + - Channel + - Emoji + - Guild + - User + - Utilities + - Token Validation (port from @ChrisJ) +- Tests + - Unit test Gateway stability / deadlockability? + - Port ChrisJ's token validator tests +- Extensions + - Commands + ? design - use finite's or quahu's + - Interactivity diff --git a/doc/.gitkeep b/doc/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ep.txt b/ep.txt new file mode 100644 index 0000000000..aad3896888 --- /dev/null +++ b/ep.txt @@ -0,0 +1,106 @@ +https://gist.github.com/SinisterRectus/9518f3e7d0d1ccb4335b2a0d389c30b0 + +Sorted By Route +-------------------------------------------------------------------------------------------------------------------- +Get Entitlements GET /applications/{application.id}/entitlements +Get Entitlement GET /applications/{application.id}/entitlements/{entitlement.id} +Delete Test Entitlement DELETE /applications/{application.id}/entitlements/{entitlement.id}/ +Consume SKU POST /applications/{application.id}/entitlements/{entitlement.id}/consume +Get SKUs GET /applications/{application.id}/skus +Delete/Close Channel DELETE /channels/{channel.id} +Get Channel GET /channels/{channel.id} +Modify Channel PUT/PATCH /channels/{channel.id} +Get Channel Invites GET /channels/{channel.id}/invites +Create Channel Invite POST /channels/{channel.id}/invites +Get Channel Messages GET /channels/{channel.id}/messages +Create Message POST /channels/{channel.id}/messages +Bulk Delete Messages POST /channels/{channel.id}/messages/bulk-delete +Bulk Delete Messages (deprecated) POST /channels/{channel.id}/messages/bulk_delete +Delete Message DELETE /channels/{channel.id}/messages/{message.id} +Get Channel Message GET /channels/{channel.id}/messages/{message.id} +Edit Message PATCH /channels/{channel.id}/messages/{message.id} +Delete All Reactions DELETE /channels/{channel.id}/messages/{message.id}/reactions +Get Reactions GET /channels/{channel.id}/messages/{message.id}/reactions/{emoji} +Delete Own Reaction DELETE /channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me +Create Reaction PUT /channels/{channel.id}/messages/{message.id}/reactions/{emoji}/@me +Delete User Reaction DELETE /channels/{channel.id}/messages/{message.id}/reactions/{emoji}/{user.id} +Delete Channel Permission DELETE /channels/{channel.id}/permissions/{overwrite.id} +Edit Channel Permissions PUT /channels/{channel.id}/permissions/{overwrite.id} +Get Pinned Messages GET /channels/{channel.id}/pins +Delete Pinned Channel Message DELETE /channels/{channel.id}/pins/{message.id} +Add Pinned Channel Message PUT /channels/{channel.id}/pins/{message.id} +Group DM Remove Recipient DELETE /channels/{channel.id}/recipients/{user.id} +Group DM Add Recipient PUT /channels/{channel.id}/recipients/{user.id} +Trigger Typing Indicator POST /channels/{channel.id}/typing +Get Channel Webhooks GET /channels/{channel.id}/webhooks +Create Webhook POST /channels/{channel.id}/webhooks +Get Gateway GET /gateway +Get Gateway Bot GET /gateway/bot +Create Guild POST /guilds +Delete Guild DELETE /guilds/{guild.id} +Get Guild GET /guilds/{guild.id} +Modify Guild PATCH /guilds/{guild.id} +Get Guild Audit Log GET /guilds/{guild.id}/audit-logs +Get Guild Bans GET /guilds/{guild.id}/bans +Remove Guild Ban DELETE /guilds/{guild.id}/bans/{user.id} +Get Guild Ban GET /guilds/{guild.id}/bans/{user.id} +Create Guild Ban PUT /guilds/{guild.id}/bans/{user.id} +Get Guild Channels GET /guilds/{guild.id}/channels +Modify Guild Channel Positions PATCH /guilds/{guild.id}/channels +Create Guild Channel POST /guilds/{guild.id}/channels +Get Guild Embed GET /guilds/{guild.id}/embed +Modify Guild Embed PATCH /guilds/{guild.id}/embed +List Guild Emojis GET /guilds/{guild.id}/emojis +Create Guild Emoji POST /guilds/{guild.id}/emojis +Delete Guild Emoji DELETE /guilds/{guild.id}/emojis/{emoji.id} +Get Guild Emoji GET /guilds/{guild.id}/emojis/{emoji.id} +Modify Guild Emoji PATCH /guilds/{guild.id}/emojis/{emoji.id} +Get Guild Integrations GET /guilds/{guild.id}/integrations +Create Guild Integration POST /guilds/{guild.id}/integrations +Delete Guild Integration DELETE /guilds/{guild.id}/integrations/{integration.id} +Modify Guild Integration PATCH /guilds/{guild.id}/integrations/{integration.id} +Sync Guild Integration POST /guilds/{guild.id}/integrations/{integration.id}/sync +Get Guild Invites GET /guilds/{guild.id}/invites +List Guild Members GET /guilds/{guild.id}/members +Modify Current User Nick PATCH /guilds/{guild.id}/members/@me/nick +Remove Guild Member DELETE /guilds/{guild.id}/members/{user.id} +Get Guild Member GET /guilds/{guild.id}/members/{user.id} +Modify Guild Member PATCH /guilds/{guild.id}/members/{user.id} +Add Guild Member PUT /guilds/{guild.id}/members/{user.id} +Remove Guild Member Role DELETE /guilds/{guild.id}/members/{user.id}/roles/{role.id} +Add Guild Member Role PUT /guilds/{guild.id}/members/{user.id}/roles/{role.id} +Get Guild Prune Count GET /guilds/{guild.id}/prune +Begin Guild Prune POST /guilds/{guild.id}/prune +Get Guild Voice Regions GET /guilds/{guild.id}/regions +Get Guild Roles GET /guilds/{guild.id}/roles +Modify Guild Role Positions PATCH /guilds/{guild.id}/roles +Create Guild Role POST /guilds/{guild.id}/roles +Delete Guild Role DELETE /guilds/{guild.id}/roles/{role.id} +Modify Guild Role PATCH /guilds/{guild.id}/roles/{role.id} +Get Guild Vanity URL GET /guilds/{guild.id}/vanity-url +Get Guild Webhooks GET /guilds/{guild.id}/webhooks +Get Guild Widget Image GET /guilds/{guild.id}/widget.png +Delete Invite DELETE /invites/{invite.code} +Get Invite GET /invites/{invite.code} +Get Current Application Information GET /oauth2/applications/@me +Delete Purchase Discount DELETE /store/skus/{sku.id}/discounts/{user.id}/ +Create Purchase Discount PUT /store/skus/{sku.id}/discounts/{user.id}/ +Get Current User GET /users/@me +Modify Current User PATCH /users/@me +Get User DMs GET /users/@me/channels +Create DM POST /users/@me/channels +Create Group DM POST /users/@me/channels +Get User Connections GET /users/@me/connections +Get Current User Guilds GET /users/@me/guilds +Leave Guild DELETE /users/@me/guilds/{guild.id} +Get User GET /users/{user.id} +List Voice Regions GET /voice/regions +Delete Webhook DELETE /webhooks/{webhook.id} +Get Webhook GET /webhooks/{webhook.id} +Modify Webhook PATCH /webhooks/{webhook.id} +Delete Webhook with Token DELETE /webhooks/{webhook.id}/{webhook.token} +Get Webhook with Token GET /webhooks/{webhook.id}/{webhook.token} +Modify Webhook with Token PATCH /webhooks/{webhook.id}/{webhook.token} +Execute Webhook POST /webhooks/{webhook.id}/{webhook.token} +Execute GitHub-Compatible Webhook POST /webhooks/{webhook.id}/{webhook.token}/github +Execute Slack-Compatible Webhook POST /webhooks/{webhook.id}/{webhook.token}/slack \ No newline at end of file diff --git a/sample/.gitkeep b/sample/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sample/idn/Inspector.cs b/sample/idn/Inspector.cs new file mode 100644 index 0000000000..3806e0e797 --- /dev/null +++ b/sample/idn/Inspector.cs @@ -0,0 +1,74 @@ +using System.Collections; +using System.Linq; +using System.Reflection; +using System.Text; + +namespace idn +{ + public static class Inspector + { + public static string Inspect(object value) + { + var builder = new StringBuilder(); + if (value != null) + { + var type = value.GetType().GetTypeInfo(); + builder.AppendLine($"[{type.Namespace}.{type.Name}]"); + builder.AppendLine($"{InspectProperty(value)}"); + + if (value is IEnumerable) + { + var items = (value as IEnumerable).Cast().ToArray(); + if (items.Length > 0) + { + builder.AppendLine(); + foreach (var item in items) + builder.AppendLine($"- {InspectProperty(item)}"); + } + } + else + { + var groups = type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(x => x.GetIndexParameters().Length == 0) + .GroupBy(x => x.Name) + .OrderBy(x => x.Key) + .ToArray(); + if (groups.Length > 0) + { + builder.AppendLine(); + int pad = groups.Max(x => x.Key.Length) + 1; + foreach (var group in groups) + builder.AppendLine($"{group.Key.PadRight(pad, ' ')}{InspectProperty(group.First().GetValue(value))}"); + } + } + } + else + builder.AppendLine("null"); + return builder.ToString(); + } + + private static string InspectProperty(object obj) + { + if (obj == null) + return "null"; + + var type = obj.GetType(); + + var debuggerDisplay = type.GetProperty("DebuggerDisplay", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (debuggerDisplay != null) + return debuggerDisplay.GetValue(obj).ToString(); + + var toString = type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(x => x.Name == "ToString" && x.DeclaringType != typeof(object)) + .FirstOrDefault(); + if (toString != null) + return obj.ToString(); + + var count = type.GetProperty("Count", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (count != null) + return $"[{count.GetValue(obj)} Items]"; + + return obj.ToString(); + } + } +} diff --git a/sample/idn/Program.cs b/sample/idn/Program.cs new file mode 100644 index 0000000000..4c1c2411d9 --- /dev/null +++ b/sample/idn/Program.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using Discord; + +namespace idn +{ + public class Program + { + public static readonly string[] Imports = + { + "System", + "System.Collections.Generic", + "System.Linq", + "System.Threading.Tasks", + "System.Diagnostics", + "System.IO", + "Discord", + "Discord.Rest", + "Discord.Socket", + "idn" + }; + + static async Task Main(string[] args) + { + var token = File.ReadAllText("token.ignore"); + var client = IDiscordClient.Create(token); + // client.start + + var options = ScriptOptions.Default + .AddReferences(GetAssemblies().ToArray()) + .AddImports(Imports); + + var globals = new ScriptGlobals + { + Client = client, + }; + + while (true) + { + Console.Write("> "); + string input = Console.ReadLine(); + + if (input == "quit") + { + break; + } + + object eval; + try + { + eval = await CSharpScript.EvaluateAsync(input, options, globals); + } + catch (Exception e) + { + eval = e; + } + Console.WriteLine(Inspector.Inspect(eval)); + } + + // client.Stop + client.Dispose(); + } + + static IEnumerable GetAssemblies() + { + var Assemblies = Assembly.GetEntryAssembly().GetReferencedAssemblies(); + foreach (var a in Assemblies) + { + var asm = Assembly.Load(a); + yield return asm; + } + yield return Assembly.GetEntryAssembly(); + } + + public class ScriptGlobals + { + public IDiscordClient Client { get; set; } + } + } +} diff --git a/sample/idn/idn.csproj b/sample/idn/idn.csproj new file mode 100644 index 0000000000..e39a15a286 --- /dev/null +++ b/sample/idn/idn.csproj @@ -0,0 +1,16 @@ + + + + Exe + netcoreapp3.1 + + + + + + + + + + + diff --git a/src/Discord.Net/Discord.Net.csproj b/src/Discord.Net/Discord.Net.csproj new file mode 100644 index 0000000000..ddb15bd0a9 --- /dev/null +++ b/src/Discord.Net/Discord.Net.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.1 + 8.0 + enable + Discord + + + + + + + + + + + + + + diff --git a/src/Discord.Net/DiscordClient.cs b/src/Discord.Net/DiscordClient.cs new file mode 100644 index 0000000000..5c651d88d9 --- /dev/null +++ b/src/Discord.Net/DiscordClient.cs @@ -0,0 +1,37 @@ +using System; +using Discord.Rest; +using Discord.Socket; + +namespace Discord +{ + internal class DiscordClient : IDiscordClient + { + public DiscordRestApi Rest { get; } + public DiscordGatewayApi Gateway { get; } + + private readonly DiscordConfig _config; + private readonly Logger _logger; + + public DiscordClient(DiscordConfig config, DiscordRestApi restApi, DiscordGatewayApi gatewayApi) + { + _config = config; + _logger = new Logger("Client", config.MinClientSeverity); + + Rest = restApi; + Gateway = gatewayApi; + + Log += _ => { }; // initialize log method + Rest.Logger.Message += m => Log(m); + Gateway.Logger.Message += m => Log(m); + _logger.Message += m => Log(m); + } + + public event Action Log; + + public void Dispose() + { + Rest.Dispose(); + Gateway.Dispose(); + } + } +} diff --git a/src/Discord.Net/DiscordConfig.cs b/src/Discord.Net/DiscordConfig.cs new file mode 100644 index 0000000000..c752c27c32 --- /dev/null +++ b/src/Discord.Net/DiscordConfig.cs @@ -0,0 +1,51 @@ +using Discord.Socket; +using Discord.Socket.Providers; +using System; + +namespace Discord +{ + public class DiscordConfig + { + /// + /// Discord.Net version + /// + public const string Version = "3.0.0a0"; + /// + /// Discord.Net User-Agent + /// + public const string UserAgent = "DiscordBot (https://github.com/discord-net/Discord.Net, " + Version + ")"; + + /// + /// The default, fallback Gateway URI. This will generally be replaced by . + /// + public static readonly Uri DefaultGatewayUri = new Uri("wss://gateway.discord.gg"); + /// + /// The default REST URI. + /// + public static readonly Uri DefaultRestUri = new Uri("https://discordapp.com/api/v6/"); + /// + /// The URI to use when making HTTP requests. If specified, this will override the default. + /// + public Uri? RestUri = null; + /// + /// The URI to use when connecting to the gateway. If specified, this will override the URI Discord instructs us to use. + /// + public Uri? GatewayUri = null; + /// + /// SocketFactory gets or sets how a WebSocket will be created. + /// + public SocketFactory SocketFactory { get; set; } = DefaultSocketFactory.Create; + /// + /// Minimum Log Severity for the Rest API. + /// + public LogSeverity MinRestSeverity { get; set; } = LogSeverity.Info; + /// + /// Minimum Log Severity for the Gateway API. + /// + public LogSeverity MinGatewaySeverity { get; set; } = LogSeverity.Info; + /// + /// Minimum Log Severity for the Client. + /// + public LogSeverity MinClientSeverity { get; set; } = LogSeverity.Info; + } +} diff --git a/src/Discord.Net/Entities/Snowflake.cs b/src/Discord.Net/Entities/Snowflake.cs new file mode 100644 index 0000000000..5dc8dad0ff --- /dev/null +++ b/src/Discord.Net/Entities/Snowflake.cs @@ -0,0 +1,24 @@ +namespace Discord +{ + /// + /// A Snowflake represents a unique, 64-bit identifier. + /// + public struct Snowflake + { + private readonly ulong _value; + + private Snowflake(ulong value) + { + _value = value; + } + + public static implicit operator ulong(Snowflake snowflake) + { + return snowflake._value; + } + public static implicit operator Snowflake(ulong value) + { + return new Snowflake(value); + } + } +} diff --git a/src/Discord.Net/IDiscordClient.cs b/src/Discord.Net/IDiscordClient.cs new file mode 100644 index 0000000000..03e1224250 --- /dev/null +++ b/src/Discord.Net/IDiscordClient.cs @@ -0,0 +1,28 @@ +using System; +using System.Net.Http.Headers; +using Discord.Rest; +using Discord.Socket; + +namespace Discord +{ + public interface IDiscordClient : IDisposable + { + public static IDiscordClient Create(string token, DiscordConfig? config = default) + { + config = config ?? new DiscordConfig(); + + // todo: validate token + var tokenHeader = AuthenticationHeaderValue.Parse(token); + + var rest = new DiscordRestApi(config, tokenHeader); + var gateway = new DiscordGatewayApi(config, token); + + return new DiscordClient(config, rest, gateway); + } + + DiscordRestApi Rest { get; } + DiscordGatewayApi Gateway { get; } + + event Action Log; + } +} diff --git a/src/Discord.Net/Rest/DiscordHttpClientHandler.cs b/src/Discord.Net/Rest/DiscordHttpClientHandler.cs new file mode 100644 index 0000000000..cf3d75f1cb --- /dev/null +++ b/src/Discord.Net/Rest/DiscordHttpClientHandler.cs @@ -0,0 +1,23 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Rest +{ + internal sealed class DiscordHttpClientHandler : HttpClientHandler + { + private readonly AuthenticationHeaderValue _token; + + public DiscordHttpClientHandler(AuthenticationHeaderValue token) + { + _token = token; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.Headers.Authorization = _token; + return base.SendAsync(request, cancellationToken); + } + } +} diff --git a/src/Discord.Net/Rest/DiscordRestApi.cs b/src/Discord.Net/Rest/DiscordRestApi.cs new file mode 100644 index 0000000000..3681a70142 --- /dev/null +++ b/src/Discord.Net/Rest/DiscordRestApi.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Threading.Tasks; +using System.Net.Http.Headers; +using System.Net.Http; +using Refit; +using Discord.Models; +using Discord.Serialization; + +// This is essentially a reimplementation of Wumpus.Net.Rest +namespace Discord.Rest +{ + public class DiscordRestApi : IDiscordRestApi + { + private readonly IDiscordRestApi _api; + private readonly HttpClient _http; + + internal Logger Logger { get; private set; } + + public DiscordRestApi(DiscordConfig config, AuthenticationHeaderValue token) + { + Logger = new Logger("Rest", config.MinRestSeverity); + + _http = new HttpClient(new DiscordHttpClientHandler(token), true) + { + BaseAddress = config.RestUri ?? DiscordConfig.DefaultRestUri, + }; + + var jsonOptions = new JsonSerializerOptions(); + jsonOptions.Converters.Add(new OptionalConverter()); + var refitSettings = new RefitSettings + { + ContentSerializer = new JsonContentSerializer(jsonOptions), + }; + _api = RestService.For(_http, refitSettings); + } + + public Task GetGatewayInfoAsync() + => _api.GetGatewayInfoAsync(); + public Task GetBotGatewayInfoAsync() + => _api.GetBotGatewayInfoAsync(); + + public void Dispose() + { + _http.Dispose(); + } + } +} diff --git a/src/Discord.Net/Rest/IDiscordRestApi.cs b/src/Discord.Net/Rest/IDiscordRestApi.cs new file mode 100644 index 0000000000..83b5103290 --- /dev/null +++ b/src/Discord.Net/Rest/IDiscordRestApi.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading.Tasks; +using Refit; +using Discord.Models; + +namespace Discord.Rest +{ + public interface IDiscordRestApi + { + // --- /applications + + // --- /channels + + // --- /gateway + [Get("/gateway/bot")] + Task GetGatewayInfoAsync(); + [Get("/gateway/bot")] + Task GetBotGatewayInfoAsync(); + + // --- /guilds + + // --- /invites + + // --- /oauth2 + + // --- /store + + // --- /users + + // --- /voice + + // --- /webhooks + } +} diff --git a/src/Discord.Net/Rest/Models/Channel/Channel.cs b/src/Discord.Net/Rest/Models/Channel/Channel.cs new file mode 100644 index 0000000000..463daecf56 --- /dev/null +++ b/src/Discord.Net/Rest/Models/Channel/Channel.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace Discord.Models +{ + public class Channel + { + public const int MinChannelNameLength = 2; + public const int MaxChannelNameLength = 100; + + public const int MinChannelTopicLength = 0; + public const int MaxChannelTopicLength = 1024; + + public const int MinUserLimit = 0; + public const int MaxUserLimit = 100; + + public const int MinBitrate = 8000; + public const int MaxBitrate = 384000; + + public const int MinRateLimitPerUser = 0; + public const int MaxRateLimitPerUser = 21600; + + [JsonPropertyName("id")] + public Snowflake Id { get; set; } + [JsonPropertyName("type")] + public ChannelType Type { get; set; } + [JsonPropertyName("guild_id")] + public Optional GuildId { get; set; } + [JsonPropertyName("position")] + public Optional Position { get; set; } + [JsonPropertyName("permission_overwrites")] + public Optional Overwrites { get; set; } + [JsonPropertyName("name")] + public Optional Name { get; set; } + [JsonPropertyName("topic")] + public Optional Topic { get; set; } + [JsonPropertyName("nsfw")] + public Optional Nsfw { get; set; } + [JsonPropertyName("user_limit")] + public Optional Bitrate { get; set; } + [JsonPropertyName("rate_limit_per_user")] + public Optional RateLimitPerUser { get; set; } + [JsonPropertyName("recipients")] + public Optional Recipients { get; set; } + [JsonPropertyName("icon")] + public Optional IconId { get; set; } + [JsonPropertyName("owner_id")] + public Optional OwnerId { get; set; } + [JsonPropertyName("application_id")] + public Optional ApplicationId { get; set; } + [JsonPropertyName("parent_id")] + public Optional ParentId { get; set; } + [JsonPropertyName("last_pin_timestamp")] + public Optional LastPinTimestamp { get; set; } + // omitted: last_message_id + } +} diff --git a/src/Discord.Net/Rest/Models/Channel/ChannelType.cs b/src/Discord.Net/Rest/Models/Channel/ChannelType.cs new file mode 100644 index 0000000000..47d8bb0556 --- /dev/null +++ b/src/Discord.Net/Rest/Models/Channel/ChannelType.cs @@ -0,0 +1,13 @@ +namespace Discord.Models +{ + public enum ChannelType : byte + { + Text = 0, + Direct = 1, + Voice = 2, + Group = 3, + Category = 4, + News = 5, + Store = 6 + } +} diff --git a/src/Discord.Net/Rest/Models/GatewayInfo.cs b/src/Discord.Net/Rest/Models/GatewayInfo.cs new file mode 100644 index 0000000000..59331bddf1 --- /dev/null +++ b/src/Discord.Net/Rest/Models/GatewayInfo.cs @@ -0,0 +1,25 @@ +#pragma warning disable CS8618 // Uninitialized NRT expected in models +using System.Text.Json.Serialization; + +namespace Discord.Models +{ + public class GatewayInfo + { + [JsonPropertyName("url")] + public string Url { get; set; } + [JsonPropertyName("shards")] + public int? Shards { get; set; } + [JsonPropertyName("session_start_limit")] + public GatewaySessionStartInfo? SessionStartInfo { get; set; } + } + + public class GatewaySessionStartInfo + { + [JsonPropertyName("total")] + public int Total { get; set; } + [JsonPropertyName("remaining")] + public int Remaining { get; set; } + [JsonPropertyName("reset_after")] + public int ResetAfter { get; set; } + } +} diff --git a/src/Discord.Net/Rest/Models/Permissions/ChannelPermissions.cs b/src/Discord.Net/Rest/Models/Permissions/ChannelPermissions.cs new file mode 100644 index 0000000000..d3b018ab40 --- /dev/null +++ b/src/Discord.Net/Rest/Models/Permissions/ChannelPermissions.cs @@ -0,0 +1,36 @@ +using System; + +namespace Discord.Models +{ + [Flags] + public enum ChannelPermissions : ulong + { + // General + CreateInstantInvite = 0x0000_0001, + ManageChannel = 0x0000_0010, + AddReactions = 0x0000_0040, + ViewChannel = 0x0000_0400, + ManagePermissions = 0x1000_0000, + ManageWebhooks = 0x2000_0000, + + // Messages + SendMessages = 0x0000_0800, + SendTtsMessages = 0x0000_0100, + ManageMessages = 0x0000_02000, + EmbedLinks = 0x0000_4000, + AttachFiles = 0x0000_8000, + ReadMessageHistory = 0x0001_0000, + MentionEveryone = 0x0002_0000, + UseExternalEmoji = 0x0004_0000, + + // Voice + Connect = 0x0010_0000, + Speak = 0x0020_0000, + MuteMembers = 0x0040_0000, + DeafenMembers = 0x0080_0000, + MoveMembers = 0x0100_0000, + UseVoiceActivity = 0x0200_0000, + PrioritySpeaker = 0x0000_0100, + Stream = 0x0000_0200, + } +} diff --git a/src/Discord.Net/Rest/Models/Permissions/GuildPermissions.cs b/src/Discord.Net/Rest/Models/Permissions/GuildPermissions.cs new file mode 100644 index 0000000000..5ce197a48b --- /dev/null +++ b/src/Discord.Net/Rest/Models/Permissions/GuildPermissions.cs @@ -0,0 +1,45 @@ +using System; + +namespace Discord.Models +{ + // todo: doc these when other models exist + [Flags] + public enum GuildPermissions : ulong + { + // General + CreateInstantInvite = 0x0000_0001, + KickMembers = 0x0000_0002, + BanMembers = 0x0000_0004, + Administrator = 0x0000_0008, + ManageChannels = 0x0000_0010, + ManageGuild = 0x0000_0020, + AddReactions = 0x0000_0040, + ViewAuditLog = 0x0000_0080, + ViewChannel = 0x0000_0400, + ChangeNickname = 0x0400_0000, + ManageNicknames = 0x0800_0000, + ManageRoles = 0x1000_0000, + ManageWebhooks = 0x2000_0000, + ManageEmoji = 0x4000_0000, + + // Messages + SendMessages = 0x0000_0800, + SendTtsMessages = 0x0000_0100, + ManageMessages = 0x0000_02000, + EmbedLinks = 0x0000_4000, + AttachFiles = 0x0000_8000, + ReadMessageHistory = 0x0001_0000, + MentionEveryone = 0x0002_0000, + UseExternalEmoji = 0x0004_0000, + + // Voice + Connect = 0x0010_0000, + Speak = 0x0020_0000, + MuteMembers = 0x0040_0000, + DeafenMembers = 0x0080_0000, + MoveMembers = 0x0100_0000, + UseVoiceActivity = 0x0200_0000, + PrioritySpeaker = 0x0000_0100, + Stream = 0x0000_0200, + } +} diff --git a/src/Discord.Net/Rest/Models/Permissions/Overwrite.cs b/src/Discord.Net/Rest/Models/Permissions/Overwrite.cs new file mode 100644 index 0000000000..4b947eab4f --- /dev/null +++ b/src/Discord.Net/Rest/Models/Permissions/Overwrite.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json.Serialization; + +namespace Discord.Models +{ + public class Overwrite + { + [JsonPropertyName("id")] + public Snowflake Id { get; set; } + [JsonPropertyName("type")] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PermissionTarget TargetType { get; set; } + } +} diff --git a/src/Discord.Net/Rest/Models/Permissions/PermissionTarget.cs b/src/Discord.Net/Rest/Models/Permissions/PermissionTarget.cs new file mode 100644 index 0000000000..980aac4537 --- /dev/null +++ b/src/Discord.Net/Rest/Models/Permissions/PermissionTarget.cs @@ -0,0 +1,8 @@ +namespace Discord.Models +{ + public enum PermissionTarget + { + Member, + Role + } +} diff --git a/src/Discord.Net/Rest/Models/Users/AccountFlags.cs b/src/Discord.Net/Rest/Models/Users/AccountFlags.cs new file mode 100644 index 0000000000..4ce70def6b --- /dev/null +++ b/src/Discord.Net/Rest/Models/Users/AccountFlags.cs @@ -0,0 +1,20 @@ +using System; + +namespace Discord.Models +{ + [Flags] + public enum AccountFlags : short + { + None = 0, + Employee = 1<<0, + Partner = 1<<1, + HypesquadEvents = 1<<2, + BugHunter = 1<<3, + HypesquadBravery = 1<<6, + HypesquadBrilliance = 1<<7, + HypesquadBalance = 1<<8, + EarlySupporter = 1<<9, + TeamUser = 1<<10, + System = 1<<12, + } +} diff --git a/src/Discord.Net/Rest/Models/Users/PremiumType.cs b/src/Discord.Net/Rest/Models/Users/PremiumType.cs new file mode 100644 index 0000000000..492586ffc4 --- /dev/null +++ b/src/Discord.Net/Rest/Models/Users/PremiumType.cs @@ -0,0 +1,8 @@ +namespace Discord.Models +{ + public enum PremiumType : byte + { + Classic = 1, + Nitro = 2 + } +} diff --git a/src/Discord.Net/Rest/Models/Users/User.cs b/src/Discord.Net/Rest/Models/Users/User.cs new file mode 100644 index 0000000000..e6d643e47d --- /dev/null +++ b/src/Discord.Net/Rest/Models/Users/User.cs @@ -0,0 +1,33 @@ +#pragma warning disable CS8618 // Uninitialized NRT expected in models +using System.Text.Json.Serialization; + +namespace Discord.Models +{ + public class User + { + [JsonPropertyName("id")] + public Snowflake Id { get; set; } + [JsonPropertyName("username")] + public string Username { get; set; } + [JsonPropertyName("discriminator")] + public ushort Discriminator { get; set; } + [JsonPropertyName("avatar")] + public string? AvatarId { get; set; } + [JsonPropertyName("bot")] + public Optional Bot { get; set; } + [JsonPropertyName("system")] + public Optional System { get; set; } + [JsonPropertyName("mfa_enabled")] + public Optional MfaEnabled { get; set; } + [JsonPropertyName("locale")] + public Optional Locale { get; set; } + [JsonPropertyName("verified")] + public Optional Verified { get; set; } + [JsonPropertyName("email")] + public Optional Email { get; set; } + [JsonPropertyName("flags")] + public Optional Flags { get; set; } + [JsonPropertyName("premium_type")] + public Optional PremiumType { get; set; } + } +} diff --git a/src/Discord.Net/Rest/Models/Webhook/Webhook.cs b/src/Discord.Net/Rest/Models/Webhook/Webhook.cs new file mode 100644 index 0000000000..08ea61f47d --- /dev/null +++ b/src/Discord.Net/Rest/Models/Webhook/Webhook.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace Discord.Models +{ + public class Webhook + { + public const int MinWebhookNameLength = 2; + public const int MaxWebhookNameLength = 32; + + public const int MinMessageContentLength = 0; + public const int MaxMessageContentLength = 2000; + + public const int MinEmbedLimit = 0; + public const int MaxEmbedLimit = 10; + + [JsonPropertyName("id")] + public Snowflake Id { get; set; } + [JsonPropertyName("type")] + public WebhookType Type { get; set; } + [JsonPropertyName("guild_id")] + public Optional GuildId { get; set; } + [JsonPropertyName("channel_id")] + public Snowflake ChannelId { get; set; } + [JsonPropertyName("user")] + public Optional Creator { get; set; } + [JsonPropertyName("name")] + public string? Name { get; set; } + [JsonPropertyName("avatar")] + public string? AvatarId { get; set; } + [JsonPropertyName("token")] + public Optional Token { get; set; } + } +} diff --git a/src/Discord.Net/Rest/Models/Webhook/WebhookType.cs b/src/Discord.Net/Rest/Models/Webhook/WebhookType.cs new file mode 100644 index 0000000000..574d5c9ca8 --- /dev/null +++ b/src/Discord.Net/Rest/Models/Webhook/WebhookType.cs @@ -0,0 +1,8 @@ +namespace Discord.Models +{ + public enum WebhookType : byte + { + Incoming = 1, + ChannelFollower = 2 + } +} diff --git a/src/Discord.Net/Serialization/JsonContentSerializer.cs b/src/Discord.Net/Serialization/JsonContentSerializer.cs new file mode 100644 index 0000000000..4fd4c19715 --- /dev/null +++ b/src/Discord.Net/Serialization/JsonContentSerializer.cs @@ -0,0 +1,49 @@ +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Refit; + +// https://blog.martincostello.com/refit-and-system-text-json/ + +namespace Discord +{ + public class JsonContentSerializer : IContentSerializer + { + private static readonly MediaTypeHeaderValue _jsonMediaType = new MediaTypeHeaderValue("application/json") { CharSet = Encoding.UTF8.WebName }; + private readonly JsonSerializerOptions _serializerOptions; + + public JsonContentSerializer(JsonSerializerOptions serializerOptions) + { + _serializerOptions = serializerOptions; + } + + public async Task DeserializeAsync(HttpContent content) + { + using var json = await content.ReadAsStreamAsync().ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync(json, _serializerOptions).ConfigureAwait(false); + } + + public async Task SerializeAsync(T data) + { + var stream = new MemoryStream(); + try + { + await JsonSerializer.SerializeAsync(stream, data, _serializerOptions).ConfigureAwait(false); + await stream.FlushAsync(); + + var content = new StreamContent(stream); + content.Headers.ContentType = _jsonMediaType; + + return content; + } + catch + { + await stream.DisposeAsync().ConfigureAwait(false); + throw; + } + } + } +} diff --git a/src/Discord.Net/Serialization/OptionalConverter.cs b/src/Discord.Net/Serialization/OptionalConverter.cs new file mode 100644 index 0000000000..375dd60d59 --- /dev/null +++ b/src/Discord.Net/Serialization/OptionalConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Discord.Serialization +{ + // TODO: This does not allow us to omit properties at runtime + // Need to evaluate which cases need us to omit properties and write a separate converter + // for those. At this time I can only think of the outgoing REST PATCH requests. Incoming + // omitted properties will be correctly treated as Optional.Unspecified (the default) + public class OptionalConverter : JsonConverterFactory + { + private class OptionalTypeConverter : JsonConverter> + { + public override Optional Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return Optional.Unspecified; + else + return new Optional(JsonSerializer.Deserialize(ref reader, options)); + } + + public override void Write(Utf8JsonWriter writer, Optional value, JsonSerializerOptions options) + { + if (!value.IsSpecified) + writer.WriteNullValue(); + else + JsonSerializer.Serialize(writer, value.Value, options); + } + } + + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Optional<>); + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var innerType = typeToConvert.GetGenericArguments()[0]; + var converterType = typeof(OptionalTypeConverter<>).MakeGenericType(innerType); + return (JsonConverter)Activator.CreateInstance(converterType); + } + } +} diff --git a/src/Discord.Net/Socket/DiscordGatewayApi.cs b/src/Discord.Net/Socket/DiscordGatewayApi.cs new file mode 100644 index 0000000000..9e098ff5c5 --- /dev/null +++ b/src/Discord.Net/Socket/DiscordGatewayApi.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Socket +{ + public class DiscordGatewayApi : IDisposable + { + private readonly DiscordConfig _config; + private readonly string _token; + + internal Logger Logger { get; private set; } + public ISocket Socket { get; set; } + + public DiscordGatewayApi(DiscordConfig config, string token) + { + Logger = new Logger("Gateway", config.MinGatewaySeverity); + + _config = config; + _token = token; + + Socket = config.SocketFactory(OnAborted, OnPacket); + } + + public async Task ConnectAsync(Uri? gatewayUri) + { + var baseUri = _config.GatewayUri ?? (gatewayUri ?? DiscordConfig.DefaultGatewayUri); + await Socket.ConnectAsync(baseUri, CancellationToken.None).ConfigureAwait(false); + } + + public void OnAborted(Exception error) + { + // todo: log + } + public async Task OnPacket(object packet) + { + await Task.CompletedTask; + } + + public void Dispose() + { + Socket.Dispose(); + } + } +} diff --git a/src/Discord.Net/Socket/ISocket.cs b/src/Discord.Net/Socket/ISocket.cs new file mode 100644 index 0000000000..98a0eacca8 --- /dev/null +++ b/src/Discord.Net/Socket/ISocket.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Socket +{ + public delegate ISocket SocketFactory(OnAbortionHandler abortionHandler, OnPacketHandler packetHandler); + + // A socket should only have one parent, so these do not need to be decoupled events. + public delegate Task OnPacketHandler(object packet); + public delegate void OnAbortionHandler(Exception error); + + public enum SocketState + { + Closed = default, + AcquiringOpenLock, + Opening, + Open, + AcquiringClosingLock, + Closing, + Aborted + } + + public interface ISocket : IDisposable + { + SocketState State { get; } + + Task ConnectAsync(Uri uri, CancellationToken token); + Task CloseAsync(int? code = null, string? reason = null); + Task SendAsync(ReadOnlyMemory payload); + + OnAbortionHandler OnAbortion { get; } + OnPacketHandler OnPacket { get; } + } +} diff --git a/src/Discord.Net/Socket/Providers/DefaultSocket.cs b/src/Discord.Net/Socket/Providers/DefaultSocket.cs new file mode 100644 index 0000000000..e071444b00 --- /dev/null +++ b/src/Discord.Net/Socket/Providers/DefaultSocket.cs @@ -0,0 +1,196 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Discord.Socket.Providers +{ + public static class DefaultSocketFactory + { + public static ISocket Create(OnAbortionHandler onAbortion, OnPacketHandler onPacket) + { + return new DefaultSocket(onAbortion, onPacket); + } + } + + internal class DefaultSocket : ISocket + { + public SocketState State { get; private set; } + public OnAbortionHandler OnAbortion { get; } + public OnPacketHandler OnPacket { get; } + + private ClientWebSocket _socket; + private Task? _receiveTask; + + private CancellationTokenSource _cancelTokenSource; + private SemaphoreSlim _sendLock; + private SemaphoreSlim _stateLock; + + public DefaultSocket(OnAbortionHandler onAbortion, OnPacketHandler onPacket) + { + _socket = new ClientWebSocket(); + + _cancelTokenSource = new CancellationTokenSource(); + _sendLock = new SemaphoreSlim(1); + _stateLock = new SemaphoreSlim(1); + + OnAbortion = onAbortion; + OnPacket = onPacket; + } + + public async Task ConnectAsync(Uri uri, CancellationToken connectCancelToken) + { + if (State == SocketState.Open + || State == SocketState.Opening + || State == SocketState.AcquiringOpenLock + || State == SocketState.Aborted) + { + // todo: evaluate how to handle a (redundant?) state operation + return; + } + + CancellationTokenSource openLock; // create a linked token in case the caller wants to cancel an opening connection + try + { + openLock = CancellationTokenSource.CreateLinkedTokenSource(_cancelTokenSource.Token, connectCancelToken); + } + catch (ObjectDisposedException e) + { + // Failed to link openLock, an expired cancellation token was passed + State = SocketState.Aborted; + OnAbortion(e); + return; + } + + State = SocketState.AcquiringOpenLock; + try + { + await _stateLock.WaitAsync(openLock.Token).ConfigureAwait(false); + } + catch (Exception e) + { + // Failed to acquire openLock + State = SocketState.Aborted; + OnAbortion(e); + } + State = SocketState.Opening; + + try + { + await _socket.ConnectAsync(uri, _cancelTokenSource.Token).ConfigureAwait(false); + } + catch (Exception e) + { + // Failed to open socket connection + State = SocketState.Aborted; + OnAbortion(e); + return; + } + State = SocketState.Open; + + _receiveTask = ReceiveAsync(); + + // TODO: this should not be expected to fail + _stateLock.Release(); + openLock.Dispose(); + } + public async Task CloseAsync(int? code, string? reason) + { + if (State == SocketState.Closed + || State == SocketState.Closing + || State == SocketState.AcquiringClosingLock + || State == SocketState.Aborted) + { + // todo: evaluate how to handle a (redundant?) state operation; see OpenAsync + return; + } + + State = SocketState.AcquiringClosingLock; + try + { + await _stateLock.WaitAsync(); + } + catch (Exception e) + { + State = SocketState.Aborted; + OnAbortion(e); + return; + } + State = SocketState.Closing; + + // I think it is acceptable to use CancellationToken.None here, as no parallel operation should need to cancel the socket closure + await _socket.CloseAsync((WebSocketCloseStatus)(code ?? 1005), + reason ?? string.Empty, + CancellationToken.None + ).ConfigureAwait(false); + + // Wait until after .NET has been told to close the socket to cancel any pending sends/receives + // + // Presumably, sends/receives should have failed gracefully by this point, instead of aborting the underlying socket + try + { + _cancelTokenSource.Cancel(); + await (_receiveTask ?? Task.CompletedTask); + } + catch + { + // just log for now + } + + State = SocketState.Closed; + } + public async Task ReceiveAsync() + { + while (State == SocketState.Open && !_cancelTokenSource.IsCancellationRequested) + { + try + { + Memory buffer = new Memory(); + var res = await _socket.ReceiveAsync(buffer, _cancelTokenSource.Token).ConfigureAwait(false); + // todo: handle memory renting and ongoing messages + // todo: parse and OnPacket + } + catch (Exception err) + { + // log error + if (_socket.State != WebSocketState.Open) // detrimental error + { + State = SocketState.Aborted; + OnAbortion(err); + return; + } + } + } + } + public async Task SendAsync(ReadOnlyMemory data) + { + if (State != SocketState.Open) + { + // raise error? + return; + } + + await _sendLock.WaitAsync().ConfigureAwait(false); + try + { + // TODO: compression? who needs it + await _socket.SendAsync(data, WebSocketMessageType.Text, true, _cancelTokenSource.Token).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + public void Dispose() + { + if (State != SocketState.Closed) + { + // log error? can this still proceed... + } + _socket.Dispose(); + _cancelTokenSource.Dispose(); + _stateLock.Dispose(); + } + } +} diff --git a/src/Discord.Net/Utilities/Logging.cs b/src/Discord.Net/Utilities/Logging.cs new file mode 100644 index 0000000000..9cf6ebcff5 --- /dev/null +++ b/src/Discord.Net/Utilities/Logging.cs @@ -0,0 +1,159 @@ +using System; +using System.Text; + +namespace Discord +{ + public enum LogSeverity + { + Trace, + Debug, + Info, + Warn, + Error + } + + public struct LogMessage + { + public LogSeverity Level { get; } + public string Source { get; } + public string Message { get; } + public Exception? Exception { get; } + + public LogMessage(LogSeverity level, string source, string message, Exception? exception = null) + { + Level = level; + Source = source; + Message = message; + Exception = exception; + } + + public override string ToString() => ToString(); + public string ToString(StringBuilder? builder = null, + bool fullException = true, + bool prependTimestamp = true, + DateTimeKind timestampKind = DateTimeKind.Local, + int? padSource = 11) + { + string? exMessage = fullException ? Exception?.ToString() : Exception?.Message; + int maxLength = 1 + + (prependTimestamp ? 8 : 0) + 1 + + (padSource.HasValue ? padSource.Value : Source?.Length ?? 0) + 1 + + (Message?.Length ?? 0) + + (exMessage?.Length ?? 0) + 3; + + if (builder == null) + builder = new StringBuilder(maxLength); + else + { + builder.Clear(); + builder.EnsureCapacity(maxLength); + } + + if (prependTimestamp) + { + DateTime now; + if (timestampKind == DateTimeKind.Utc) + now = DateTime.UtcNow; + else + now = DateTime.Now; + if (now.Hour < 10) + builder.Append('0'); + builder.Append(now.Hour); + builder.Append(':'); + if (now.Minute < 10) + builder.Append('0'); + builder.Append(now.Minute); + builder.Append(':'); + if (now.Second < 10) + builder.Append('0'); + builder.Append(now.Second); + builder.Append(' '); + } + if (Source != null) + { + if (padSource.HasValue) + { + if (Source.Length < padSource.Value) + { + builder.Append(Source); + builder.Append(' ', padSource.Value - Source.Length); + } + else if (Source.Length > padSource.Value) + builder.Append(Source.Substring(0, padSource.Value)); + else + builder.Append(Source); + } + else + builder.Append(Source); + builder.Append(' '); + } + if (!string.IsNullOrEmpty(Message)) + { + char c; + for (int i = 0; i < Message.Length; i++) + { + c = Message[i]; + if (!char.IsControl(c)) + builder.Append(c); + } + } + if (exMessage != null) + { + if (!string.IsNullOrEmpty(Message)) + { + builder.Append(':'); + builder.AppendLine(); + } + builder.Append(exMessage); + } + + return builder.ToString(); + } + } + + public class Logger + { + public event Action? Message; + public string Name { get; set; } + public LogSeverity MinSeverity { get; set; } + + public Logger(string source, LogSeverity minSeverity) + { + Name = source; + MinSeverity = minSeverity; + } + + public void Log(LogMessage message) + { + if (message.Level < MinSeverity) + return; + Message?.Invoke(message); + } + + public void Log(LogSeverity severity, string message, Exception? err = null) + => Log(new LogMessage(severity, Name, message, err)); + + public void Trace(string message, Exception? err = null) + => Log(LogSeverity.Trace, message, err); + public void Debug(string message, Exception? err = null) + => Log(LogSeverity.Debug, message, err); + public void Info(string message, Exception? err = null) + => Log(LogSeverity.Info, message, err); + public void Warn(string message, Exception? err = null) + => Log(LogSeverity.Warn, message, err); + public void Error(string message, Exception? err = null) + => Log(LogSeverity.Error, message, err); + + public void Trace(Exception err) + => Log(LogSeverity.Trace, null!, err); + public void Debug(Exception err) + => Log(LogSeverity.Debug, null!, err); + public void Info(Exception err) + => Log(LogSeverity.Info, null!, err); + public void Warn(Exception err) + => Log(LogSeverity.Warn, null!, err); + public void Error(Exception err) + => Log(LogSeverity.Error, null!, err); + + } +} diff --git a/src/Discord.Net/Utilities/Optional.cs b/src/Discord.Net/Utilities/Optional.cs new file mode 100644 index 0000000000..f61560764e --- /dev/null +++ b/src/Discord.Net/Utilities/Optional.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Text; +// todo: impl +namespace Discord +{ + public struct Optional + { + public static Optional Unspecified => default; + + public bool IsSpecified { get; } + private readonly T _innerValue; + + public T Value + { + get + { + if (!IsSpecified) + throw new UnspecifiedOptionalException(); + return _innerValue; + } + } + + public Optional(T value) + { + IsSpecified = true; + _innerValue = value; + } + + public override string ToString() + { + return $""; + } + + public override bool Equals(object obj) + { + if (obj is Optional opt) + { + if (IsSpecified && opt.IsSpecified) + return Value?.Equals(opt.Value) ?? opt.Value == null; + return IsSpecified == opt.IsSpecified; + } + return base.Equals(obj); + } + + public override int GetHashCode() + => IsSpecified ? Value?.GetHashCode() ?? 0 : 0; + + public static bool operator ==(Optional a, Optional b) + => a.Equals(b); + public static bool operator !=(Optional a, Optional b) + => !a.Equals(b); + + // todo: implement comparing, GetValueOrDefault, hash codes etc + } + + + public class UnspecifiedOptionalException : Exception + { + public UnspecifiedOptionalException() : base("An attempt was made to access an unspecified optional value") { } + } +} diff --git a/test/.gitkeep b/test/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/Discord.Tests.Unit/Discord.Tests.Unit.csproj b/test/Discord.Tests.Unit/Discord.Tests.Unit.csproj new file mode 100644 index 0000000000..0a4bb8e5e4 --- /dev/null +++ b/test/Discord.Tests.Unit/Discord.Tests.Unit.csproj @@ -0,0 +1,20 @@ + + + + netcoreapp3.1 + + false + + + + + + + + + + + + + + diff --git a/test/Discord.Tests.Unit/Serialization/OptionalConverterTests.cs b/test/Discord.Tests.Unit/Serialization/OptionalConverterTests.cs new file mode 100644 index 0000000000..f606bc94b3 --- /dev/null +++ b/test/Discord.Tests.Unit/Serialization/OptionalConverterTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Xunit; +using Discord.Serialization; + +namespace Discord.Tests.Unit.Serialization +{ + public class OptionalConverterTests + { + private readonly JsonSerializerOptions _jsonOptions; + + public OptionalConverterTests() + { + _jsonOptions = new JsonSerializerOptions(); + _jsonOptions.Converters.Add(new OptionalConverter()); + } + + public class SampleOptionalClass + { + [JsonPropertyName("optional_number")] + public Optional OptionalNumber { get; set; } + [JsonPropertyName("required_number")] + public int RequiredNumber { get; set; } + + public override bool Equals(object obj) + => (obj is SampleOptionalClass other) && (other.OptionalNumber == OptionalNumber && other.RequiredNumber == RequiredNumber); + public override int GetHashCode() + => OptionalNumber.GetHashCode() ^ RequiredNumber.GetHashCode(); + } + + private string expectedOptionalUnset = "{\"optional_number\":null,\"required_number\":10}"; + private SampleOptionalClass withOptionalUnset = new SampleOptionalClass + { + OptionalNumber = Optional.Unspecified, + RequiredNumber = 10, + }; + private string expectedOptionalSet = "{\"optional_number\":11,\"required_number\":10}"; + private SampleOptionalClass withOptionalSet = new SampleOptionalClass + { + OptionalNumber = new Optional(11), + RequiredNumber = 10, + }; + + [Fact] + public void OptionalConverter_Can_Write() + { + // todo: is STJ deterministic in writing order? want to make sure this test doesn't fail because of cosmic rays + var unsetString = JsonSerializer.Serialize(withOptionalUnset, _jsonOptions); + Assert.Equal(expectedOptionalUnset, unsetString); + + var setString = JsonSerializer.Serialize(withOptionalSet, _jsonOptions); + Assert.Equal(expectedOptionalSet, setString); + } + + [Fact] + public void OptionalConverter_Can_Read() + { + var unset = JsonSerializer.Deserialize(expectedOptionalUnset, _jsonOptions); + Assert.Equal(withOptionalUnset, unset); + + var set = JsonSerializer.Deserialize(expectedOptionalSet, _jsonOptions); + Assert.Equal(withOptionalSet, set); + } + + public class NestedPoco + { + [JsonPropertyName("name")] + public string Name { get; set; } + [JsonPropertyName("age")] + public int Age { get; set; } + + public override bool Equals(object obj) + => (obj is NestedPoco other) && (Name == other.Name && Age == other.Age); + public override int GetHashCode() + => Name.GetHashCode() ^ Age.GetHashCode(); + + } + public class NestedSampleClass + { + [JsonPropertyName("nested")] + public Optional Nested { get; set; } + } + + private string expectedNestedWithUnset = "{\"nested\":null}"; + private NestedSampleClass nestedWithUnset = new NestedSampleClass + { + Nested = Optional.Unspecified + }; + private string expectedNestedWithSet = "{\"nested\":{\"name\":\"Ashley\",\"age\":23}}"; + private NestedSampleClass nestedWithSet = new NestedSampleClass + { + Nested = new Optional(new NestedPoco + { + Name = "Ashley", + Age = 23 + }), + }; + + [Fact] + public void OptionalConverter_Can_Write_Nested_Poco() + { + var unset = JsonSerializer.Serialize(nestedWithUnset, _jsonOptions); + Assert.Equal(expectedNestedWithUnset, unset); + + var set = JsonSerializer.Serialize(nestedWithSet, _jsonOptions); + Assert.Equal(expectedNestedWithSet, set); + } + [Fact] + public void OptionalConverter_Can_Read_Nested_Poco() + { + var unset = JsonSerializer.Deserialize(expectedNestedWithUnset, _jsonOptions); + Assert.Equal(nestedWithUnset.Nested, unset.Nested); + + var set = JsonSerializer.Deserialize(expectedNestedWithSet, _jsonOptions); + Assert.Equal(nestedWithSet.Nested, set.Nested); + } + } +} diff --git a/test/Discord.Tests.Unit/UnitTest1.cs b/test/Discord.Tests.Unit/UnitTest1.cs new file mode 100644 index 0000000000..547a0b053a --- /dev/null +++ b/test/Discord.Tests.Unit/UnitTest1.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace Discord.Tests.Unit +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + Assert.True(true); + } + } +}