mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-03-31 17:43:04 -06:00
feat: add action row support
this adds support for exporting the action rows sometimes found in embeds. Only was able to test with single button action rows. I used copilot to draft up a proof of concept and tweaked to get it working 100% and fixed the colors to match discord styling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
4f29fa63d0
commit
fcf58f5b8e
83
DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs
Normal file
83
DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class ComponentParsingSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void I_can_parse_a_link_button_component_from_a_message_payload()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var document = JsonDocument.Parse(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "123456789012345678",
|
||||||
|
"type": 0,
|
||||||
|
"author": {
|
||||||
|
"id": "987654321098765432",
|
||||||
|
"username": "Tester",
|
||||||
|
"discriminator": "0",
|
||||||
|
"avatar": null
|
||||||
|
},
|
||||||
|
"timestamp": "2026-02-25T00:00:00.000000+00:00",
|
||||||
|
"content": "",
|
||||||
|
"attachments": [],
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"style": 5,
|
||||||
|
"label": "Direct Link",
|
||||||
|
"url": "https://www.example.com",
|
||||||
|
"custom_id": null,
|
||||||
|
"sku_id": null,
|
||||||
|
"disabled": false,
|
||||||
|
"emoji": {
|
||||||
|
"id": null,
|
||||||
|
"name": "📎",
|
||||||
|
"animated": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"embeds": [],
|
||||||
|
"sticker_items": [],
|
||||||
|
"reactions": [],
|
||||||
|
"mentions": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = Message.Parse(document.RootElement);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message.Components.Should().HaveCount(1);
|
||||||
|
message.IsEmpty.Should().BeFalse();
|
||||||
|
|
||||||
|
var actionRow = message.Components[0];
|
||||||
|
actionRow.Components.Should().HaveCount(1);
|
||||||
|
|
||||||
|
var button = actionRow.Components[0];
|
||||||
|
button.Style.Should().Be(DiscordChatExporter.Core.Discord.Data.Components.ButtonStyle.Link);
|
||||||
|
button.Label.Should().Be("Direct Link");
|
||||||
|
button
|
||||||
|
.Url.Should()
|
||||||
|
.Be(
|
||||||
|
"https://www.example.com"
|
||||||
|
);
|
||||||
|
button.IsUrlButton.Should().BeTrue();
|
||||||
|
button.IsDisabled.Should().BeFalse();
|
||||||
|
|
||||||
|
button.Emoji.Should().NotBeNull();
|
||||||
|
button.Emoji!.Id.Should().BeNull();
|
||||||
|
button.Emoji.Name.Should().Be("📎");
|
||||||
|
button.Emoji.Code.Should().Be("paperclip");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -88,4 +88,4 @@ public class JsonContentSpecs
|
||||||
"866674314627121232"
|
"866674314627121232"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data.Components;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/components/reference#action-row
|
||||||
|
public partial record ActionRowComponent(IReadOnlyList<ButtonComponent> Components)
|
||||||
|
{
|
||||||
|
public bool HasButtons => Components.Any();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record ActionRowComponent
|
||||||
|
{
|
||||||
|
public static ActionRowComponent? Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var type = json.GetPropertyOrNull("type")?.GetInt32OrNull();
|
||||||
|
if (type != 1)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var components =
|
||||||
|
json.GetPropertyOrNull("components")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Where(c => c.GetPropertyOrNull("type")?.GetInt32OrNull() == 2)
|
||||||
|
?.Select(ButtonComponent.Parse)
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
return new ActionRowComponent(components);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data.Components;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/components/reference#button
|
||||||
|
public partial record ButtonComponent(
|
||||||
|
ButtonStyle Style,
|
||||||
|
string? Label,
|
||||||
|
Emoji? Emoji,
|
||||||
|
string? Url,
|
||||||
|
string? CustomId,
|
||||||
|
Snowflake? SkuId,
|
||||||
|
bool IsDisabled
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public bool IsUrlButton => !string.IsNullOrWhiteSpace(Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record ButtonComponent
|
||||||
|
{
|
||||||
|
public static ButtonComponent Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var style =
|
||||||
|
json.GetPropertyOrNull("style")
|
||||||
|
?.GetInt32OrNull()
|
||||||
|
?.Pipe(s =>
|
||||||
|
Enum.IsDefined(typeof(ButtonStyle), s) ? (ButtonStyle)s : (ButtonStyle?)null
|
||||||
|
)
|
||||||
|
?? ButtonStyle.Secondary;
|
||||||
|
|
||||||
|
var label = json.GetPropertyOrNull("label")?.GetStringOrNull();
|
||||||
|
var emoji = json.GetPropertyOrNull("emoji")?.Pipe(Emoji.Parse);
|
||||||
|
|
||||||
|
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
|
var customId = json.GetPropertyOrNull("custom_id")?.GetNonWhiteSpaceStringOrNull();
|
||||||
|
var skuId = json.GetPropertyOrNull("sku_id")
|
||||||
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
var isDisabled = json.GetPropertyOrNull("disabled")?.GetBooleanOrNull() ?? false;
|
||||||
|
|
||||||
|
return new ButtonComponent(style, label, emoji, url, customId, skuId, isDisabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data.Components;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/components/reference#button-button-styles
|
||||||
|
public enum ButtonStyle
|
||||||
|
{
|
||||||
|
Primary = 1,
|
||||||
|
Secondary = 2,
|
||||||
|
Success = 3,
|
||||||
|
Danger = 4,
|
||||||
|
Link = 5,
|
||||||
|
Premium = 6,
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data.Components;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
@ -21,6 +22,7 @@ public partial record Message(
|
||||||
bool IsPinned,
|
bool IsPinned,
|
||||||
string Content,
|
string Content,
|
||||||
IReadOnlyList<Attachment> Attachments,
|
IReadOnlyList<Attachment> Attachments,
|
||||||
|
IReadOnlyList<ActionRowComponent> Components,
|
||||||
IReadOnlyList<Embed> Embeds,
|
IReadOnlyList<Embed> Embeds,
|
||||||
IReadOnlyList<Sticker> Stickers,
|
IReadOnlyList<Sticker> Stickers,
|
||||||
IReadOnlyList<Reaction> Reactions,
|
IReadOnlyList<Reaction> Reactions,
|
||||||
|
|
@ -34,6 +36,7 @@ public partial record Message(
|
||||||
public bool IsEmpty { get; } =
|
public bool IsEmpty { get; } =
|
||||||
string.IsNullOrWhiteSpace(Content)
|
string.IsNullOrWhiteSpace(Content)
|
||||||
&& !Attachments.Any()
|
&& !Attachments.Any()
|
||||||
|
&& !Components.Any()
|
||||||
&& !Embeds.Any()
|
&& !Embeds.Any()
|
||||||
&& !Stickers.Any();
|
&& !Stickers.Any();
|
||||||
|
|
||||||
|
|
@ -149,6 +152,14 @@ public partial record Message
|
||||||
.ToArray()
|
.ToArray()
|
||||||
?? [];
|
?? [];
|
||||||
|
|
||||||
|
var components =
|
||||||
|
json.GetPropertyOrNull("components")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(ActionRowComponent.Parse)
|
||||||
|
.WhereNotNull()
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var embeds = NormalizeEmbeds(
|
var embeds = NormalizeEmbeds(
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
?? []
|
?? []
|
||||||
|
|
@ -198,6 +209,7 @@ public partial record Message
|
||||||
isPinned,
|
isPinned,
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
|
components,
|
||||||
embeds,
|
embeds,
|
||||||
stickers,
|
stickers,
|
||||||
reactions,
|
reactions,
|
||||||
|
|
@ -208,4 +220,4 @@ public partial record Message
|
||||||
interaction
|
interaction
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data.Components;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
@ -352,7 +353,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask WriteStickerAsync(
|
private async ValueTask WriteStickerAsync(
|
||||||
Sticker sticker,
|
Sticker sticker,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
|
|
@ -369,6 +370,48 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
);
|
);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteButtonComponentAsync(
|
||||||
|
ButtonComponent button,
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("type", "Button");
|
||||||
|
_writer.WriteString("style", button.Style.ToString());
|
||||||
|
_writer.WriteString("label", button.Label);
|
||||||
|
_writer.WriteString("url", button.Url);
|
||||||
|
_writer.WriteString("customId", button.CustomId);
|
||||||
|
_writer.WriteString("skuId", button.SkuId?.ToString());
|
||||||
|
_writer.WriteBoolean("isDisabled", button.IsDisabled);
|
||||||
|
|
||||||
|
if (button.Emoji is not null)
|
||||||
|
{
|
||||||
|
_writer.WritePropertyName("emoji");
|
||||||
|
await WriteEmojiAsync(button.Emoji, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteActionRowComponentAsync(
|
||||||
|
ActionRowComponent actionRow,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("type", "ActionRow");
|
||||||
|
|
||||||
|
_writer.WriteStartArray("components");
|
||||||
|
foreach (var button in actionRow.Components)
|
||||||
|
await WriteButtonComponentAsync(button, cancellationToken);
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(
|
public override async ValueTask WritePreambleAsync(
|
||||||
|
|
@ -477,6 +520,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Components
|
||||||
|
_writer.WriteStartArray("components");
|
||||||
|
|
||||||
|
foreach (var component in message.Components)
|
||||||
|
await WriteActionRowComponentAsync(component, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
// Embeds
|
// Embeds
|
||||||
_writer.WriteStartArray("embeds");
|
_writer.WriteStartArray("embeds");
|
||||||
|
|
||||||
|
|
@ -645,4 +696,4 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
await _writer.DisposeAsync();
|
await _writer.DisposeAsync();
|
||||||
await base.DisposeAsync();
|
await base.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
@using System.Linq
|
@using System.Linq
|
||||||
@using System.Threading.Tasks
|
@using System.Threading.Tasks
|
||||||
@using DiscordChatExporter.Core.Discord.Data
|
@using DiscordChatExporter.Core.Discord.Data
|
||||||
|
@using DiscordChatExporter.Core.Discord.Data.Components
|
||||||
@using DiscordChatExporter.Core.Discord.Data.Embeds
|
@using DiscordChatExporter.Core.Discord.Data.Embeds
|
||||||
@using DiscordChatExporter.Core.Markdown.Parsing
|
@using DiscordChatExporter.Core.Markdown.Parsing
|
||||||
@using DiscordChatExporter.Core.Utils.Extensions
|
@using DiscordChatExporter.Core.Utils.Extensions
|
||||||
|
|
@ -731,6 +732,61 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Components *@
|
||||||
|
@if (message.Components.Any(c => c.HasButtons))
|
||||||
|
{
|
||||||
|
<div class="chatlog__components">
|
||||||
|
@foreach (var actionRow in message.Components.Where(c => c.HasButtons))
|
||||||
|
{
|
||||||
|
<div class="chatlog__action-row">
|
||||||
|
@foreach (var button in actionRow.Components)
|
||||||
|
{
|
||||||
|
var styleClass = button.Style switch {
|
||||||
|
ButtonStyle.Primary => "chatlog__component-button--primary",
|
||||||
|
ButtonStyle.Secondary => "chatlog__component-button--secondary",
|
||||||
|
ButtonStyle.Success => "chatlog__component-button--success",
|
||||||
|
ButtonStyle.Danger => "chatlog__component-button--danger",
|
||||||
|
ButtonStyle.Link => "chatlog__component-button--link",
|
||||||
|
ButtonStyle.Premium => "chatlog__component-button--premium",
|
||||||
|
_ => "chatlog__component-button--secondary"
|
||||||
|
};
|
||||||
|
|
||||||
|
var isUrlButton = button.IsUrlButton;
|
||||||
|
|
||||||
|
if (isUrlButton)
|
||||||
|
{
|
||||||
|
<a class="chatlog__component-button @styleClass" href="@button.Url" target="_blank" rel="noreferrer noopener">
|
||||||
|
@if (button.Emoji is not null)
|
||||||
|
{
|
||||||
|
<img class="chatlog__emoji chatlog__emoji--small" alt="@button.Emoji.Name" src="@await ResolveAssetUrlAsync(button.Emoji.ImageUrl)" loading="lazy">
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(button.Label))
|
||||||
|
{
|
||||||
|
<span>@button.Label</span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="chatlog__component-button @styleClass" type="button" disabled>
|
||||||
|
@if (button.Emoji is not null)
|
||||||
|
{
|
||||||
|
<img class="chatlog__emoji chatlog__emoji--small" alt="@button.Emoji.Name" src="@await ResolveAssetUrlAsync(button.Emoji.ImageUrl)" loading="lazy">
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(button.Label))
|
||||||
|
{
|
||||||
|
<span>@button.Label</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Stickers *@
|
@* Stickers *@
|
||||||
@foreach (var sticker in message.Stickers)
|
@foreach (var sticker in message.Stickers)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,72 @@
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__components {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__action-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__component-button {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2rem;
|
||||||
|
min-width: 3.75rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.chatlog__component-button:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__component-button--primary {
|
||||||
|
background-color: #5865f2;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__component-button--secondary {
|
||||||
|
background-color: @Themed("#4e5058", "#e3e5e8");
|
||||||
|
color: @Themed("#ffffff", "#313338");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__component-button--success {
|
||||||
|
background-color: #248046;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__component-button--danger {
|
||||||
|
background-color: #da373c;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__component-button--link {
|
||||||
|
background-color: #4e5058;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__component-button--premium {
|
||||||
|
background-color: #5865f2;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__attachment {
|
.chatlog__attachment {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue