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

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; }
}