Rework architecture

This commit is contained in:
Alexey Golub
2020-04-21 21:30:42 +03:00
parent 130c0b6fe2
commit 8685a3d7e3
119 changed files with 1520 additions and 1560 deletions

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace DiscordChatExporter.Domain.Discord
{
public static class AccessibilityExtensions
{
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable)
{
var list = new List<T>();
await foreach (var i in asyncEnumerable)
list.Add(i);
return list;
}
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) =>
asyncEnumerable.AggregateAsync().GetAwaiter();
}
}

View File

@@ -0,0 +1,29 @@
using System.Net.Http.Headers;
namespace DiscordChatExporter.Domain.Discord
{
public enum AuthTokenType
{
User,
Bot
}
public class AuthToken
{
public AuthTokenType Type { get; }
public string Value { get; }
public AuthToken(AuthTokenType type, string value)
{
Type = type;
Value = value;
}
public AuthenticationHeaderValue GetAuthenticationHeader() => Type == AuthTokenType.User
? new AuthenticationHeaderValue(Value)
: new AuthenticationHeaderValue("Bot", Value);
public override string ToString() => Value;
}
}

View File

@@ -0,0 +1,198 @@
using System;
using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Internal;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord
{
public partial class DiscordClient
{
private string ParseId(JsonElement json) =>
json.GetProperty("id").GetString();
private User ParseUser(JsonElement json)
{
var id = ParseId(json);
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
var name = json.GetProperty("username").GetString();
var avatarHash = json.GetProperty("avatar").GetString();
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
return new User(id, discriminator, name, avatarHash, isBot);
}
private Member ParseMember(JsonElement json)
{
var userId = json.GetProperty("user").Pipe(ParseId);
var nick = json.GetPropertyOrNull("nick")?.GetString();
var roles = json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ??
Array.Empty<string>();
return new Member(userId, nick, roles);
}
private Guild ParseGuild(JsonElement json)
{
var id = ParseId(json);
var name = json.GetProperty("name").GetString();
var iconHash = json.GetProperty("icon").GetString();
var roles = json.GetPropertyOrNull("roles")?.EnumerateArray().Select(ParseRole).ToArray() ??
Array.Empty<Role>();
return new Guild(id, name, iconHash, roles);
}
private Channel ParseChannel(JsonElement json)
{
var id = ParseId(json);
var parentId = json.GetPropertyOrNull("parent_id")?.GetString();
var type = (ChannelType) json.GetProperty("type").GetInt32();
var topic = json.GetPropertyOrNull("topic")?.GetString();
var guildId = json.GetPropertyOrNull("guild_id")?.GetString() ??
Guild.DirectMessages.Id;
var name =
json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(ParseUser).Select(u => u.Name).JoinToString(", ") ??
id;
return new Channel(id, guildId, parentId, type, name, topic);
}
private Role ParseRole(JsonElement json)
{
var id = ParseId(json);
var name = json.GetProperty("name").GetString();
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(Color.FromArgb).ResetAlpha().NullIf(c => c.ToRgb() <= 0);
var position = json.GetProperty("position").GetInt32();
return new Role(id, name, color, position);
}
private Attachment ParseAttachment(JsonElement json)
{
var id = ParseId(json);
var url = json.GetProperty("url").GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32();
var fileName = json.GetProperty("filename").GetString();
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
return new Attachment(id, url, fileName, width, height, fileSize);
}
private EmbedAuthor ParseEmbedAuthor(JsonElement json)
{
var name = json.GetPropertyOrNull("name")?.GetString();
var url = json.GetPropertyOrNull("url")?.GetString();
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
return new EmbedAuthor(name, url, iconUrl);
}
private EmbedField ParseEmbedField(JsonElement json)
{
var name = json.GetProperty("name").GetString();
var value = json.GetProperty("value").GetString();
var isInline = json.GetPropertyOrNull("inline")?.GetBoolean() ?? false;
return new EmbedField(name, value, isInline);
}
private EmbedImage ParseEmbedImage(JsonElement json)
{
var url = json.GetPropertyOrNull("url")?.GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32();
return new EmbedImage(url, width, height);
}
private EmbedFooter ParseEmbedFooter(JsonElement json)
{
var text = json.GetProperty("text").GetString();
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
return new EmbedFooter(text, iconUrl);
}
private Embed ParseEmbed(JsonElement json)
{
var title = json.GetPropertyOrNull("title")?.GetString();
var description = json.GetPropertyOrNull("description")?.GetString();
var url = json.GetPropertyOrNull("url")?.GetString();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(Color.FromArgb).ResetAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(ParseEmbedAuthor);
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(ParseEmbedImage);
var image = json.GetPropertyOrNull("image")?.Pipe(ParseEmbedImage);
var footer = json.GetPropertyOrNull("footer")?.Pipe(ParseEmbedFooter);
var fields = json.GetPropertyOrNull("fields")?.EnumerateArray().Select(ParseEmbedField).ToArray() ??
Array.Empty<EmbedField>();
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
}
private Emoji ParseEmoji(JsonElement json)
{
var id = json.GetPropertyOrNull("id")?.GetString();
var name = json.GetProperty("name").GetString();
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
return new Emoji(id, name, isAnimated);
}
private Reaction ParseReaction(JsonElement json)
{
var count = json.GetProperty("count").GetInt32();
var emoji = json.GetProperty("emoji").Pipe(ParseEmoji);
return new Reaction(emoji, count);
}
private Message ParseMessage(JsonElement json)
{
var id = ParseId(json);
var channelId = json.GetProperty("channel_id").GetString();
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
var type = (MessageType) json.GetProperty("type").GetInt32();
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
var content = type switch
{
MessageType.RecipientAdd => "Added a recipient.",
MessageType.RecipientRemove => "Removed a recipient.",
MessageType.Call => "Started a call.",
MessageType.ChannelNameChange => "Changed the channel name.",
MessageType.ChannelIconChange => "Changed the channel icon.",
MessageType.ChannelPinnedMessage => "Pinned a message.",
MessageType.GuildMemberJoin => "Joined the server.",
_ => json.GetPropertyOrNull("content")?.GetString() ?? ""
};
var author = json.GetProperty("author").Pipe(ParseUser);
var attachments = json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(ParseAttachment).ToArray() ??
Array.Empty<Attachment>();
var embeds = json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(ParseEmbed).ToArray() ??
Array.Empty<Embed>();
var reactions = json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(ParseReaction).ToArray() ??
Array.Empty<Reaction>();
var mentionedUsers = json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(ParseUser).ToArray() ??
Array.Empty<User>();
return new Message(id, channelId, type, author, timestamp, editedTimestamp, isPinned, content, attachments, embeds,
reactions, mentionedUsers);
}
}
}

View File

@@ -0,0 +1,249 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal;
using Polly;
namespace DiscordChatExporter.Domain.Discord
{
public partial class DiscordClient
{
private readonly AuthToken _token;
private readonly HttpClient _httpClient;
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
public DiscordClient(AuthToken token, HttpClient httpClient)
{
_token = token;
_httpClient = httpClient;
// Discord seems to always respond 429 on our first request with unreasonable wait time (10+ minutes).
// For that reason the policy will start respecting their retry-after header only after Nth failed response.
_httpRequestPolicy = Policy
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
.WaitAndRetryAsync(6,
(i, result, ctx) =>
{
if (i <= 3)
return TimeSpan.FromSeconds(2 * i);
if (i <= 5)
return TimeSpan.FromSeconds(5 * i);
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
},
(response, timespan, retryCount, context) => Task.CompletedTask);
}
public DiscordClient(AuthToken token)
: this(token, LazyHttpClient.Value)
{
}
private async Task<JsonElement> GetApiResponseAsync(string url)
{
using var response = await _httpRequestPolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = _token.GetAuthenticationHeader();
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
});
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw DiscordChatExporterException.Unauthorized();
if ((int) response.StatusCode >= 400)
throw DiscordChatExporterException.FailedHttpRequest(response);
return await response.Content.ReadAsJsonAsync();
}
// TODO: do we need this?
private async Task<JsonElement?> TryGetApiResponseAsync(string url)
{
try
{
return await GetApiResponseAsync(url);
}
catch (DiscordChatExporterException)
{
return null;
}
}
public async Task<Guild> GetGuildAsync(string guildId)
{
// Special case for direct messages pseudo-guild
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
var response = await GetApiResponseAsync($"guilds/{guildId}");
var guild = ParseGuild(response);
return guild;
}
public async Task<Member?> GetGuildMemberAsync(string guildId, string userId)
{
var response = await TryGetApiResponseAsync($"guilds/{guildId}/members/{userId}");
return response?.Pipe(ParseMember);
}
public async Task<Channel> GetChannelAsync(string channelId)
{
var response = await GetApiResponseAsync($"channels/{channelId}");
var channel = ParseChannel(response);
return channel;
}
public async IAsyncEnumerable<Guild> GetUserGuildsAsync()
{
var afterId = "";
while (true)
{
var route = "users/@me/guilds?limit=100";
if (!string.IsNullOrWhiteSpace(afterId))
route += $"&after={afterId}";
var response = await GetApiResponseAsync(route);
var isEmpty = true;
// Get full guild object
foreach (var guildJson in response.EnumerateArray())
{
var guildId = ParseId(guildJson);
yield return await GetGuildAsync(guildId);
afterId = guildId;
isEmpty = false;
}
if (isEmpty)
yield break;
}
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync()
{
var response = await GetApiResponseAsync("users/@me/channels");
var channels = response.EnumerateArray().Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string guildId)
{
// Special case for direct messages pseudo-guild
if (guildId == Guild.DirectMessages.Id)
return Array.Empty<Channel>();
var response = await GetApiResponseAsync($"guilds/{guildId}/channels");
var channels = response.EnumerateArray().Select(ParseChannel).ToArray();
return channels;
}
private async Task<Message> GetLastMessageAsync(string channelId, DateTimeOffset? before = null)
{
var route = $"channels/{channelId}/messages?limit=1";
if (before != null)
route += $"&before={before.Value.ToSnowflake()}";
var response = await GetApiResponseAsync(route);
return response.EnumerateArray().Select(ParseMessage).FirstOrDefault();
}
public async IAsyncEnumerable<Message> GetMessagesAsync(string channelId,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double>? progress = null)
{
// Get the last message
var lastMessage = await GetLastMessageAsync(channelId, before);
// If the last message doesn't exist or it's outside of range - return
if (lastMessage == null || lastMessage.Timestamp < after)
{
progress?.Report(1);
yield break;
}
// Get other messages
var firstMessage = default(Message);
var afterId = after?.ToSnowflake() ?? "0";
while (true)
{
// Get message batch
var route = $"channels/{channelId}/messages?limit=100&after={afterId}";
var response = await GetApiResponseAsync(route);
// Parse
var messages = response
.EnumerateArray()
.Select(ParseMessage)
.Reverse() // reverse because messages appear newest first
.ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
break;
// Trim messages to range (until last message)
var messagesInRange = messages
.TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp)
.ToArray();
// Yield messages
foreach (var message in messagesInRange)
{
// Set first message if it's not set
firstMessage ??= message;
// Report progress (based on the time range of parsed messages compared to total)
progress?.Report((message.Timestamp - firstMessage.Timestamp).TotalSeconds /
(lastMessage.Timestamp - firstMessage.Timestamp).TotalSeconds);
yield return message;
afterId = message.Id;
}
// Break if messages were trimmed (which means the last message was encountered)
if (messagesInRange.Length != messages.Length)
break;
}
// Yield last message
yield return lastMessage;
progress?.Report(1);
}
}
public partial class DiscordClient
{
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
{
var handler = new HttpClientHandler();
if (handler.SupportsAutomaticDecompression)
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
handler.UseCookies = false;
return new HttpClient(handler, true)
{
BaseAddress = new Uri("https://discordapp.com/api/v6")
};
});
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.IO;
using System.Linq;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#attachment-object
public partial class Attachment : IHasId
{
public string Id { get; }
public string Url { get; }
public string FileName { get; }
public int? Width { get; }
public int? Height { get; }
public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
public bool IsSpoiler => IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
public FileSize FileSize { get; }
public Attachment(string id, string url, string fileName, int? width, int? height, FileSize fileSize)
{
Id = id;
Url = url;
FileName = fileName;
Width = width;
Height = height;
FileSize = fileSize;
}
public override string ToString() => FileName;
}
public partial class Attachment
{
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
}
}

View File

@@ -0,0 +1,58 @@
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
// Order of enum fields needs to match the order in the docs.
public enum ChannelType
{
GuildTextChat,
DirectTextChat,
GuildVoiceChat,
DirectGroupTextChat,
GuildCategory,
GuildNews,
GuildStore
}
// https://discordapp.com/developers/docs/resources/channel#channel-object
public partial class Channel : IHasId
{
public string Id { get; }
public string GuildId { get; }
public string? ParentId { get; }
public ChannelType Type { get; }
public bool IsTextChannel =>
Type == ChannelType.GuildTextChat ||
Type == ChannelType.DirectTextChat ||
Type == ChannelType.DirectGroupTextChat ||
Type == ChannelType.GuildNews ||
Type == ChannelType.GuildStore;
public string Name { get; }
public string? Topic { get; }
public Channel(string id, string guildId, string? parentId, ChannelType type, string name, string? topic)
{
Id = id;
GuildId = guildId;
ParentId = parentId;
Type = type;
Name = name;
Topic = topic;
}
public override string ToString() => Name;
}
public partial class Channel
{
public static Channel CreateDeletedChannel(string id) =>
new Channel(id, "unknown-guild", null, ChannelType.GuildTextChat, "deleted-channel", null);
}
}

View File

@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Drawing;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object
public class Embed
{
public string? Title { get; }
public string? Url { get; }
public DateTimeOffset? Timestamp { get; }
public Color? Color { get; }
public EmbedAuthor? Author { get; }
public string? Description { get; }
public IReadOnlyList<EmbedField> Fields { get; }
public EmbedImage? Thumbnail { get; }
public EmbedImage? Image { get; }
public EmbedFooter? Footer { get; }
public Embed(
string? title,
string? url,
DateTimeOffset? timestamp,
Color? color,
EmbedAuthor? author,
string? description,
IReadOnlyList<EmbedField> fields,
EmbedImage? thumbnail,
EmbedImage? image,
EmbedFooter? footer)
{
Title = title;
Url = url;
Timestamp = timestamp;
Color = color;
Author = author;
Description = description;
Fields = fields;
Thumbnail = thumbnail;
Image = image;
Footer = footer;
}
public override string ToString() => Title ?? "<untitled embed>";
}
}

View File

@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
public class EmbedAuthor
{
public string? Name { get; }
public string? Url { get; }
public string? IconUrl { get; }
public EmbedAuthor(string? name, string? url, string? iconUrl)
{
Name = name;
Url = url;
IconUrl = iconUrl;
}
public override string ToString() => Name ?? "<unnamed author>";
}
}

View File

@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
public class EmbedField
{
public string Name { get; }
public string Value { get; }
public bool IsInline { get; }
public EmbedField(string name, string value, bool isInline)
{
Name = name;
Value = value;
IsInline = isInline;
}
public override string ToString() => $"{Name} | {Value}";
}
}

View File

@@ -0,0 +1,19 @@
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
public class EmbedFooter
{
public string Text { get; }
public string? IconUrl { get; }
public EmbedFooter(string text, string? iconUrl)
{
Text = text;
IconUrl = iconUrl;
}
public override string ToString() => Text;
}
}

View File

@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
public class EmbedImage
{
public string? Url { get; }
public int? Width { get; }
public int? Height { get; }
public EmbedImage(string? url, int? width, int? height)
{
Url = url;
Height = height;
Width = width;
}
}
}

View File

@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji
{
public string? Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public string ImageUrl { get; }
public Emoji(string? id, string name, bool isAnimated)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
ImageUrl = GetImageUrl(id, name, isAnimated);
}
public override string ToString() => Name;
}
public partial class Emoji
{
private static IEnumerable<Rune> GetRunes(string emoji)
{
var lastIndex = 0;
while (lastIndex < emoji.Length && Rune.TryGetRuneAt(emoji, lastIndex, out var rune))
{
// Skip variant selector rune
if (rune.Value != 0xfe0f)
yield return rune;
lastIndex += rune.Utf16SequenceLength;
}
}
private static string GetTwemojiName(IEnumerable<Rune> runes) =>
runes.Select(r => r.Value.ToString("x")).JoinToString("-");
public static string GetImageUrl(string? id, string name, bool isAnimated)
{
// Custom emoji
if (!string.IsNullOrWhiteSpace(id))
{
// Animated
if (isAnimated)
return $"https://cdn.discordapp.com/emojis/{id}.gif";
// Non-animated
return $"https://cdn.discordapp.com/emojis/{id}.png";
}
// Get runes
var emojiRunes = GetRunes(name).ToArray();
// Get corresponding Twemoji image
var twemojiName = GetTwemojiName(emojiRunes);
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
}
}
}

View File

@@ -0,0 +1,66 @@
using System;
namespace DiscordChatExporter.Domain.Discord.Models
{
// Loosely based on https://github.com/omar/ByteSize (MIT license)
public readonly partial struct FileSize
{
public long TotalBytes { get; }
public double TotalKiloBytes => TotalBytes / 1024.0;
public double TotalMegaBytes => TotalKiloBytes / 1024.0;
public double TotalGigaBytes => TotalMegaBytes / 1024.0;
public double TotalTeraBytes => TotalGigaBytes / 1024.0;
public double TotalPetaBytes => TotalTeraBytes / 1024.0;
public FileSize(long bytes) => TotalBytes = bytes;
private double GetLargestWholeNumberValue()
{
if (Math.Abs(TotalPetaBytes) >= 1)
return TotalPetaBytes;
if (Math.Abs(TotalTeraBytes) >= 1)
return TotalTeraBytes;
if (Math.Abs(TotalGigaBytes) >= 1)
return TotalGigaBytes;
if (Math.Abs(TotalMegaBytes) >= 1)
return TotalMegaBytes;
if (Math.Abs(TotalKiloBytes) >= 1)
return TotalKiloBytes;
return TotalBytes;
}
private string GetLargestWholeNumberSymbol()
{
if (Math.Abs(TotalPetaBytes) >= 1)
return "PB";
if (Math.Abs(TotalTeraBytes) >= 1)
return "TB";
if (Math.Abs(TotalGigaBytes) >= 1)
return "GB";
if (Math.Abs(TotalMegaBytes) >= 1)
return "MB";
if (Math.Abs(TotalKiloBytes) >= 1)
return "KB";
return "B";
}
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}";
}
public partial struct FileSize
{
public static FileSize FromBytes(long bytes) => new FileSize(bytes);
}
}

View File

@@ -0,0 +1,54 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/guild#guild-object
public partial class Guild : IHasId
{
public string Id { get; }
public string Name { get; }
public string? IconHash { get; }
public string IconUrl => !string.IsNullOrWhiteSpace(IconHash)
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
public IReadOnlyList<Role> Roles { get; }
public Dictionary<string, Member?> Members { get; }
public Guild(string id, string name, string? iconHash, IReadOnlyList<Role> roles)
{
Id = id;
Name = name;
IconHash = iconHash;
Roles = roles;
Members = new Dictionary<string, Member?>();
}
public override string ToString() => Name;
}
public partial class Guild
{
public static string GetUserColor(Guild guild, User user) =>
guild.Members.GetValueOrDefault(user.Id, null)?
.RoleIds
.Select(r => guild.Roles.FirstOrDefault(role => r == role.Id))
.Where(r => r != null)
.Where(r => r.Color != null)
.Aggregate<Role, Role?>(null, (a, b) => (a?.Position ?? 0) > b.Position ? a : b)?
.Color?
.ToHexString() ?? "";
public static string GetUserNick(Guild guild, User user) => guild.Members.GetValueOrDefault(user.Id)?.Nick ?? user.Name;
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, Array.Empty<Role>());
}
}

View File

@@ -0,0 +1,7 @@
namespace DiscordChatExporter.Domain.Discord.Models
{
public interface IHasId
{
string Id { get; }
}
}

View File

@@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Domain.Discord.Models
{
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
{
public bool Equals(IHasId? x, IHasId? y) => StringComparer.Ordinal.Equals(x?.Id, y?.Id);
public int GetHashCode(IHasId obj) => StringComparer.Ordinal.GetHashCode(obj.Id);
}
public partial class IdBasedEqualityComparer
{
public static IdBasedEqualityComparer Instance { get; } = new IdBasedEqualityComparer();
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/guild#guild-member-object
public class Member
{
public string UserId { get; }
public string? Nick { get; }
public IReadOnlyList<string> RoleIds { get; }
public Member(string userId, string? nick, IReadOnlyList<string> roleIds)
{
UserId = userId;
Nick = nick;
RoleIds = roleIds;
}
}
}

View File

@@ -0,0 +1,82 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
public enum MessageType
{
Default,
RecipientAdd,
RecipientRemove,
Call,
ChannelNameChange,
ChannelIconChange,
ChannelPinnedMessage,
GuildMemberJoin
}
// https://discordapp.com/developers/docs/resources/channel#message-object
public class Message : IHasId
{
public string Id { get; }
public string ChannelId { get; }
public MessageType Type { get; }
public User Author { get; }
public DateTimeOffset Timestamp { get; }
public DateTimeOffset? EditedTimestamp { get; }
public bool IsPinned { get; }
public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<Embed> Embeds { get; }
public IReadOnlyList<Reaction> Reactions { get; }
public IReadOnlyList<User> MentionedUsers { get; }
public Message(
string id,
string channelId,
MessageType type,
User author,
DateTimeOffset timestamp,
DateTimeOffset? editedTimestamp,
bool isPinned,
string content,
IReadOnlyList<Attachment> attachments,
IReadOnlyList<Embed> embeds,
IReadOnlyList<Reaction> reactions,
IReadOnlyList<User> mentionedUsers)
{
Id = id;
ChannelId = channelId;
Type = type;
Author = author;
Timestamp = timestamp;
EditedTimestamp = editedTimestamp;
IsPinned = isPinned;
Content = content;
Attachments = attachments;
Embeds = embeds;
Reactions = reactions;
MentionedUsers = mentionedUsers;
}
public override string ToString() =>
Content ?? (Embeds.Any()
? "<embed>"
: "<no content>");
}
}

View File

@@ -0,0 +1,19 @@
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/channel#reaction-object
public class Reaction
{
public Emoji Emoji { get; }
public int Count { get; }
public Reaction(Emoji emoji, int count)
{
Emoji = emoji;
Count = count;
}
public override string ToString() => $"{Emoji} ({Count})";
}
}

View File

@@ -0,0 +1,32 @@
using System.Drawing;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class Role : IHasId
{
public string Id { get; }
public string Name { get; }
public Color? Color { get; }
public int Position { get; }
public Role(string id, string name, Color? color, int position)
{
Id = id;
Name = name;
Color = color;
Position = position;
}
public override string ToString() => Name;
}
public partial class Role
{
public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role", null, -1);
}
}

View File

@@ -0,0 +1,58 @@
using System;
namespace DiscordChatExporter.Domain.Discord.Models
{
// https://discordapp.com/developers/docs/resources/user#user-object
public partial class User : IHasId
{
public string Id { get; }
public int Discriminator { get; }
public string Name { get; }
public string FullName => $"{Name}#{Discriminator:0000}";
public string? AvatarHash { get; }
public string AvatarUrl { get; }
public bool IsBot { get; }
public User(string id, int discriminator, string name, string? avatarHash, bool isBot)
{
Id = id;
Discriminator = discriminator;
Name = name;
AvatarHash = avatarHash;
IsBot = isBot;
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
}
public override string ToString() => FullName;
}
public partial class User
{
private static string GetAvatarUrl(string id, int discriminator, string? avatarHash)
{
// Custom avatar
if (!string.IsNullOrWhiteSpace(avatarHash))
{
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
}
// Default avatar
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
}
public static User CreateUnknownUser(string id) => new User(id, 0, "Unknown", null, false);
}
}