Compare commits

...

8 Commits

Author SHA1 Message Date
Tyrrrz 84e656ccfc Update version 2022-09-24 20:40:51 +03:00
Tyrrrz 2ba0c3b38e More refactoring around system notifications 2022-09-24 19:54:42 +03:00
Tyrrrz 7467f0aeb6 Don't fail on unknown embed kinds
Closes #936
2022-09-19 23:55:17 +03:00
Tyrrrz e38479d463 Make lowercasing in system notifications a bit safer
Closes #935
2022-09-19 16:30:19 +03:00
Tyrrrz 06a4b6a8e6 Add checks for message content intent on bot accounts
Related to #918
2022-09-16 23:04:18 +03:00
Tyrrrz cc1ad8b435 Retry on SSL exceptions 2022-09-16 22:47:02 +03:00
Tyrrrz f8dac2c9d0 Simplify token kind resolution
Extra request which makes it less efficient, but much simpler code
2022-09-16 22:46:55 +03:00
Tyrrrz b8cea0d140 Unlock dotnet version on CI 2022-09-16 18:01:53 +03:00
15 changed files with 130 additions and 80 deletions
+3 -5
View File
@@ -18,8 +18,7 @@ jobs:
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v2 uses: actions/setup-dotnet@v2
with: with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176 dotnet-version: 6.0.x
dotnet-version: 6.0.100
- name: Run tests - name: Run tests
# Tests need access to secrets, so we can't run them against PRs because of limited trust # Tests need access to secrets, so we can't run them against PRs because of limited trust
@@ -50,8 +49,7 @@ jobs:
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v2 uses: actions/setup-dotnet@v2
with: with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176 dotnet-version: 6.0.x
dotnet-version: 6.0.100
- name: Publish (CLI) - name: Publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
@@ -151,4 +149,4 @@ jobs:
payload: | payload: |
{ {
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>" "content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
} }
+7
View File
@@ -1,3 +1,10 @@
### v2.36.1 (24-Sep-2022)
- Added a check which will trigger an error if the provided bot account does not have the message content intent enabled. Note, however, that this check is based on heuristics which may result in false negatives.
- Fixed an issue where certain transient HTTP errors were not retried.
- Fixed an issue which caused the export process to fail with the `IndexOutOfRangeException` exception on certain automated messages.
- Fixed an issue which caused the export process to fail on unrecognized embed types.
### v2.36 (16-Sep-2022) ### v2.36 (16-Sep-2022)
- [HTML] Added support for rendering GIFV embeds. They will now render as videos that automatically play when you hover your mouse over them. (Thanks [@gan-of-culture](https://github.com/gan-of-culture)) - [HTML] Added support for rendering GIFV embeds. They will now render as videos that automatically play when you hover your mouse over them. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>2.36</Version> <Version>2.36.1</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
@@ -37,7 +37,7 @@ public partial record Embed
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind = var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.Pipe(s => Enum.Parse<EmbedKind>(s, true)) ?? json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
EmbedKind.Rich; EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
@@ -7,6 +7,5 @@ public enum EmbedKind
Image, Image,
Video, Video,
Gifv, Gifv,
Article,
Link Link
} }
@@ -10,7 +10,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data; namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-object // https://discord.com/developers/docs/resources/channel#message-object
public record Message( public partial record Message(
Snowflake Id, Snowflake Id,
MessageKind Kind, MessageKind Kind,
User Author, User Author,
@@ -26,6 +26,16 @@ public record Message(
IReadOnlyList<User> MentionedUsers, IReadOnlyList<User> MentionedUsers,
MessageReference? Reference, MessageReference? Reference,
Message? ReferencedMessage) : IHasId Message? ReferencedMessage) : IHasId
{
public bool IsEmpty =>
Kind == MessageKind.Default &&
string.IsNullOrEmpty(Content) &&
!Attachments.Any() &&
!Embeds.Any() &&
!Stickers.Any();
}
public partial record Message
{ {
private static IReadOnlyList<Embed> NormalizeEmbeds(IReadOnlyList<Embed> embeds) private static IReadOnlyList<Embed> NormalizeEmbeds(IReadOnlyList<Embed> embeds)
{ {
@@ -17,6 +17,5 @@ public enum MessageKind
public static class MessageKindExtensions public static class MessageKindExtensions
{ {
public static bool IsSystemNotification(this MessageKind c) => public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
c is not MessageKind.Default and not MessageKind.Reply;
} }
@@ -22,65 +22,67 @@ public class DiscordClient
private readonly string _token; private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute); private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
private TokenKind _tokenKind = TokenKind.Unknown; private TokenKind? _resolvedTokenKind;
public DiscordClient(string token) => _token = token; public DiscordClient(string token) => _token = token;
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
bool isBot, TokenKind tokenKind,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
// Don't validate because token can have invalid characters // Don't validate because token can have invalid characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828 // https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation( request.Headers.TryAddWithoutValidation(
"Authorization", "Authorization",
isBot ? $"Bot {_token}" : _token tokenKind == TokenKind.Bot
); ? $"Bot {_token}"
: _token
);
return await Http.Client.SendAsync( return await Http.Client.SendAsync(
request, request,
HttpCompletionOption.ResponseHeadersRead, HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
}, cancellationToken);
}
private async ValueTask<TokenKind> GetTokenKindAsync(CancellationToken cancellationToken = default)
{
// Try authenticating as a user
using var userResponse = await GetResponseAsync(
"users/@me",
TokenKind.User,
cancellationToken cancellationToken
); );
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.User;
// Try authenticating as a bot
using var botResponse = await GetResponseAsync(
"users/@me",
TokenKind.Bot,
cancellationToken
);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.Bot;
throw DiscordChatExporterException.Unauthorized();
} }
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken => var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
{ return await GetResponseAsync(url, tokenKind, cancellationToken);
if (_tokenKind == TokenKind.User)
return await GetResponseAsync(url, false, innerCancellationToken);
if (_tokenKind == TokenKind.Bot)
return await GetResponseAsync(url, true, innerCancellationToken);
// Try to authenticate as user
var userResponse = await GetResponseAsync(url, false, innerCancellationToken);
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.User;
return userResponse;
}
userResponse.Dispose();
// Otherwise, try to authenticate as bot
var botResponse = await GetResponseAsync(url, true, innerCancellationToken);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.Bot;
return botResponse;
}
// The token is probably invalid altogether.
// Return the last response anyway, upstream should handle the error.
return botResponse;
}, cancellationToken);
} }
private async ValueTask<JsonElement> GetJsonResponseAsync( private async ValueTask<JsonElement> GetJsonResponseAsync(
@@ -321,6 +323,11 @@ public class DiscordClient
if (message.Timestamp > lastMessage.Timestamp) if (message.Timestamp > lastMessage.Timestamp)
yield break; yield break;
// Make sure the messages are not empty when exporting via a bot
// https://github.com/Tyrrrz/DiscordChatExporter/issues/918
if (_resolvedTokenKind == TokenKind.Bot && message.IsEmpty && !message.Author.IsBot)
throw DiscordChatExporterException.MessageContentIntentMissing();
// Report progress based on the duration of exported messages divided by total // Report progress based on the duration of exported messages divided by total
if (progress is not null) if (progress is not null)
{ {
@@ -2,7 +2,6 @@
public enum TokenKind public enum TokenKind
{ {
Unknown,
User, User,
Bot Bot
} }
@@ -41,4 +41,7 @@ Failed to perform an HTTP request.
internal static DiscordChatExporterException ChannelIsEmpty() => internal static DiscordChatExporterException ChannelIsEmpty() =>
new("No messages found for the specified period."); new("No messages found for the specified period.");
internal static DiscordChatExporterException MessageContentIntentMissing() =>
new("Failed to retrieve message content because the bot doesn't have the Message Content Intent enabled.");
} }
@@ -42,7 +42,7 @@ internal class ExportContext
{ {
"unix" => date.ToUnixTimeSeconds().ToString(), "unix" => date.ToUnixTimeSeconds().ToString(),
"unixms" => date.ToUnixTimeMilliseconds().ToString(), "unixms" => date.ToUnixTimeMilliseconds().ToString(),
var dateFormat => date.ToLocalString(dateFormat) var format => date.ToLocalString(format)
}; };
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id); public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
@@ -10,8 +10,6 @@
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext> @inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@{ @{
var firstMessage = Model.Messages.First();
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url); ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
@@ -20,6 +18,8 @@
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false); string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
var firstMessage = Model.Messages.First();
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id); var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id); var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
@@ -62,29 +62,31 @@
: userMember?.Nick ?? message.Author.Name; : userMember?.Nick ?? message.Author.Name;
<div class="chatlog__message-aside"> <div class="chatlog__message-aside">
<svg class=chatlog__system-notification-icon><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg> <svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
</div> </div>
<div class="chatlog__message-primary"> <div class="chatlog__message-primary">
<div class="chatlog__header"> @{/* Author name */}
@{/* Author name */} <span class="chatlog__system-notification-author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
@{/* System notification content */} @{/* System notification content */}
@if(message.Kind == MessageKind.ChannelPinnedMessage) <span class="chatlog__system-notification-content">
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
{ {
<span class="chatlog__system-notification"> pinned</span> <span> pinned</span>
<span class="chatlog__system-notification-reference-link chatlog__reference-link" onclick="scrollToMessage(event, '@message.Reference?.MessageId')"> a message</span> <a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId"> a message</a>
<span class="chatlog__system-notification"> to this channel.</span> <span> to this channel.</span>
} }
else else
{ {
<span class="chatlog__system-notification">@(char.ToLowerInvariant(message.Content[0]) + message.Content[1..])</span> <span>@message.Content.ToLowerInvariant()</span>
} }
</span>
@{/* Timestamp */} @{/* Timestamp */}
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span> <span class="chatlog__system-notification-timestamp">
</div> <a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
</div> </div>
} }
// Regular message // Regular message
@@ -168,11 +170,11 @@
} }
@{/* Content */} @{/* Content */}
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null) @if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
{ {
<div class="chatlog__content chatlog__markdown"> <div class="chatlog__content chatlog__markdown">
@{/* Text */} @{/* Text */}
@if (!message.IsContentHidden()) @if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
{ {
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span> <span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
} }
@@ -69,6 +69,7 @@
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 17px; font-size: 17px;
font-weight: @Themed("400", "500"); font-weight: @Themed("400", "500");
scroll-behavior: smooth;
} }
a { a {
@@ -254,18 +255,36 @@
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
} }
.chatlog__system-notification { .chatlog__system-notification-icon {
width: 18px;
height: 18px;
}
.chatlog__system-notification-author {
font-weight: @Themed("500", "600");
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__system-notification-content {
color: @Themed("#96989d", "#5e6772") color: @Themed("#96989d", "#5e6772")
} }
.chatlog__system-notification-reference-link { .chatlog__system-notification-link {
font-weight: 500; font-weight: 500;
color: @Themed("#ffffff", "#2f3136"); color: @Themed("#ffffff", "#2f3136");
} }
.chatlog__system-notification-icon { .chatlog__system-notification-timestamp {
width: 18px; margin-left: 0.3rem;
height: 18px; color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
direction: ltr;
unicode-bidi: bidi-override;
}
.chatlog__system-notification-timestamp a {
color: inherit;
} }
.chatlog__header { .chatlog__header {
@@ -300,7 +319,7 @@
} }
.chatlog__timestamp a { .chatlog__timestamp a {
color: @Themed("#a3a6aa", "#5e6772"); color: inherit;
} }
.chatlog__content { .chatlog__content {
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -29,6 +30,11 @@ public static class StringExtensions
public static string ToDashCase(this string str) => public static string ToDashCase(this string str) =>
Regex.Replace(str, @"(\p{Ll})(\p{Lu})", "$1-$2"); Regex.Replace(str, @"(\p{Ll})(\p{Lu})", "$1-$2");
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) where T : struct, Enum =>
Enum.TryParse<T>(str, ignoreCase, out var result)
? result
: null;
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 builder.Length > 0
? builder.Append(value) ? builder.Append(value)
+2 -1
View File
@@ -3,6 +3,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using Polly; using Polly;
@@ -21,7 +22,7 @@ public static class Http
private static bool IsRetryableException(Exception exception) => private static bool IsRetryableException(Exception exception) =>
exception.GetSelfAndChildren().Any(ex => exception.GetSelfAndChildren().Any(ex =>
ex is TimeoutException or SocketException || ex is TimeoutException or SocketException or AuthenticationException ||
ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK) ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK)
); );