Compare commits

...

8 Commits
2.36 ... 2.36.1

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

View File

@@ -18,8 +18,7 @@ jobs:
- name: Install .NET
uses: actions/setup-dotnet@v2
with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
dotnet-version: 6.0.100
dotnet-version: 6.0.x
- name: Run tests
# 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
uses: actions/setup-dotnet@v2
with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
dotnet-version: 6.0.100
dotnet-version: 6.0.x
- name: Publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
@@ -151,4 +149,4 @@ jobs:
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>"
}
}

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)
- [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))

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>2.36</Version>
<Version>2.36.1</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>

View File

@@ -37,7 +37,7 @@ public partial record Embed
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.Pipe(s => Enum.Parse<EmbedKind>(s, true)) ??
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();

View File

@@ -7,6 +7,5 @@ public enum EmbedKind
Image,
Video,
Gifv,
Article,
Link
}

View File

@@ -10,7 +10,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-object
public record Message(
public partial record Message(
Snowflake Id,
MessageKind Kind,
User Author,
@@ -26,6 +26,16 @@ public record Message(
IReadOnlyList<User> MentionedUsers,
MessageReference? Reference,
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)
{

View File

@@ -17,6 +17,5 @@ public enum MessageKind
public static class MessageKindExtensions
{
public static bool IsSystemNotification(this MessageKind c) =>
c is not MessageKind.Default and not MessageKind.Reply;
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
}

View File

@@ -22,65 +22,67 @@ public class DiscordClient
private readonly string _token;
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;
private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url,
bool isBot,
TokenKind tokenKind,
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
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation(
"Authorization",
isBot ? $"Bot {_token}" : _token
);
// Don't validate because token can have invalid characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation(
"Authorization",
tokenKind == TokenKind.Bot
? $"Bot {_token}"
: _token
);
return await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
return await Http.Client.SendAsync(
request,
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
);
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(
string url,
CancellationToken cancellationToken = default)
{
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
{
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);
var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
return await GetResponseAsync(url, tokenKind, cancellationToken);
}
private async ValueTask<JsonElement> GetJsonResponseAsync(
@@ -321,6 +323,11 @@ public class DiscordClient
if (message.Timestamp > lastMessage.Timestamp)
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
if (progress is not null)
{

View File

@@ -2,7 +2,6 @@
public enum TokenKind
{
Unknown,
User,
Bot
}

View File

@@ -41,4 +41,7 @@ Failed to perform an HTTP request.
internal static DiscordChatExporterException ChannelIsEmpty() =>
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.");
}

View File

@@ -42,7 +42,7 @@ internal class ExportContext
{
"unix" => date.ToUnixTimeSeconds().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);

View File

@@ -10,8 +10,6 @@
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@{
var firstMessage = Model.Messages.First();
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
@@ -20,6 +18,8 @@
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
var firstMessage = Model.Messages.First();
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
@@ -62,29 +62,31 @@
: userMember?.Nick ?? message.Author.Name;
<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 class="chatlog__message-primary">
<div class="chatlog__header">
@{/* Author name */}
<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>
@{/* 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>
@{/* System notification content */}
@if(message.Kind == MessageKind.ChannelPinnedMessage)
@{/* System notification content */}
<span class="chatlog__system-notification-content">
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
{
<span class="chatlog__system-notification"> pinned</span>
<span class="chatlog__system-notification-reference-link chatlog__reference-link" onclick="scrollToMessage(event, '@message.Reference?.MessageId')"> a message</span>
<span class="chatlog__system-notification"> to this channel.</span>
<span> pinned</span>
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId"> a message</a>
<span> to this channel.</span>
}
else
{
<span class="chatlog__system-notification">@(char.ToLowerInvariant(message.Content[0]) + message.Content[1..])</span>
<span>@message.Content.ToLowerInvariant()</span>
}
</span>
@{/* Timestamp */}
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
</div>
@{/* Timestamp */}
<span class="chatlog__system-notification-timestamp">
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
</div>
}
// Regular message
@@ -168,11 +170,11 @@
}
@{/* 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">
@{/* 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>
}

View File

@@ -69,6 +69,7 @@
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 17px;
font-weight: @Themed("400", "500");
scroll-behavior: smooth;
}
a {
@@ -254,18 +255,36 @@
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")
}
.chatlog__system-notification-reference-link {
.chatlog__system-notification-link {
font-weight: 500;
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__system-notification-icon {
width: 18px;
height: 18px;
.chatlog__system-notification-timestamp {
margin-left: 0.3rem;
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 {
@@ -300,7 +319,7 @@
}
.chatlog__timestamp a {
color: @Themed("#a3a6aa", "#5e6772");
color: inherit;
}
.chatlog__content {

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Text;
using System.Text.RegularExpressions;
@@ -29,6 +30,11 @@ public static class StringExtensions
public static string ToDashCase(this string str) =>
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) =>
builder.Length > 0
? builder.Append(value)

View File

@@ -3,6 +3,7 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Utils.Extensions;
using Polly;
@@ -21,7 +22,7 @@ public static class Http
private static bool IsRetryableException(Exception exception) =>
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)
);