mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-04-28 08:46:44 +00:00
Rework architecture
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
29
DiscordChatExporter.Domain/Discord/AuthToken.cs
Normal file
29
DiscordChatExporter.Domain/Discord/AuthToken.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
198
DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs
Normal file
198
DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
249
DiscordChatExporter.Domain/Discord/DiscordClient.cs
Normal file
249
DiscordChatExporter.Domain/Discord/DiscordClient.cs
Normal 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")
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
44
DiscordChatExporter.Domain/Discord/Models/Attachment.cs
Normal file
44
DiscordChatExporter.Domain/Discord/Models/Attachment.cs
Normal 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"};
|
||||
}
|
||||
}
|
||||
58
DiscordChatExporter.Domain/Discord/Models/Channel.cs
Normal file
58
DiscordChatExporter.Domain/Discord/Models/Channel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
57
DiscordChatExporter.Domain/Discord/Models/Embed.cs
Normal file
57
DiscordChatExporter.Domain/Discord/Models/Embed.cs
Normal 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>";
|
||||
}
|
||||
}
|
||||
22
DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs
Normal file
22
DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs
Normal 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>";
|
||||
}
|
||||
}
|
||||
22
DiscordChatExporter.Domain/Discord/Models/EmbedField.cs
Normal file
22
DiscordChatExporter.Domain/Discord/Models/EmbedField.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
19
DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs
Normal file
19
DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
20
DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs
Normal file
20
DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
71
DiscordChatExporter.Domain/Discord/Models/Emoji.cs
Normal file
71
DiscordChatExporter.Domain/Discord/Models/Emoji.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
66
DiscordChatExporter.Domain/Discord/Models/FileSize.cs
Normal file
66
DiscordChatExporter.Domain/Discord/Models/FileSize.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
54
DiscordChatExporter.Domain/Discord/Models/Guild.cs
Normal file
54
DiscordChatExporter.Domain/Discord/Models/Guild.cs
Normal 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>());
|
||||
}
|
||||
}
|
||||
7
DiscordChatExporter.Domain/Discord/Models/IHasId.cs
Normal file
7
DiscordChatExporter.Domain/Discord/Models/IHasId.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
public interface IHasId
|
||||
{
|
||||
string Id { get; }
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
22
DiscordChatExporter.Domain/Discord/Models/Member.cs
Normal file
22
DiscordChatExporter.Domain/Discord/Models/Member.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
DiscordChatExporter.Domain/Discord/Models/Message.cs
Normal file
82
DiscordChatExporter.Domain/Discord/Models/Message.cs
Normal 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>");
|
||||
}
|
||||
}
|
||||
19
DiscordChatExporter.Domain/Discord/Models/Reaction.cs
Normal file
19
DiscordChatExporter.Domain/Discord/Models/Reaction.cs
Normal 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})";
|
||||
}
|
||||
}
|
||||
32
DiscordChatExporter.Domain/Discord/Models/Role.cs
Normal file
32
DiscordChatExporter.Domain/Discord/Models/Role.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
58
DiscordChatExporter.Domain/Discord/Models/User.cs
Normal file
58
DiscordChatExporter.Domain/Discord/Models/User.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user