Embrace Snowflake as first class citizen

This commit is contained in:
Tyrrrz
2020-12-27 19:41:28 +02:00
parent 4ff7990967
commit 3d9ee3b339
36 changed files with 243 additions and 195 deletions

View File

@@ -8,7 +8,7 @@ using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Http;
using JsonExtensions.Reading;
@@ -70,13 +70,14 @@ namespace DiscordChatExporter.Domain.Discord
{
yield return Guild.DirectMessages;
var afterId = "";
var currentAfter = Snowflake.Zero;
while (true)
{
var url = new UrlBuilder()
.SetPath("users/@me/guilds")
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", afterId)
.SetQueryParameter("after", currentAfter.ToString())
.Build();
var response = await GetJsonResponseAsync(url);
@@ -86,7 +87,7 @@ namespace DiscordChatExporter.Domain.Discord
{
yield return guild;
afterId = guild.Id;
currentAfter = guild.Id;
isEmpty = false;
}
@@ -95,7 +96,7 @@ namespace DiscordChatExporter.Domain.Discord
}
}
public async ValueTask<Guild> GetGuildAsync(string guildId)
public async ValueTask<Guild> GetGuildAsync(Snowflake guildId)
{
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
@@ -104,7 +105,7 @@ namespace DiscordChatExporter.Domain.Discord
return Guild.Parse(response);
}
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(string guildId)
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(Snowflake guildId)
{
if (guildId == Guild.DirectMessages.Id)
{
@@ -141,7 +142,7 @@ namespace DiscordChatExporter.Domain.Discord
}
}
public async IAsyncEnumerable<Role> GetGuildRolesAsync(string guildId)
public async IAsyncEnumerable<Role> GetGuildRolesAsync(Snowflake guildId)
{
if (guildId == Guild.DirectMessages.Id)
yield break;
@@ -152,7 +153,7 @@ namespace DiscordChatExporter.Domain.Discord
yield return Role.Parse(roleJson);
}
public async ValueTask<Member?> TryGetGuildMemberAsync(string guildId, User user)
public async ValueTask<Member?> TryGetGuildMemberAsync(Snowflake guildId, User user)
{
if (guildId == Guild.DirectMessages.Id)
return Member.CreateForUser(user);
@@ -161,30 +162,31 @@ namespace DiscordChatExporter.Domain.Discord
return response?.Pipe(Member.Parse);
}
private async ValueTask<string> GetChannelCategoryAsync(string channelParentId)
private async ValueTask<string> GetChannelCategoryAsync(Snowflake channelParentId)
{
var response = await GetJsonResponseAsync($"channels/{channelParentId}");
return response.GetProperty("name").GetString();
}
public async ValueTask<Channel> GetChannelAsync(string channelId)
public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
{
var response = await GetJsonResponseAsync($"channels/{channelId}");
var parentId = response.GetPropertyOrNull("parent_id")?.GetString();
var category = !string.IsNullOrWhiteSpace(parentId)
? await GetChannelCategoryAsync(parentId)
var parentId = response.GetPropertyOrNull("parent_id")?.GetString().Pipe(Snowflake.Parse);
var category = parentId != null
? await GetChannelCategoryAsync(parentId.Value)
: null;
return Channel.Parse(response, category);
}
private async ValueTask<Message?> TryGetLastMessageAsync(string channelId, DateTimeOffset? before = null)
private async ValueTask<Message?> TryGetLastMessageAsync(Snowflake channelId, Snowflake? before = null)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "1")
.SetQueryParameter("before", before?.ToSnowflake())
.SetQueryParameter("before", before?.ToString())
.Build();
var response = await GetJsonResponseAsync(url);
@@ -192,9 +194,9 @@ namespace DiscordChatExporter.Domain.Discord
}
public async IAsyncEnumerable<Message> GetMessagesAsync(
string channelId,
DateTimeOffset? after = null,
DateTimeOffset? before = null,
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<double>? progress = null)
{
// Get the last message in the specified range.
@@ -202,19 +204,19 @@ namespace DiscordChatExporter.Domain.Discord
// will not appear in the output.
// Additionally, it provides the date of the last message, which is used to calculate progress.
var lastMessage = await TryGetLastMessageAsync(channelId, before);
if (lastMessage == null || lastMessage.Timestamp < after)
if (lastMessage == null || lastMessage.Timestamp < after?.ToDate())
yield break;
// Keep track of first message in range in order to calculate progress
var firstMessage = default(Message);
var afterId = after?.ToSnowflake() ?? "0";
var currentAfter = after ?? Snowflake.Zero;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", afterId)
.SetQueryParameter("after", currentAfter.ToString())
.Build();
var response = await GetJsonResponseAsync(url);
@@ -244,7 +246,7 @@ namespace DiscordChatExporter.Domain.Discord
);
yield return message;
afterId = message.Id;
currentAfter = message.Id;
}
}
}

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
@@ -11,7 +11,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/channel#attachment-object
public partial class Attachment : IHasId
{
public string Id { get; }
public Snowflake Id { get; }
public string Url { get; }
@@ -32,7 +32,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public FileSize FileSize { get; }
public Attachment(string id, string url, string fileName, int? width, int? height, FileSize fileSize)
public Attachment(Snowflake id, string url, string fileName, int? width, int? height, FileSize fileSize)
{
Id = id;
Url = url;
@@ -58,7 +58,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public static Attachment Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var url = json.GetProperty("url").GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32();

View File

@@ -1,6 +1,7 @@
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
using Tyrrrz.Extensions;
@@ -22,7 +23,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/channel#channel-object
public partial class Channel : IHasId
{
public string Id { get; }
public Snowflake Id { get; }
public ChannelType Type { get; }
@@ -33,7 +34,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
Type == ChannelType.GuildNews ||
Type == ChannelType.GuildStore;
public string GuildId { get; }
public Snowflake GuildId { get; }
public string Category { get; }
@@ -41,7 +42,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public string? Topic { get; }
public Channel(string id, ChannelType type, string guildId, string category, string name, string? topic)
public Channel(Snowflake id, ChannelType type, Snowflake guildId, string category, string name, string? topic)
{
Id = id;
Type = type;
@@ -68,8 +69,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
public static Channel Parse(JsonElement json, string? category = null)
{
var id = json.GetProperty("id").GetString();
var guildId = json.GetPropertyOrNull("guild_id")?.GetString();
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse);
var topic = json.GetPropertyOrNull("topic")?.GetString();
var type = (ChannelType) json.GetProperty("type").GetInt32();
@@ -77,7 +78,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
var name =
json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
id;
id.ToString();
return new Channel(
id,

View File

@@ -2,6 +2,6 @@
{
public interface IHasId
{
string Id { get; }
Snowflake Id { get; }
}
}

View File

@@ -5,9 +5,9 @@ namespace DiscordChatExporter.Domain.Discord.Models.Common
{
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
{
public bool Equals(IHasId? x, IHasId? y) => StringComparer.Ordinal.Equals(x?.Id, y?.Id);
public bool Equals(IHasId? x, IHasId? y) => x?.Id == y?.Id;
public int GetHashCode(IHasId obj) => StringComparer.Ordinal.GetHashCode(obj.Id);
public int GetHashCode(IHasId obj) => obj.Id.GetHashCode();
}
public partial class IdBasedEqualityComparer

View File

@@ -4,6 +4,7 @@ using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models

View File

@@ -1,18 +1,19 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discord.com/developers/docs/resources/guild#guild-object
public partial class Guild : IHasId
{
public string Id { get; }
public Snowflake Id { get; }
public string Name { get; }
public string IconUrl { get; }
public Guild(string id, string name, string iconUrl)
public Guild(Snowflake id, string name, string iconUrl)
{
Id = id;
Name = name;
@@ -24,17 +25,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Guild
{
public static Guild DirectMessages { get; } = new("@me", "Direct Messages", GetDefaultIconUrl());
public static Guild DirectMessages { get; } = new(Snowflake.Zero, "Direct Messages", GetDefaultIconUrl());
private static string GetDefaultIconUrl() =>
"https://cdn.discordapp.com/embed/avatars/0.png";
private static string GetIconUrl(string id, string iconHash) =>
private static string GetIconUrl(Snowflake id, string iconHash) =>
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
public static Guild Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetString();
var iconHash = json.GetProperty("icon").GetString();

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
@@ -11,15 +11,15 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/guild#guild-member-object
public partial class Member : IHasId
{
public string Id => User.Id;
public Snowflake Id => User.Id;
public User User { get; }
public string Nick { get; }
public IReadOnlyList<string> RoleIds { get; }
public IReadOnlyList<Snowflake> RoleIds { get; }
public Member(User user, string? nick, IReadOnlyList<string> roleIds)
public Member(User user, string? nick, IReadOnlyList<Snowflake> roleIds)
{
User = user;
Nick = nick ?? user.Name;
@@ -31,7 +31,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Member
{
public static Member CreateForUser(User user) => new(user, null, Array.Empty<string>());
public static Member CreateForUser(User user) => new(user, null, Array.Empty<Snowflake>());
public static Member Parse(JsonElement json)
{
@@ -39,8 +39,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
var nick = json.GetPropertyOrNull("nick")?.GetString();
var roleIds =
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ??
Array.Empty<string>();
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString().Pipe(Snowflake.Parse)).ToArray() ??
Array.Empty<Snowflake>();
return new Member(
user,

View File

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
@@ -24,7 +24,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/channel#message-object
public partial class Message : IHasId
{
public string Id { get; }
public Snowflake Id { get; }
public MessageType Type { get; }
@@ -49,7 +49,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public IReadOnlyList<User> MentionedUsers { get; }
public Message(
string id,
Snowflake id,
MessageType type,
User author,
DateTimeOffset timestamp,
@@ -83,7 +83,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
{
public static Message Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var author = json.GetProperty("author").Pipe(User.Parse);
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();

View File

@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Discord.Models
{

View File

@@ -1,14 +1,16 @@
using System.Drawing;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discord.com/developers/docs/topics/permissions#role-object
public partial class Role
public partial class Role : IHasId
{
public string Id { get; }
public Snowflake Id { get; }
public string Name { get; }
@@ -16,7 +18,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public Color? Color { get; }
public Role(string id, string name, int position, Color? color)
public Role(Snowflake id, string name, int position, Color? color)
{
Id = id;
Name = name;
@@ -31,7 +33,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
{
public static Role Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetString();
var position = json.GetProperty("position").GetInt32();

View File

@@ -1,7 +1,7 @@
using System;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Utilities;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Domain.Discord.Models
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
// https://discord.com/developers/docs/resources/user#user-object
public partial class User : IHasId
{
public string Id { get; }
public Snowflake Id { get; }
public bool IsBot { get; }
@@ -21,7 +21,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public string AvatarUrl { get; }
public User(string id, bool isBot, int discriminator, string name, string avatarUrl)
public User(Snowflake id, bool isBot, int discriminator, string name, string avatarUrl)
{
Id = id;
IsBot = isBot;
@@ -38,7 +38,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
private static string GetDefaultAvatarUrl(int discriminator) =>
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
private static string GetAvatarUrl(string id, string avatarHash)
private static string GetAvatarUrl(Snowflake id, string avatarHash)
{
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
@@ -50,7 +50,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
public static User Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
var name = json.GetProperty("username").GetString();
var avatarHash = json.GetProperty("avatar").GetString();

View File

@@ -0,0 +1,68 @@
using System;
using System.Globalization;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Domain.Discord
{
public readonly partial struct Snowflake
{
public ulong Value { get; }
public Snowflake(ulong value) => Value = value;
public DateTimeOffset ToDate() =>
DateTimeOffset.FromUnixTimeMilliseconds((long) ((Value >> 22) + 1420070400000UL)).ToLocalTime();
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
}
public partial struct Snowflake
{
public static Snowflake Zero { get; } = new(0);
public static Snowflake FromDate(DateTimeOffset date)
{
var value = ((ulong) date.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
return new Snowflake(value);
}
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
{
if (string.IsNullOrWhiteSpace(str))
return null;
// As number
if (Regex.IsMatch(str, @"^\d{15,}$") &&
ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value))
{
return new Snowflake(value);
}
// As date
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var date))
{
return FromDate(date);
}
return null;
}
public static Snowflake Parse(string str, IFormatProvider? formatProvider) =>
TryParse(str, formatProvider) ?? throw new FormatException($"Invalid snowflake: {str}.");
public static Snowflake Parse(string str) => Parse(str, null);
}
public partial struct Snowflake : IEquatable<Snowflake>
{
public bool Equals(Snowflake other) => Value == other.Value;
public override bool Equals(object? obj) => obj is Snowflake other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public static bool operator ==(Snowflake left, Snowflake right) => left.Equals(right);
public static bool operator !=(Snowflake left, Snowflake right) => !(left == right);
}
}