Refactor and rename project to yawaflua.Discord.Net; add core entities and interfaces for Discord OAuth2 integration

This commit is contained in:
Dmitri Shimanski
2025-08-22 06:48:43 +03:00
parent 423bc8def0
commit e0d2b65fff
37 changed files with 867 additions and 382 deletions

23
.github/workflows/dotnet.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: .NET Tests
on:
push:
branches: [ "*" ]
pull_request:
branches: [ "*" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore /p:TreatWarningsAsErrors=false
- name: Test
run: dotnet test --no-build --verbosity normal

48
.github/workflows/nuget.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: CI/CD NuGet Package
on:
release:
types: [created]
env:
NUGET_SOURCE: https://api.nuget.org/v3/index.json
GITHUB_SOURCE: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: Extract Package Version
id: get_version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "PACKAGE_VERSION=$VERSION" >> $GITHUB_ENV
echo "Using version: $VERSION"
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Test
run: dotnet test --no-build --verbosity normal --configuration Release -e telegram_test_token=${{secrets.TELEGRAM_TEST_TOKEN}}
- name: Pack
run: dotnet pack --no-build --configuration Release -p:PackageVersion=${{ env.PACKAGE_VERSION }}
- name: Add GitHub source
run: dotnet nuget add source --username ${{ github.repository_owner }} --password ${{ secrets.GHCR_TOKEN }} --store-password-in-clear-text --name github ${{ env.GITHUB_SOURCE }}
- name: Publish to GitHub Packages
run: dotnet nuget push "**/*.nupkg" --source "github"
- name: Publish to NuGet
run: dotnet nuget push "**/*.nupkg" --source ${{ env.NUGET_SOURCE }} --api-key ${{ secrets.NUGET_API_KEY }}

11
DiscordConfig.cs Normal file
View File

@@ -0,0 +1,11 @@
namespace yawaflua.Discord.Net;
public sealed class DiscordConfig
{
public ulong ClientId { get; set; }
public string ClientSecret { get; set; } = string.Empty;
public string RedirectUri { get; set; } = string.Empty;
public ScopesBuilder Scope { get; set; }
public bool Prompt { get; set; } = true;
public string Token { get; set; } = string.Empty;
}

View File

@@ -1,36 +1,70 @@
using System.Collections.Specialized;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Web;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
using x3rt.DiscordOAuth2.Entities;
using x3rt.DiscordOAuth2.Options;
using yawaflua.Discord.Net.Entities;
using yawaflua.Discord.Net.Entities.Enums;
using yawaflua.Discord.Net.Interfaces;
using yawaflua.Discord.Net.Interfaces.Models;
using ISession = yawaflua.Discord.Net.Interfaces.Models.ISession;
namespace x3rt.DiscordOAuth2;
namespace yawaflua.Discord.Net;
public class DiscordOAuth
public class DiscordOAuth : IDiscord
{
private static ulong ClientId { get; set; }
private static string ClientSecret { get; set; } = string.Empty;
private static string? BotToken { get; set; }
private ulong ClientId { get; set; }
private string ClientSecret { get; set; } = string.Empty;
private string? BotToken { get; set; }
private string RedirectUri { get; set; }
private bool Prompt { get; set; }
private ScopesBuilder Scopes { get; set; }
private string? AccessToken { get; set; }
public static void Configure(ulong clientId, string clientSecret, string? botToken = null)
private IToken? token { get; set; }
private readonly HttpClient _httpClient = new HttpClient();
/// <summary>
/// Now deprecated, use DiscordConfig instead.
/// </summary>
/// <param name="clientId"></param>
/// <param name="clientSecret"></param>
/// <param name="botToken"></param>
[Obsolete]
public static DiscordOAuth Configure(ulong clientId, string clientSecret, string? botToken = null)
{
return new DiscordOAuth(clientId, clientSecret, string.Empty, new ScopesBuilder(OAuthScope.Identify))
{
BotToken = botToken
};
}
public DiscordOAuth(ulong clientId, string clientSecret, string redirectUri, ScopesBuilder scopes, string? botToken = null, bool prompt = true)
{
ClientId = clientId;
ClientSecret = clientSecret;
RedirectUri = redirectUri;
Scopes = scopes;
Prompt = prompt;
BotToken = botToken;
}
private readonly HttpClient _httpClient = new HttpClient();
public DiscordOAuth(DiscordConfig config)
{
ClientId = config.ClientId;
ClientSecret = config.ClientSecret;
RedirectUri = config.RedirectUri;
Scopes = config.Scope;
Prompt = config.Prompt;
BotToken = config.Token;
}
[Obsolete]
public DiscordOAuth(string redirectUri, ScopesBuilder scopes, bool prompt = true)
{
RedirectUri = redirectUri;
@@ -38,30 +72,12 @@ public class DiscordOAuth
Prompt = prompt;
}
public string GetAuthorizationUrl(string state)
{
NameValueCollection query = HttpUtility.ParseQueryString(string.Empty);
query["client_id"] = ClientId.ToString();
query["redirect_uri"] = RedirectUri;
query["response_type"] = "code";
query["scope"] = Scopes.ToString();
query["state"] = state;
query["prompt"] = Prompt ? "consent" : "none";
var uriBuilder = new UriBuilder("https://discord.com/api/oauth2/authorize")
{
Query = query.ToString()
};
return uriBuilder.ToString();
}
public static bool TryGetCode(HttpRequest request, out string? code)
{
code = null;
if (request.Query.TryGetValue("code", out StringValues codeValues))
if (request.Query.TryGetValue("code", out var codeQuery))
{
code = codeValues;
code = codeQuery;
return true;
}
@@ -70,12 +86,10 @@ public class DiscordOAuth
public static bool TryGetCode(HttpContext context, out string? code)
{
var b = TryGetCode(context.Request, out var a);
code = a;
return b;
return TryGetCode(context.Request, out code);
}
public async Task<OAuthToken?> GetTokenAsync(string code)
public async Task<IToken?> GetTokenAsync(string code)
{
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
@@ -89,135 +103,19 @@ public class DiscordOAuth
var response = await _httpClient.PostAsync("https://discord.com/api/oauth2/token", content);
var responseString = await response.Content.ReadAsStringAsync();
var authToken = JsonConvert.DeserializeObject<OAuthToken>(responseString);
var authToken = JsonSerializer.Deserialize<OAuthToken>(responseString);
AccessToken = authToken?.AccessToken;
token = authToken;
return authToken;
}
private async Task<T?> GetInformationAsync<T>(string accessToken, string endpoint) where T : class
public ISession CreateSession()
{
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var response = await _httpClient.GetAsync($"https://discord.com/api/{endpoint}");
if (!response.IsSuccessStatusCode)
{
return null;
}
var responseString = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<T?>(responseString) ?? null;
if (token != null)
return new DiscordSession(token, _httpClient, Scopes, ClientId, ClientSecret, RedirectUri, Prompt);
else
throw new InvalidOperationException("Token is not set. Please call GetTokenAsync first.");
}
private async Task<T?> GetInformationAsync<T>(HttpContext context, string endpoint) where T : class
{
if (AccessToken is null)
{
if (!TryGetCode(context, out var code)) return null;
var accessToken = await GetTokenAsync(code!);
if (accessToken is null) return null;
return await GetInformationAsync<T>(accessToken.AccessToken, endpoint);
}
else
{
return await GetInformationAsync<T>(AccessToken, endpoint);
}
}
private async Task<T?> GetInformationAsync<T>(OAuthToken token, string endpoint) where T : class
{
return await GetInformationAsync<T>(token.AccessToken, endpoint);
}
public async Task<DiscordUser?> GetUserAsync(string accessToken)
{
return await GetInformationAsync<DiscordUser>(accessToken, "users/@me");
}
public async Task<DiscordUser?> GetUserAsync(HttpContext context)
{
return await GetInformationAsync<DiscordUser>(context, "users/@me");
}
public async Task<DiscordUser?> GetUserAsync(OAuthToken token)
{
return await GetInformationAsync<DiscordUser>(token, "users/@me");
}
public async Task<DiscordGuild[]?> GetGuildsAsync(string accessToken)
{
return await GetInformationAsync<DiscordGuild[]>(accessToken, "users/@me/guilds");
}
public async Task<DiscordGuild[]?> GetGuildsAsync(HttpContext context)
{
return await GetInformationAsync<DiscordGuild[]>(context, "users/@me/guilds");
}
public async Task<DiscordGuild[]?> GetGuildsAsync(OAuthToken token)
{
return await GetInformationAsync<DiscordGuild[]>(token, "users/@me/guilds");
}
public async Task<DiscordConnection[]?> GetConnectionsAsync(string accessToken)
{
return await GetInformationAsync<DiscordConnection[]>(accessToken, "users/@me/connections");
}
public async Task<DiscordConnection[]?> GetConnectionsAsync(HttpContext context)
{
return await GetInformationAsync<DiscordConnection[]>(context, "users/@me/connections");
}
public async Task<DiscordConnection[]?> GetConnectionsAsync(OAuthToken token)
{
return await GetInformationAsync<DiscordConnection[]>(token, "users/@me/connections");
}
public async Task<bool> JoinGuildAsync(string accessToken, ulong userId, GuildOptions options)
{
if (BotToken is null) throw new InvalidOperationException("Bot token is not set");
var request =
new HttpRequestMessage(HttpMethod.Put,
$"https://discord.com/api/guilds/{options.GuildId}/members/{userId}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bot", BotToken);
var content = new Dictionary<string, object>
{
["access_token"] = accessToken
};
if (options.Nickname is not null) content["nick"] = options.Nickname;
if (options.RoleIds is not null) content["roles"] = options.RoleIds;
if (options.Muted is not null) content["mute"] = options.Muted;
if (options.Deafened is not null) content["deaf"] = options.Deafened;
var json = JsonConvert.SerializeObject(content);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.SendAsync(request);
return response.IsSuccessStatusCode;
}
public async Task<bool> JoinGuildAsync(HttpContext context, GuildOptions options)
{
if (AccessToken is null)
{
if (!TryGetCode(context, out var code)) return false;
var accessToken = await GetTokenAsync(code!);
if (accessToken is null) return false;
var user = await GetUserAsync(accessToken.AccessToken);
if (user is null) return false;
return await JoinGuildAsync(accessToken.AccessToken, user.Id, options);
}
else
{
var user = await GetUserAsync(AccessToken);
if (user is null) return false;
return await JoinGuildAsync(AccessToken, user.Id, options);
}
}
public async Task<bool> JoinGuildAsync(OAuthToken token, GuildOptions options)
{
var user = await GetUserAsync(token.AccessToken);
if (user is null) return false;
return await JoinGuildAsync(token.AccessToken, user.Id, options);
}
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
using yawaflua.Discord.Net.Interfaces.Models;
namespace yawaflua.Discord.Net.Entities;
internal class AvatarDecoration : IAvatarDecoration
{
[JsonPropertyName("asset")]
public string AssetHash { get; set; }
[JsonPropertyName("sku_id")]
public ulong AssetArticular { get; set; }
}

View File

@@ -1,48 +1,19 @@
using Newtonsoft.Json;
using System.Text.Json.Serialization;
using yawaflua.Discord.Net.Entities.Enums;
using yawaflua.Discord.Net.Interfaces.Models;
namespace x3rt.DiscordOAuth2.Entities;
namespace yawaflua.Discord.Net.Entities;
public class DiscordConnection
internal class DiscordConnection : IConnection
{
[JsonProperty("id")] public string Id { get; set; }
[JsonProperty("name")] public string Name { get; set; }
[JsonProperty("type")] public ConnectionType Type { get; set; }
[JsonProperty("revoked")] public bool? Revoked { get; set; }
[JsonProperty("integrations")] public object[] Integrations { get; set; }
[JsonProperty("verified")] public bool? Verified { get; set; }
[JsonProperty("friend_sync")] public bool? FriendSync { get; set; }
[JsonProperty("show_activity")] public bool? ShowActivity { get; set; }
[JsonProperty("two_way_link")] public bool? TwoWayLink { get; set; }
[JsonProperty("visibility")] public ConnectionVisibility? Visibility { get; set; }
public enum ConnectionVisibility
{
None,
Everyone
}
public enum ConnectionType
{
BattleNet,
Ebay,
EpicGames,
Facebook,
GitHub,
Instagram,
LeagueOfLegends,
PayPal,
PlayStation,
Reddit,
RiotGames,
Spotify,
Skype,
Steam,
TikTok,
Twitch,
Twitter,
Xbox,
YouTube
}
[JsonPropertyName("id")] public string Id { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("type")] public ConnectionType Type { get; set; }
[JsonPropertyName("revoked")] public bool? Revoked { get; set; }
[JsonPropertyName("integrations")] public object[] Integrations { get; set; }
[JsonPropertyName("verified")] public bool? Verified { get; set; }
[JsonPropertyName("friend_sync")] public bool? FriendSync { get; set; }
[JsonPropertyName("show_activity")] public bool? ShowActivity { get; set; }
[JsonPropertyName("two_way_link")] public bool? TwoWayLink { get; set; }
[JsonPropertyName("visibility")] public ConnectionVisibility? Visibility { get; set; }
}

View File

@@ -1,24 +1,45 @@
using Newtonsoft.Json;
using System.Text.Json.Serialization;
using yawaflua.Discord.Net.Entities.Enums;
using yawaflua.Discord.Net.Interfaces.Models;
namespace x3rt.DiscordOAuth2.Entities;
namespace yawaflua.Discord.Net.Entities;
public class DiscordGuild
internal class DiscordGuild : IGuild
{
[JsonProperty("id")] public ulong Id { get; set; }
[JsonPropertyName("id")] public ulong Id { get; set; }
[JsonProperty("name")] public string Name { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
[JsonProperty("icon")] public string? Icon { get; set; }
[JsonPropertyName("icon")] public string? IconHash { get; set; }
[JsonPropertyName("banner")] public string? BannerHash { get; set; }
[JsonProperty("owner")] public bool Owner { get; set; }
[JsonPropertyName("owner")] public bool IsOwner { get; set; }
[JsonProperty("permissions")] public string Permissions { get; set; }
[JsonPropertyName("permissions")] public string Permissions { get; set; }
[JsonProperty("features")] public GuildFeatures Features { get; set; }
[JsonPropertyName("features")] public IEnumerable<GuildFeature> Features { get; set; } = [];
[JsonPropertyName("approximate_member_count")] public int ApproximateMemberCount { get; set; }
public override string ToString()
[JsonPropertyName("approximate_presence_count")] public int ApproximatePresenceCount { get; set; }
public string GetIconUrl(int size = 128)
{
return
$"Id: {Id}; Name: {Name}; Icon: {Icon}; Owner: {Owner}; Permissions: {Permissions}; Features: {Features}";
if (string.IsNullOrEmpty(IconHash))
{
return string.Empty;
}
return $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png?size={size}";
}
public string GetBannerUrl(int size = 128)
{
if (string.IsNullOrEmpty(BannerHash))
{
return string.Empty;
}
return $"https://cdn.discordapp.com/banners/{Id}/{BannerHash}.png?size={size}";
}
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
using yawaflua.Discord.Net.Interfaces.Models;
namespace yawaflua.Discord.Net.Entities;
internal class DiscordGuildMember : IGuildMember
{
private DiscordUser User { get; set; }
public string? Nick { get; set; }
[JsonPropertyName("avatar")]
public string? AvatarHash { get; set; }
[JsonPropertyName("banner")]
public string? BannerHash { get; set; }
public List<IRole> Roles { get; set; }
public DateTime JoinedAt { get; set; }
public DateTime? PremiumSince { get; set; }
[JsonPropertyName("deaf")]
public bool IsDeaf { get; set; }
[JsonPropertyName("mute")]
public bool IsMute { get; set; }
public int Flags { get; set; }
[JsonPropertyName("pending")]
public bool IsPending { get; set; }
[JsonPropertyName("communication_disabled_until")]
public DateTime? CommunicationDisabledUntil { get; set; }
private AvatarDecoration? AvatarDecoration { get; set; }
IUser IGuildMember.User
{
get => User;
set => User = value as DiscordUser;
}
IAvatarDecoration IGuildMember.AvatarDecoration
{
get => AvatarDecoration;
set => AvatarDecoration = value as AvatarDecoration;
}
}

26
Entities/DiscordRole.cs Normal file
View File

@@ -0,0 +1,26 @@
using yawaflua.Discord.Net.Entities.Enums;
using yawaflua.Discord.Net.Interfaces.Models;
namespace yawaflua.Discord.Net.Entities;
internal class DiscordRole : IRole
{
public ulong Id { get; set; }
public string Name { get; set; }
public int Color { get; set; }
private RoleColor Colors { get; set; }
public bool IsHoisted { get; set; }
public bool IsManaged { get; set; }
public bool IsMentionable { get; set; }
public int Position { get; set; }
public ulong? Permissions { get; set; }
public string? IconHash { get; set; }
public string? UnicodeEmoji { get; set; }
public Dictionary<RoleTags, ulong?>? Tags { get; set; }
IRoleColor IRole.Colors
{
get => Colors;
set => Colors = (RoleColor)value;
}
}

103
Entities/DiscordSession.cs Normal file
View File

@@ -0,0 +1,103 @@
using System.Collections.Specialized;
using System.Net.Http.Headers;
using System.Text.Json;
using yawaflua.Discord.Net.Interfaces.Models;
namespace yawaflua.Discord.Net.Entities;
internal class DiscordSession (IToken token, HttpClient httpClient, ScopesBuilder scopes, ulong clientId, string clientSecret, string redirectUri, bool prompt) : ISession
{
private async Task<T?> _req<T>(string endpoint, HttpMethod? method = null) where T : class
{
using var request = new HttpRequestMessage(method ?? HttpMethod.Get, $"https://discord.com/api/{endpoint}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return null;
}
var responseString = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T?>(responseString) ?? null;
}
public async Task<IList<IGuild>?> GetGuildsAsync(CancellationToken cancellationToken = default)
{
if (token.AccessToken is null)
{
throw new ArgumentNullException(nameof(token), "Token cannot be null.");
}
return await _req<DiscordGuild[]>("users/@me/guilds");
}
public async Task<IGuildMember?> GetGuildMemberAsync(ulong guildId, CancellationToken cancellationToken = default)
{
if (token.AccessToken is null)
{
throw new ArgumentNullException(nameof(token), "Token cannot be null.");
}
return await _req<DiscordGuildMember>($"users/@me/guilds/{guildId}/member");
}
public async Task<IGuildMember?> AddMemberToGuildAsync(ulong guildId, ulong userId, CancellationToken cancellationToken = default)
{
if (token.AccessToken is null)
{
throw new ArgumentNullException(nameof(token), "Token cannot be null.");
}
return await _req<DiscordGuildMember>($"guilds/{guildId}/members/{userId}", HttpMethod.Put);
}
public async Task<IUser?> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
if (token.AccessToken is null)
{
throw new ArgumentNullException(nameof(token), "Token cannot be null.");
}
return await _req<DiscordUser>("users/@me");
}
public async Task<IConnection?> GetConnectionAsync(CancellationToken cancellationToken = default)
{
if (token.AccessToken is null)
{
throw new ArgumentNullException(nameof(token), "Token cannot be null.");
}
return await _req<DiscordConnection>("users/@me/connections");
}
public string GetAuthorizationUrl(string state)
{
NameValueCollection query = new()
{
["client_id"] = clientId.ToString(),
["redirect_uri"] = redirectUri,
["response_type"] = "code",
["scope"] = scopes.ToString(),
["state"] = state,
["prompt"] = prompt ? "consent" : "none"
};
var uriBuilder = new UriBuilder("https://discord.com/api/oauth2/authorize")
{
Query = query.ToString()
};
return uriBuilder.ToString();
}
public IToken GetToken(CancellationToken cancellationToken = default)
{
if (token.AccessToken is null)
{
throw new ArgumentNullException(nameof(token), "Token cannot be null.");
}
return token;
}
}

View File

@@ -1,38 +1,85 @@
using x3rt.DiscordOAuth2.Entities.Enums;
using System.Text.Json.Serialization;
using yawaflua.Discord.Net.Entities.Enums;
using yawaflua.Discord.Net.Interfaces.Models;
namespace x3rt.DiscordOAuth2.Entities;
namespace yawaflua.Discord.Net.Entities;
public class DiscordUser
internal class DiscordUser : IUser
{
[JsonPropertyName("id")]
public ulong Id { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; }
[JsonPropertyName("global_name")]
public string GlobalName { get; set; }
[JsonPropertyName("discriminator")]
public string Discriminator { get; set; }
public string? Avatar { get; set; }
[JsonPropertyName("avatar")]
public string? AvatarHash { get; set; }
[JsonPropertyName("bot")]
public bool? Bot { get; set; }
[JsonPropertyName("system")]
public bool? System { get; set; }
[JsonPropertyName("mfa_enabled")]
public bool? MfaEnabled { get; set; }
[JsonPropertyName("banner")]
public string? Banner { get; set; }
[JsonPropertyName("accent_color")]
public int? AccentColor { get; set; }
[JsonPropertyName("locale")]
public string? Locale { get; set; }
[JsonPropertyName("verified")]
public bool? Verified { get; set; }
[JsonPropertyName("email")]
public string? Email { get; set; }
[JsonPropertyName("flags")]
public UserFlag? Flags { get; set; }
[JsonPropertyName("premium_type")]
public PremiumType? PremiumType { get; set; }
[JsonPropertyName("public_flags")]
public UserFlag? PublicFlags { get; set; }
[JsonPropertyName("avatar_decoration")]
public AvatarDecoration? AvatarDecoration { get; set; }
public override string ToString()
IAvatarDecoration IUser.AvatarDecoration
{
string result = "";
foreach (var property in GetType().GetProperties())
get => AvatarDecoration;
set => AvatarDecoration = value as AvatarDecoration;
}
public string GetAvatarUrl(int size = 128)
{
if (string.IsNullOrEmpty(AvatarHash))
{
var value = property.GetValue(this);
if (value is not null)
{
result += $"{property.Name}: {value}; ";
}
return string.Empty;
}
result = result.TrimEnd(' ', ';');
return result;
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size={size}";
}
public string GetBannerUrl(int size = 128)
{
if (string.IsNullOrEmpty(AvatarHash))
{
return string.Empty;
}
return $"https://cdn.discordapp.com/banners/{Id}/{AvatarHash}.png?size={size}";
}
}

View File

@@ -0,0 +1,24 @@
namespace yawaflua.Discord.Net.Entities.Enums;
public enum ConnectionType
{
BattleNet,
Ebay,
EpicGames,
Facebook,
GitHub,
Instagram,
LeagueOfLegends,
PayPal,
PlayStation,
Reddit,
RiotGames,
Spotify,
Skype,
Steam,
TikTok,
Twitch,
Twitter,
Xbox,
YouTube
}

View File

@@ -0,0 +1,7 @@
namespace yawaflua.Discord.Net.Entities.Enums;
public enum ConnectionVisibility
{
None,
Everyone
}

View File

@@ -1,4 +1,4 @@
namespace x3rt.DiscordOAuth2.Entities.Enums;
namespace yawaflua.Discord.Net.Entities.Enums;
[Flags]
// Credit: Discord.Net

View File

@@ -1,4 +1,4 @@
namespace x3rt.DiscordOAuth2.Entities.Enums;
namespace yawaflua.Discord.Net.Entities.Enums;
/// <summary>
/// Represents the OAuth2 scopes available for a Discord application.

View File

@@ -1,4 +1,4 @@
namespace x3rt.DiscordOAuth2.Entities.Enums;
namespace yawaflua.Discord.Net.Entities.Enums;
public enum PremiumType
{

View File

@@ -0,0 +1,11 @@
namespace yawaflua.Discord.Net.Entities.Enums;
public enum RoleTags
{
bot_id,
integration_id,
premium_subscriber,
subscription_listing_id,
available_for_purchase,
guild_connections,
}

View File

@@ -1,4 +1,4 @@
namespace x3rt.DiscordOAuth2.Entities.Enums;
namespace yawaflua.Discord.Net.Entities.Enums;
[Flags]
public enum UserFlag : ulong

View File

@@ -1,13 +0,0 @@
using x3rt.DiscordOAuth2.Entities.Enums;
namespace x3rt.DiscordOAuth2.Entities;
public record GuildFeatures
{
IEnumerable<GuildFeature> Features { get; set; }
public override string ToString()
{
return string.Join(", ", Features);
}
}

View File

@@ -1,16 +1,69 @@
using Newtonsoft.Json;
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using yawaflua.Discord.Net.Interfaces.Models;
namespace x3rt.DiscordOAuth2.Entities;
namespace yawaflua.Discord.Net.Entities;
public class OAuthToken
internal class OAuthToken (HttpClient client, ulong ClientId, string ClientSecret) : IToken
{
[JsonProperty("access_token")] public string AccessToken { get; set; }
[JsonPropertyName("access_token")] public string AccessToken { get; set; }
[JsonProperty("expires_in")] public int ExpiresIn { get; set; }
[JsonPropertyName("expires_in")] public int ExpiresIn { get; set; }
[JsonProperty("refresh_token")] public string RefreshToken { get; set; }
[JsonPropertyName("refresh_token")] public string RefreshToken { get; set; }
[JsonProperty("scope")] public string Scope { get; set; }
[JsonPropertyName("scope")] public string Scope { get; set; }
[JsonProperty("token_type")] public string TokenType { get; set; }
[JsonPropertyName("token_type")] public string TokenType { get; set; }
public Task RevokeAsync(CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "https://discord.com/api/oauth2/token/revoke")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "token", AccessToken },
{ "client_id", ClientId.ToString() },
{ "client_secret", ClientSecret }
})
};
return client.SendAsync(request, cancellationToken)
.ContinueWith(task =>
{
if (!task.Result.IsSuccessStatusCode)
{
throw new Exception("Failed to revoke token.");
}
}, cancellationToken);
}
public async Task RefreshAsync(CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "https://discord.com/api/oauth2/token")
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "refresh_token", RefreshToken },
})
};
var byteArray = System.Text.Encoding.ASCII.GetBytes($"{ClientId}:{ClientSecret}");
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(byteArray));
var req = await client.SendAsync(request, cancellationToken);
if (!req.IsSuccessStatusCode)
{
throw new Exception("Failed to refresh token.");
}
var responseString = await req.Content.ReadAsStringAsync();
var newToken = JsonSerializer.Deserialize<OAuthToken>(responseString);
if (newToken is null)
{
throw new Exception("Failed to deserialize token.");
}
AccessToken = newToken.AccessToken;
ExpiresIn = newToken.ExpiresIn;
RefreshToken = newToken.RefreshToken;
Scope = newToken.Scope;
TokenType = newToken.TokenType;
}
}

10
Entities/RoleColor.cs Normal file
View File

@@ -0,0 +1,10 @@
using yawaflua.Discord.Net.Interfaces.Models;
namespace yawaflua.Discord.Net.Entities;
internal class RoleColor : IRoleColor
{
public int Primary { get; set; }
public int? Secondary { get; set; }
public int? Tertiary { get; set; }
}

9
Interfaces/IDiscord.cs Normal file
View File

@@ -0,0 +1,9 @@
using yawaflua.Discord.Net.Interfaces.Models;
namespace yawaflua.Discord.Net.Interfaces;
public interface IDiscord
{
Task<IToken?> GetTokenAsync(string code);
ISession? CreateSession();
}

View File

@@ -0,0 +1,7 @@
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IAvatarDecoration
{
public string AssetHash { get; set; }
public ulong AssetArticular { get; set; }
}

View File

@@ -0,0 +1,17 @@
using yawaflua.Discord.Net.Entities.Enums;
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IConnection
{
public string Id { get; set; }
public string Name { get; set; }
public ConnectionType Type { get; set; }
public bool? Revoked { get; set; }
public object[] Integrations { get; set; }
public bool? Verified { get; set; }
public bool? FriendSync { get; set; }
public bool? ShowActivity { get; set; }
public bool? TwoWayLink { get; set; }
public ConnectionVisibility? Visibility { get; set; }
}

View File

@@ -0,0 +1,19 @@
using yawaflua.Discord.Net.Entities.Enums;
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IGuild
{
public ulong Id { get; set; }
public string Name { get; set; }
public string? IconHash { get; set; }
public string? BannerHash { get; set; }
public bool IsOwner { get; set; }
public string Permissions { get; set; }
public IEnumerable<GuildFeature> Features { get; set; }
public int ApproximateMemberCount { get; set; }
public int ApproximatePresenceCount { get; set; }
public string GetIconUrl(int size = 128);
public string GetBannerUrl(int size = 128);
}

View File

@@ -0,0 +1,18 @@
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IGuildMember
{
public IUser User { get; set; }
public string? Nick { get; set; }
public string? AvatarHash { get; set; }
public string? BannerHash { get; set; }
public List<IRole> Roles { get; set; }
public DateTime JoinedAt { get; set; }
public DateTime? PremiumSince { get; set; }
public bool IsDeaf { get; set; }
public bool IsMute { get; set; }
public int Flags { get; set; }
public bool IsPending { get; set; }
public DateTime? CommunicationDisabledUntil { get; set; }
public IAvatarDecoration? AvatarDecoration { get; set; }
}

View File

@@ -0,0 +1,25 @@
using yawaflua.Discord.Net.Entities.Enums;
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IRole
{
public ulong Id { get; set; }
public string Name { get; set; }
[Obsolete("Deprecated integer representation of hexadecimal color code")]
public int Color { get; set; }
public IRoleColor Colors { get; set; }
public bool IsHoisted { get; set; }
public bool IsManaged { get; set; }
public bool IsMentionable { get; set; }
public int Position { get; set; }
public ulong? Permissions { get; set; }
public string? IconHash { get; set; }
public string? UnicodeEmoji { get; set; }
public Dictionary<RoleTags, ulong?>? Tags { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IRoleColor
{
public int Primary { get; set; }
public int? Secondary { get; set; }
public int? Tertiary { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface ISession
{
Task<IList<IGuild>?> GetGuildsAsync(CancellationToken cancellationToken = default);
Task<IGuildMember?> GetGuildMemberAsync(ulong guildId, CancellationToken cancellationToken = default);
Task<IGuildMember?> AddMemberToGuildAsync(ulong guildId, ulong userId, CancellationToken cancellationToken = default);
Task<IUser?> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<IConnection?> GetConnectionAsync(CancellationToken cancellationToken = default);
string GetAuthorizationUrl(string state);
IToken GetToken(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,14 @@
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IToken
{
public string? AccessToken { get; set; }
public int ExpiresIn { get; set; }
public string? RefreshToken { get; set; }
public string? Scope { get; set; }
public string? TokenType { get; set; }
Task RevokeAsync(CancellationToken cancellationToken = default);
Task RefreshAsync(CancellationToken cancellationToken = default);
}

115
Interfaces/Models/IUser.cs Normal file
View File

@@ -0,0 +1,115 @@
using yawaflua.Discord.Net.Entities.Enums;
namespace yawaflua.Discord.Net.Interfaces.Models;
public interface IUser
{
/// <summary>
/// the user's id
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public ulong Id { get; set; }
/// <summary>
/// the user's username, not unique across the platform
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public string Username { get; set; }
/// <summary>
/// the user's display name, if it is set. For bots, this is the application name
/// </summary>
public string GlobalName { get; set; }
/// <summary>
/// the user's Discord-tag (Obsolete)
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public string Discriminator { get; set; }
/// <summary>
/// the user's avatar hash
/// </summary>
/// <seealso href="https://discord.com/developers/docs/reference#image-formatting">Image formatting</seealso>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public string? AvatarHash { get; set; }
/// <summary>
/// the user's banner hash
/// </summary>
/// <seealso href="https://discord.com/developers/docs/reference#image-formatting">Image formatting</seealso>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
/// <remarks>Available in the User object only if the user has a banner set.</remarks>
/// <example>https://cdn.discordapp.com/banners/{user.id}/{banner}.png?size=512</example>
public string? Banner { get; set; }
/// <summary>
/// whether the user belongs to an OAuth2 application
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public bool? Bot { get; set; }
/// <summary>
/// whether the user is an Official Discord System user (part of the urgent message system)
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public bool? System { get; set; }
/// <summary>
/// whether the user has two factor enabled on their account
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public bool? MfaEnabled { get; set; }
/// <summary>
/// the user's banner color encoded as an integer representation of hexadecimal color code
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public int? AccentColor { get; set; }
/// <summary>
/// the user's chosen language option
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public string? Locale { get; set; }
/// <summary>
/// whether the email on this account has been verified
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public bool? Verified { get; set; }
/// <summary>
/// the user's email
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public string? Email { get; set; }
/// <summary>
/// the flags on a user's account
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object-user-flags">User flags</seealso>
public UserFlag? Flags { get; set; }
/// <summary>
/// the type of Nitro subscription on a user's account
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public PremiumType? PremiumType { get; set; }
/// <summary>
/// the public flags on a user's account
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public UserFlag? PublicFlags { get; set; }
/// <summary>
/// data for the user's avatar decoration
/// </summary>
/// <seealso href="https://discord.com/developers/docs/resources/user#user-object">User-object</seealso>
public IAvatarDecoration? AvatarDecoration { get; set; }
public string GetAvatarUrl(int size = 128);
public string GetBannerUrl(int size = 128);
}

28
LICENSE
View File

@@ -1,21 +1,13 @@
MIT License
Copyright 2025 Dmitrii Shimanskii
Copyright (c) 2023 x3rt
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
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:
http://www.apache.org/licenses/LICENSE-2.0
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.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,69 +0,0 @@
namespace x3rt.DiscordOAuth2.Options;
public class GuildOptions
{
public ulong GuildId { get; set; }
public string? Nickname { get; set; }
public IEnumerable<ulong>? RoleIds { get; set; }
public bool? Muted { get; set; }
public bool? Deafened { get; set; }
public GuildOptions(ulong guildId, string? nickname = null, IEnumerable<ulong>? roleIds = null, bool? mute = null, bool? deafened = null)
{
GuildId = guildId;
Nickname = nickname;
RoleIds = roleIds;
Muted = mute;
Deafened = deafened;
}
public GuildOptions Mute(bool mute = true)
{
Muted = mute;
return this;
}
public GuildOptions Deafen(bool deafen = true)
{
Deafened = deafen;
return this;
}
public GuildOptions WithNickname(string nickname)
{
Nickname = nickname;
return this;
}
public GuildOptions WithRoleIds(params ulong[] roleIds)
{
RoleIds = roleIds;
return this;
}
public GuildOptions WithRoleIds(IEnumerable<ulong> roleIds)
{
RoleIds = roleIds.ToArray();
return this;
}
public GuildOptions WithRoleId(ulong roleId)
{
if (RoleIds is null)
{
RoleIds = new[] { roleId };
}
else
{
RoleIds = RoleIds.Append(roleId);
}
return this;
}
public override string ToString()
{
return $"GuildId: {GuildId}; Nickname: {Nickname}; RoleIds: {RoleIds}";
}
}

View File

@@ -1,41 +1,64 @@
# x3rt.DiscordOAuth2
# yawaflua.Discord.Net
[![NuGet](https://img.shields.io/nuget/v/x3rt.DiscordOAuth2.svg)](https://www.nuget.org/packages/x3rt.DiscordOAuth2/)
[![NuGet](https://img.shields.io/nuget/dt/x3rt.DiscordOAuth2.svg)](https://www.nuget.org/packages/x3rt.DiscordOAuth2/)
[![NuGet](https://img.shields.io/nuget/v/yawaflua.Discord.Net.svg)](https://www.nuget.org/packages/yawaflua.Discord.Net/)
[![NuGet](https://img.shields.io/nuget/dt/yawaflua.Discord.Net.svg)](https://www.nuget.org/packages/yawaflua.Discord.Net/)
A **simple** library to handle Discord OAuth2 authentication.
Meant to serve as an alternative to
the [AspNet.Security.OAuth.Providers](https://github.com/aspnet-contrib/AspNet.Security.OAuth.Providers) library that
gives more control to the user and one that can be used by people that are trying to avoid ASP.NETs cluttered and bulky Identity system.
yawaflua.Discord.Net is a library for integrating Discord OAuth2 into your .NET applications. It provides a simple and easy-to-use interface for authenticating users with Discord, retrieving user information, and managing sessions.
## Features
- **OAuth2 Authentication**: Easily authenticate users with Discord using OAuth2.
- **User Information**: Retrieve user information such as username, avatar, and guilds.
- **Session Management**: Create and manage sessions for authenticated users.
- **Dependency Injection**: Seamlessly integrate with your .NET application's dependency injection system.
## Usage
```csharp
// Configure OAuth ClientID and ClientSecret globally
DiscordOAuth.Configure(0123456789, "ClientSecret", "OptionalBotToken");
```
```csharp
var scopes = new ScopesBuilder(OAuthScope.Identify);
var oAuth = new DiscordOAuth("https://example.com/Login", scopes);
var url = oAuth.GetAuthorizationUrl("state");
/* Redirect user to url via preferred method */
```
```csharp
// Your callback method
if (DiscordOAuth.TryGetCode(HttpContext, out var code))
// Configure configuration to DI
builder.Services.AddSingleton<DiscordConfig>(
new DiscordConfig
{
ClientId = "your-client-id",
ClientSecret = "shhhhh!",
RedirectUri = "https://example.com/Login",
Scopes = new ScopesBuilder(OAuthScope.Identify, OAuthScope.Guilds),
Token = "MyVeryCool.BotToken.For.Discord" // Optional, for bot interactions
});
builder.Services.AddSingleton<IDiscord, DiscordOAuth>();
// And use it in you very cool controller or smth
public class MyController (IDiscord discord) : ControllerBase
{
var scopes = new ScopesBuilder(OAuthScope.Identify);
var oAuth = new DiscordOAuth("https://example.com/Login", scopes);
var token = await oAuth.GetTokenAsync(code);
var user = await oAuth.GetUserAsync(token);
ulong userId = user.Id;
// ...
public async Task<IActionResult> Login()
{
var url = discord.GetAuthorizationUrl("state");
return Redirect(url);
}
public async Task<IActionResult> Callback()
{
if (discord.TryGetCode(HttpContext, out var code))
{
var token = await discord.GetTokenAsync(code);
var user = await discord.GetUserAsync(token);
var session = discord.CreateSession();
var guilds = await session.GetGuildsAsync();
// Do something with the user and guilds, like storing them in a database or session
// ...
return Ok();
}
return BadRequest("Invalid code");
}
}
```
## Feedback
DiscordOAuth2 is a work in progress and any feedback or suggestions are welcome.
This library is released under the [MIT License](LICENSE).
`yawaflua.Discord.Net` is a library still in development, and your feedback or support is very welcome. If you have any issues, suggestions, or questions, please open an issue on the [GitHub repository](https://github.com/yawaflua/Discord.Net/)
This library is released under the [Apache 2.0](LICENSE).

View File

@@ -1,6 +1,6 @@
using x3rt.DiscordOAuth2.Entities.Enums;
using yawaflua.Discord.Net.Entities.Enums;
namespace x3rt.DiscordOAuth2;
namespace yawaflua.Discord.Net;
public class ScopesBuilder
{

View File

@@ -1,28 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Title>Discord OAuth2</Title>
<Authors>x3rt</Authors>
<Authors>yawaflua</Authors>
<Description>Discord OAuth2 implementation for C#</Description>
<AssemblyName>x3rt.DiscordOAuth2</AssemblyName>
<RootNamespace>x3rt.DiscordOAuth2</RootNamespace>
<AssemblyName>yawaflua.Discord.Net</AssemblyName>
<RootNamespace>yawaflua.Discord.Net</RootNamespace>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageProjectUrl>httpsL//github.com/yawaflua/Discord.Net</PackageProjectUrl>
<PUblishRepositoryUrl>true</PUblishRepositoryUrl>
<RepositoryUrl>https://github.com/x3rt/x3rt.DiscordOAuth2</RepositoryUrl>
<RepositoryUrl>https://github.com/yawaflua/Discord.Net</RepositoryUrl>
<RepositoryType>GIT</RepositoryType>
<PackageTags>Discord-OAuth2;Discord-OAuth-2;Discord-OAuth;DiscordOAuth;Discord;OAuth;OAuth-2;OAuth2</PackageTags>
<PackageTags>Discord-OAuth2;Discord-OAuth-2;Discord-OAuth;DiscordOAuth;Discord;yawaflua;OAuth;OAuth-2;OAuth2</PackageTags>
<Deterministic>true</Deterministic>
<Version>1.0.4</Version>
<Version>1.0.5</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
</ItemGroup>
<ItemGroup>

View File

@@ -1,5 +1,5 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "x3rt.DiscordOAuth2", "x3rt.DiscordOAuth2.csproj", "{09C8F24A-5CC8-42E3-9D86-3DD68D6642E0}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "yawaflua.Discord.Net", "yawaflua.Discord.Net.csproj", "{09C8F24A-5CC8-42E3-9D86-3DD68D6642E0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution