diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs index 1f4846e..80c38dc 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs @@ -156,4 +156,19 @@ public class HtmlEmbedSpecs message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle(); } + + [Fact] + public async Task Message_with_a_guild_invite_link_is_rendered_with_a_widget() + { + // https://github.com/Tyrrrz/DiscordChatExporter/issues/649 + + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.EmbedTestCases, + Snowflake.Parse("1075116548966064128") + ); + + // Assert + message.Text().Should().Contain("DiscordChatExporter TestServer"); + } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Invite.cs b/DiscordChatExporter.Core/Discord/Data/Invite.cs new file mode 100644 index 0000000..a9e4cb5 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Invite.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data; + +// https://discord.com/developers/docs/resources/invite#invite-object +public record Invite( + string Code, + Guild? Guild, + Channel? Channel) +{ + public static string? TryGetCodeFromUrl(string url) => + Regex.Match(url, @"^https?://discord\.gg/(\w+)/?$").Groups[1].Value.NullIfWhiteSpace(); + + public static Invite Parse(JsonElement json) + { + var code = json.GetProperty("code").GetNonWhiteSpaceString(); + var guild = json.GetPropertyOrNull("guild")?.Pipe(Guild.Parse); + var channel = json.GetPropertyOrNull("channel")?.Pipe(c => Channel.Parse(c)); + + return new Invite(code, guild, channel); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Member.cs b/DiscordChatExporter.Core/Discord/Data/Member.cs index aadfd70..c389451 100644 --- a/DiscordChatExporter.Core/Discord/Data/Member.cs +++ b/DiscordChatExporter.Core/Discord/Data/Member.cs @@ -19,12 +19,6 @@ public partial record Member( public partial record Member { - public static Member CreateForUser(User user) => new( - user, - user.Name, - Array.Empty() - ); - public static Member Parse(JsonElement json) { var user = json.GetProperty("user").Pipe(User.Parse); diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 8b7f1bc..40b71f3 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -260,10 +260,17 @@ public class DiscordClient return null; var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{memberId}", cancellationToken); - return response?.Pipe(Member.Parse); } + public async ValueTask TryGetGuildInviteAsync( + string code, + CancellationToken cancellationToken = default) + { + var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken); + return response?.Pipe(Invite.Parse); + } + public async ValueTask GetChannelCategoryAsync( Snowflake channelId, CancellationToken cancellationToken = default) diff --git a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs index 005567b..681115c 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs @@ -130,7 +130,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor // Try to extract the message ID if the link points to a Discord message var linkedMessageId = Regex.Match( link.Url, - "^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$" + @"^https?://(?:discord|discordapp)\.com/channels/.*?/(\d+)/?$" ).Groups[1].Value; _buffer.Append( diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index 10e64c3..8b2f4a8 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -4,6 +4,7 @@ @using System.Threading.Tasks @using DiscordChatExporter.Core.Discord.Data @using DiscordChatExporter.Core.Discord.Data.Embeds +@using DiscordChatExporter.Core.Markdown.Parsing @using DiscordChatExporter.Core.Utils.Extensions @inherits RazorBlade.HtmlTemplate @@ -311,6 +312,49 @@ } + @{/* Invites */} + @{ + var inviteCodes = MarkdownParser + .ExtractLinks(message.Content) + .Select(l => l.Url) + .Select(Invite.TryGetCodeFromUrl) + .WhereNotNull() + .ToArray(); + + foreach (var inviteCode in inviteCodes) + { + var invite = await ExportContext.Discord.TryGetGuildInviteAsync(inviteCode, CancellationToken); + if (invite is null) + { + continue; + } + +
+
+
Invite to join a server
+
+
+ Guild icon +
+
+ +
+ + + + @(invite.Channel?.Name ?? "Unknown channel") +
+
+
+
+
+ } + } + @{/* Embeds */} @foreach (var embed in message.Embeds) { diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index 53ddeac..9474354 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -595,6 +595,56 @@ border-radius: 3px; } + .chatlog__embed-invite-container { + min-width: 320px; + padding: 0.6rem 0.7rem; + border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)"); + border-radius: 3px; + background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)"); + } + + .chatlog__embed-invite-title { + margin: 0 0 0.8rem 0; + color: @Themed("#b9bbbe", "#4f5660"); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + } + + .chatlog__embed-invite { + display: flex; + } + + .chatlog__embed-invite-guild-icon { + width: 50px; + height: 50px; + border-radius: 0.85rem; + } + + .chatlog__embed-invite-info { + margin-left: 1rem; + } + + .chatlog__embed-invite-guild-name { + color: @Themed("#ffffff", "#36393e"); + font-weight: 600; + } + + .chatlog__embed-invite-guild-name a { + color: inherit; + } + + .chatlog__embed-invite-channel-icon { + width: 18px; + height: 18px; + vertical-align: bottom; + } + + .chatlog__embed-invite-channel-name { + font-size: 0.9rem; + font-weight: 600; + } + .chatlog__embed-spotify { border: 0; } @@ -851,6 +901,9 @@ + + + diff --git a/DiscordChatExporter.Core/Markdown/FormattingNode.cs b/DiscordChatExporter.Core/Markdown/FormattingNode.cs index db1c153..c18a57d 100644 --- a/DiscordChatExporter.Core/Markdown/FormattingNode.cs +++ b/DiscordChatExporter.Core/Markdown/FormattingNode.cs @@ -2,4 +2,7 @@ namespace DiscordChatExporter.Core.Markdown; -internal record FormattingNode(FormattingKind Kind, IReadOnlyList Children) : MarkdownNode; \ No newline at end of file +internal record FormattingNode( + FormattingKind Kind, + IReadOnlyList Children +) : MarkdownNode, IContainerNode; \ No newline at end of file diff --git a/DiscordChatExporter.Core/Markdown/IContainerNode.cs b/DiscordChatExporter.Core/Markdown/IContainerNode.cs new file mode 100644 index 0000000..17b0e51 --- /dev/null +++ b/DiscordChatExporter.Core/Markdown/IContainerNode.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace DiscordChatExporter.Core.Markdown; + +internal interface IContainerNode +{ + IReadOnlyList Children { get; } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Markdown/LinkNode.cs b/DiscordChatExporter.Core/Markdown/LinkNode.cs index e023ec8..03105f0 100644 --- a/DiscordChatExporter.Core/Markdown/LinkNode.cs +++ b/DiscordChatExporter.Core/Markdown/LinkNode.cs @@ -2,9 +2,10 @@ namespace DiscordChatExporter.Core.Markdown; +// Named links can contain child nodes (e.g. [**bold URL**](https://test.com)) internal record LinkNode( string Url, - IReadOnlyList Children) : MarkdownNode + IReadOnlyList Children) : MarkdownNode, IContainerNode { public LinkNode(string url) : this(url, new[] { new TextNode(url) }) diff --git a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs index b063400..9d276d0 100644 --- a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs +++ b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs @@ -385,12 +385,32 @@ internal static partial class MarkdownParser private static IReadOnlyList Parse(StringSegment segment) => Parse(segment, AggregateNodeMatcher); + public static IReadOnlyList Parse(string markdown) => + Parse(new StringSegment(markdown)); + private static IReadOnlyList ParseMinimal(StringSegment segment) => Parse(segment, MinimalAggregateNodeMatcher); - public static IReadOnlyList Parse(string input) => - Parse(new StringSegment(input)); + public static IReadOnlyList ParseMinimal(string markdown) => + ParseMinimal(new StringSegment(markdown)); + + private static void ExtractLinks(IEnumerable nodes, ICollection links) + { + foreach (var node in nodes) + { + if (node is LinkNode linkNode) + links.Add(linkNode); + + if (node is IContainerNode containerNode) + ExtractLinks(containerNode.Children, links); + } + } + + public static IReadOnlyList ExtractLinks(string markdown) + { + var links = new List(); + ExtractLinks(Parse(markdown), links); - public static IReadOnlyList ParseMinimal(string input) => - ParseMinimal(new StringSegment(input)); + return links; + } } \ No newline at end of file