Compare commits

...

47 Commits

Author SHA1 Message Date
Tyrrrz fdf421df3c Update version 2022-10-08 15:38:07 +03:00
Tyrrrz 2259f19c44 Remove message content intent check due to false positives 2022-10-08 15:35:06 +03:00
Tyrrrz 8695942328 Enforce a higher version of .NET runtime
Closes #941
2022-10-02 13:42:46 +03:00
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
Tyrrrz 7996a38c5c Update version 2022-09-16 17:22:53 +03:00
Tyrrrz 9d052f9404 Improve wording in CLI exception when output path is ambiguous
Related to #929
2022-09-14 21:59:22 +03:00
Tyrrrz 3078351670 Add fallback content for thread start messages 2022-09-14 20:17:04 +03:00
Tyrrrz 4f41c4fff3 Fix styles for system notifications in HTML 2022-09-14 20:14:30 +03:00
Tyrrrz a80ee2943f Refactor 2022-09-14 02:06:11 +03:00
gan-of-culture 8612d2c84a Add HTML formatting for system messages (#926) 2022-09-13 23:51:10 +03:00
Tyrrrz 57c849d0f8 More refactoring 2022-09-13 23:27:54 +03:00
Tyrrrz 9c2a26aa82 Fix tests 2022-09-13 18:32:31 +03:00
Tyrrrz f376028d76 Add tests for gifv embeds 2022-09-13 18:09:56 +03:00
Tyrrrz da87f3c774 Hide message content if it only contains a link to a GIFV 2022-09-13 18:05:41 +03:00
Tyrrrz 61516feafa Refactor 2022-09-13 17:33:28 +03:00
gan-of-culture 8d209cd67a Improve GIF playback (#927) 2022-09-13 16:08:57 +03:00
Tyrrrz c58d7e752b Update readme 2022-09-12 16:13:28 +03:00
Ivv fcc1605455 Mention nix package in readme (#925) 2022-09-12 15:59:15 +03:00
gan-of-culture f28d662a71 Support GIF embeds when exporting to HTML (#919) 2022-09-10 23:48:52 +03:00
Tyrrrz a405ab184e Update version 2022-09-08 16:57:00 +03:00
Tyrrrz b861a69799 Recognize existing directories as valid output paths in CLI
Closes #917
2022-09-04 18:14:22 +03:00
Tyrrrz d53b4c825a Update usage guide 2022-09-02 13:29:56 +03:00
Oleksii Holub 30678e96e5 Move into maintenance mode 2022-08-26 18:01:19 +03:00
Oleksii Holub 2024d3658b Update version 2022-08-26 17:58:41 +03:00
Oleksii Holub 9453cac389 Fix regex in contains filter 2022-08-26 00:00:02 +03:00
Oleksii Holub 504c3c53cd Fail if an export command is called with multiple channels but a single-file output path
Closes #799
2022-08-25 22:11:26 +03:00
Oleksii Holub 4e358e8e6f Add support for animated guild icons
Closes #900
2022-08-25 21:38:59 +03:00
Oleksii Holub 5e852235ce Retry on all Discord server side errors
Closes #908
2022-08-25 21:32:47 +03:00
Oleksii Holub 612c6d37a4 Make contains filter match on spaces and input boundaries, as well as word boundaries
Closes #909
2022-08-25 21:26:56 +03:00
Oleksii Holub 92cf886eab Fix not being able to parse message filters that share common prefix with one of the named filters
Related to #909
2022-08-25 21:20:22 +03:00
Oleksii Holub f48af53d72 Simplify filter grammar 2022-08-25 21:07:02 +03:00
Oleksii Holub 99d721c63b Update nuget packages 2022-08-25 20:22:10 +03:00
Oleksii Holub 11414b5937 Refactor message filter grammar and add ~ as an alias for - in negated filters for better UX when using CLI 2022-08-25 20:09:06 +03:00
Oleksii Holub 6c039ebfa6 Filter out categories when displaying channels 2022-08-19 18:39:19 +03:00
TSRBerry 9cffbbc5ce Expand tilde to home directory on *nix systems in output path (#903) 2022-08-18 13:23:26 -07:00
Oleksii Holub 6f87960513 Make workflow stages less ambigious 2022-08-13 04:13:09 -07:00
Oleksii Holub e8f8c47dbd Make JS in HTML templates more consistent 2022-07-19 16:16:48 +03:00
Oleksii Holub 9aba36fce5 Update twemoji URLs 2022-07-19 15:55:58 +03:00
Oleksii Holub f29e1d2d9f Inform about a breaking change in Docker configuration
Related to #884
2022-07-10 02:42:43 -07:00
Oleksii Holub b000a72f81 Update download instructions 2022-07-06 14:08:07 +03:00
42 changed files with 1003 additions and 696 deletions
+3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: ⚠ Feature request
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
- name: 💬 Discord server - name: 💬 Discord server
url: https://discord.gg/2SUWKFnHSm url: https://discord.gg/2SUWKFnHSm
about: Chat with the project community. about: Chat with the project community.
@@ -1,42 +0,0 @@
name: ✨ Feature request
description: Request a new feature.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
🧐 **Guidelines:**
- Search through [existing issues](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) first to ensure that this feature has not been requested before.
- Write a descriptive title for your issue. Avoid generic or vague titles such as "Some suggestions" or "Ideas for improvement".
- Keep your issue focused on one single problem. If you have multiple feature requests, please create separate issues for each of them.
- Provide as much context as possible in the details section. Include screenshots, screen recordings, links, references, or anything else you may consider relevant.
- If you want to ask a question instead of requesting a feature, please use [discussions](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
- type: dropdown
attributes:
label: Flavor
description: Is this feature request related to specific flavor(s) of DiscordChatExporter? If not applicable, leave this field blank.
multiple: true
options:
- GUI (Graphical User Interface)
- CLI (Command Line Interface)
- type: dropdown
attributes:
label: Export format
description: Is this feature request related to specific export format(s)? If not applicable, leave this field blank.
multiple: true
options:
- HTML
- TXT
- JSON
- CSV
- type: textarea
attributes:
label: Details
description: Clear and thorough explanation of the feature you have in mind.
validations:
required: true
+4 -6
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
@@ -34,7 +33,7 @@ jobs:
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
publish: package:
needs: test needs: test
runs-on: windows-latest runs-on: windows-latest
@@ -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
@@ -73,7 +71,7 @@ jobs:
deploy: deploy:
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
needs: publish needs: package
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+1
View File
@@ -5,6 +5,7 @@
*.sln.docstates *.sln.docstates
.idea/ .idea/
.vs/ .vs/
.vscode/
# Build results # Build results
[Dd]ebug/ [Dd]ebug/
+37 -3
View File
@@ -1,3 +1,36 @@
### v2.36.2 (08-Oct-2022)
- Removed the message content intent check because the heuristics turned out to be not reliable enough.
- Fixed an issue where the runtime manifest included the wrong target runtime version. This resulted in the application failing to launch for some users when upgrading from earlier versions.
### 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))
- [HTML] Added support for rendering system notification messages, such as when a user joins a server or when a messages gets pinned. Previously, such messages were rendered as regular text messages, but now they look similar to how they're presented in the Discord client. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
- [CLI] Improved the wording of the error message that's shown when exporting multiple channels and the provided output path is ambiguous. It now mentions that you can resolve the ambiguity by adding a slash at the end of the path.
### v2.35.2 (08-Sep-2022)
- Updated usage guide to mention the need to enable "Message Content Intent" to use a bot as a medium for exporting messages.
- [CLI] Fixed an issue where passing an existing directory as the output path failed when exporting multiple channels (for example, when running `exportdm`). An ambiguous output path will now always be treated as a directory path if that directory already exists. If it doesn't exist, it will be treated as a file path, matching the previous behavior.
### v2.35.1 (26-Aug-2022)
- Added `~` character as an alias for `-` when negating a message filter. This should make it easier to use negated filters from CLI where the dash character already has other meanings.
- Extended retry logic to encompass all server-side errors from Discord API, instead of only `500 Internal Server Error`.
- [CLI] Attempt to export multiple channels will now fail if provided with an output path that is neither a directory, nor a file name template. Previously, this resulted in all channels being exported to the same file, overwriting each other.
- Fixed an issue where a text message filter didn't work if it started with a character that's also the first character in one of the named filters (e.g. `from` or `mentions`).
- Fixed an issue where a text message filter didn't work correctly if it was enclosed in parentheses.
- Fixed an issue where categories were displayed alongside regular channels in the channel list, both in GUI and in CLI (when using the `guild` command).
- [CLI] Fixed an issue where an output path starting with a tilde on Linux was not expanded properly into a full path. (Thanks [@TSRBerry](https://github.com/TSRBerry))
### v2.35 (02-Jul-2022) ### v2.35 (02-Jul-2022)
- Added support for exporting text chat in voice channels. These channels are now also displayed when browsing in the GUI and in the CLI when executing the `channels` command. - Added support for exporting text chat in voice channels. These channels are now also displayed when browsing in the GUI and in the CLI when executing the `channels` command.
@@ -17,6 +50,7 @@
- Added file name template token that resolves to the current date (`%d`). (Thanks [@Lucas LaBuff](https://github.com/96-LB)) - Added file name template token that resolves to the current date (`%d`). (Thanks [@Lucas LaBuff](https://github.com/96-LB))
- Updated the usage guide to replace the instructions for retrieving the user token with those that appear to work for more people. - Updated the usage guide to replace the instructions for retrieving the user token with those that appear to work for more people.
- Updated the usage guide to feature the TOS warning more prominently. - Updated the usage guide to feature the TOS warning more prominently.
- [Docker] Changed internal working directory from `/app/out` to `/out`. This was an unintended breaking change. Please update your volume binding configuration from `-v /path/on/machine:/app/out` to `-v /path/on/machine:/out`.
### v2.34.1 (01-Jun-2022) ### v2.34.1 (01-Jun-2022)
@@ -86,7 +120,7 @@
- [HTML] Added special casing for Spotify track embeds to render them directly using an embedded player. - [HTML] Added special casing for Spotify track embeds to render them directly using an embedded player.
- [HTML] Added special casing for plain image embeds to render them like image attachments. - [HTML] Added special casing for plain image embeds to render them like image attachments.
- [HTML] Added tooltip text for unix timestamp markers which shows the full date represented by the timestamp, regardless of configured date format. - [HTML] Added tooltip text for unix timestamp markers which shows the full date represented by the timestamp, regardless of configured date format.
- [HTML] Updated the fallback text shown when the referenced message could not be loaded to make it more explicit. - [HTML] Updated the fallback text shown when the referenced message could not be loaded to make it more explicit.
- [HTML] Updated styling of quoted text in markdown to more closely resemble how it looks in Discord. - [HTML] Updated styling of quoted text in markdown to more closely resemble how it looks in Discord.
- [HTML] Fixed an issue where some file formats were not correctly recognized as image or video attachments, which affected how they were rendered. - [HTML] Fixed an issue where some file formats were not correctly recognized as image or video attachments, which affected how they were rendered.
@@ -113,7 +147,7 @@
- [HTML] Added special casing for YouTube video embeds to render them directly using an embedded player. (Thanks [@quentinmay](https://github.com/quentinmay)) - [HTML] Added special casing for YouTube video embeds to render them directly using an embedded player. (Thanks [@quentinmay](https://github.com/quentinmay))
- [HTML] Added support for rendering standard emoji by code. Such emoji may sometimes be found in messages sent by bots or through webhooks. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB)) - [HTML] Added support for rendering standard emoji by code. Such emoji may sometimes be found in messages sent by bots or through webhooks. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB))
- [HTML] Changed tooltips on standard emoji to show emoji code instead of their raw string representations. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB)) - [HTML] Changed tooltips on standard emoji to show emoji code instead of their raw string representations. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB))
- [HTML] Added tooltips on individual messages to show when those messages were sent. - [HTML] Added tooltips on individual messages to show when those messages were sent.
- [HTML] Updated colors and minor styling elements to match Discord's new style direction after rebranding. - [HTML] Updated colors and minor styling elements to match Discord's new style direction after rebranding.
- [HTML] Updated Twemoji URLs to use SVG image variants for better rendering quality. - [HTML] Updated Twemoji URLs to use SVG image variants for better rendering quality.
- [HTML] Changed user avatar URLs to include predetermined size query parameter for better rendering quality. - [HTML] Changed user avatar URLs to include predetermined size query parameter for better rendering quality.
@@ -209,7 +243,7 @@
- Added a new option that enables self-contained exports for all output formats. You can turn it on in the export setup dialog in GUI or using the `--media` option in CLI. When using this, the application will additionally download any media content directly referenced from the exported file instead of linking back to Discord CDN. The files which are downloaded include: guild icons, user avatars, attachments, embedded images, reaction emoji. Note that only files which are actually referenced by the export are downloaded, which means that, for example, user avatars will not be downloaded when using plain text export format. This option is not meant to enable complete offline viewing for HTML exports, but rather to make it easier to archive media content that may eventually get deleted from Discord servers. Also keep in mind that this option may make the export drastically slower and the total file size larger. - Added a new option that enables self-contained exports for all output formats. You can turn it on in the export setup dialog in GUI or using the `--media` option in CLI. When using this, the application will additionally download any media content directly referenced from the exported file instead of linking back to Discord CDN. The files which are downloaded include: guild icons, user avatars, attachments, embedded images, reaction emoji. Note that only files which are actually referenced by the export are downloaded, which means that, for example, user avatars will not be downloaded when using plain text export format. This option is not meant to enable complete offline viewing for HTML exports, but rather to make it easier to archive media content that may eventually get deleted from Discord servers. Also keep in mind that this option may make the export drastically slower and the total file size larger.
- Changed "discordapp.com" to "discord.com" where applicable as Discord is migrating to a new domain. CDN will remain on "cdn.discordapp.com" for the foreseeable future. - Changed "discordapp.com" to "discord.com" where applicable as Discord is migrating to a new domain. CDN will remain on "cdn.discordapp.com" for the foreseeable future.
Note that all existing and current HTML exports will likely not render accurately because Discord enabled CORS for their font resources, which prevents them from loading locally. Please refer to [issue #322](https://github.com/Tyrrrz/DiscordChatExporter/issues/322) for discussion on this topic. Note that all existing and current HTML exports will likely not render accurately because Discord enabled CORS for their font resources, which prevents them from loading locally. Please refer to [issue #322](https://github.com/Tyrrrz/DiscordChatExporter/issues/322) for discussion on this topic.
### v2.20 (27-Apr-2020) ### v2.20 (27-Apr-2020)
+9 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>2.35</Version> <Version>2.36.2</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>
@@ -10,4 +10,12 @@
<WarningsAsErrors>nullable</WarningsAsErrors> <WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<!--
Even though the project builds against .NET 6, some dependencies
apparently rely on a specific version of the runtime.
-->
<PropertyGroup>
<RuntimeFrameworkVersion>6.0.9</RuntimeFrameworkVersion>
</PropertyGroup>
</Project> </Project>
@@ -15,9 +15,9 @@
<PackageReference Include="FluentAssertions" Version="6.7.0" /> <PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.0" />
<PackageReference Include="System.Reactive" Version="5.0.0" /> <PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
@@ -54,8 +54,9 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
message message
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("i.redd.it/f8w05ja8s4e61.png") ?? false)
.Should() .Should()
.Contain("https://i.redd.it/f8w05ja8s4e61.png"); .ContainSingle();
} }
[Fact] [Fact]
@@ -74,6 +75,38 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
content.Should().BeNullOrEmpty(); content.Should().BeNullOrEmpty();
} }
[Fact]
public async Task Message_containing_a_gifv_link_is_rendered_with_a_video_embed()
{
// Act
var message = await _exportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1019234520349814794")
);
// Assert
message
.QuerySelectorAll("source")
.Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("media.tenor.com/DDAJeW6BQKkAAAPo/tooncasm-test-copy.mp4") ?? false)
.Should()
.ContainSingle();
}
[Fact]
public async Task Message_containing_a_gifv_link_and_nothing_else_is_rendered_without_text_content()
{
// Act
var message = await _exportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1019234520349814794")
);
// Assert
var content = message.QuerySelector(".chatlog__content")?.Text();
content.Should().BeNullOrEmpty();
}
[Fact] [Fact]
public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed() public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed()
{ {
@@ -14,18 +14,27 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils;
using Gress; using Gress;
namespace DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class ExportCommandBase : TokenCommandBase public abstract class ExportCommandBase : TokenCommandBase
{ {
private readonly string _outputPath = Directory.GetCurrentDirectory();
[CommandOption( [CommandOption(
"output", "output",
'o', 'o',
Description = "Output file or directory path." Description = "Output file or directory path. Directory path should end in a slash."
)] )]
public string OutputPath { get; init; } = Directory.GetCurrentDirectory(); public string OutputPath
{
get => _outputPath;
// Handle ~/ in paths on *nix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _outputPath = Path.GetFullPath(value);
}
[CommandOption( [CommandOption(
"format", "format",
@@ -88,13 +97,36 @@ public abstract class ExportCommandBase : TokenCommandBase
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
var cancellationToken = console.RegisterCancellationHandler(); // Reuse media option should only be used when the media option is set.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseMedia && !ShouldDownloadMedia) if (ShouldReuseMedia && !ShouldDownloadMedia)
{ {
throw new CommandException("Option --reuse-media cannot be used without --media."); throw new CommandException(
"Option --reuse-media cannot be used without --media."
);
} }
// Make sure the user does not try to export all channels into a single file.
// Output path must either be a directory, or contain template tokens.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath =
// Anything is valid when exporting a single channel
channels.Count <= 1 ||
// When using template tokens, assume the user knows what they're doing
OutputPath.Contains('%') ||
// Otherwise, require an existing directory or an unambiguous directory path
Directory.Exists(OutputPath) || PathEx.IsDirectoryPath(OutputPath);
if (!isValidOutputPath)
{
throw new CommandException(
"Attempted to export multiple channels, but the output path is neither a directory nor a template. " +
"If the provided output path is meant to be treated as a directory, make sure it ends with a slash."
);
}
var cancellationToken = console.RegisterCancellationHandler();
var errors = new ConcurrentDictionary<Channel, string>(); var errors = new ConcurrentDictionary<Channel, string>();
// Export // Export
@@ -1,4 +1,5 @@
using System.Threading.Tasks; using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
@@ -17,7 +18,10 @@ public class ExportDirectMessagesCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels..."); await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.ToArray();
await base.ExecuteAsync(console, channels); await base.ExecuteAsync(console, channels);
} }
@@ -1,8 +1,10 @@
using System.Threading.Tasks; using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
@@ -25,7 +27,10 @@ public class ExportGuildCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels..."); await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.ToArray();
await base.ExecuteAsync(console, channels); await base.ExecuteAsync(console, channels);
} }
@@ -5,6 +5,7 @@ using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
@@ -25,6 +26,7 @@ public class GetChannelsCommand : TokenCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken)) var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.OrderBy(c => c.Category.Position) .OrderBy(c => c.Category.Position)
.ThenBy(c => c.Name) .ThenBy(c => c.Name)
.ToArray(); .ToArray();
@@ -17,6 +17,7 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken)) var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.OrderByDescending(c => c.LastMessageId) .OrderByDescending(c => c.LastMessageId)
.ThenBy(c => c.Name) .ThenBy(c => c.Name)
.ToArray(); .ToArray();
@@ -17,13 +17,14 @@ public class GuideCommand : ICommand
console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"); console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!");
console.Output.WriteLine(" 1. Open Discord in your web browser and login"); console.Output.WriteLine(" 1. Open Discord in your web browser and login");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 2. Open any server or direct message channel");
console.Output.WriteLine(" 3. Navigate to the Network tab"); console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 4. Press Ctrl+R to reload"); console.Output.WriteLine(" 4. Navigate to the Network tab");
console.Output.WriteLine(" 5. Search for a request containing \"messages?limit=50\" or similar"); console.Output.WriteLine(" 5. Press Ctrl+R to reload");
console.Output.WriteLine(" 6. Select the Headers tab on the right"); console.Output.WriteLine(" 6. Search for a request containing \"messages?limit=50\" or similar");
console.Output.WriteLine(" 7. Scroll down to the Request Headers section"); console.Output.WriteLine(" 7. Select the Headers tab on the right");
console.Output.WriteLine(" 8. Copy the value of the \"authorization\" header"); console.Output.WriteLine(" 8. Scroll down to the Request Headers section");
console.Output.WriteLine(" 9. Copy the value of the \"authorization\" header");
console.Output.WriteLine(); console.Output.WriteLine();
// Bot token // Bot token
@@ -34,6 +35,7 @@ public class GuideCommand : ICommand
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Copy");
console.Output.WriteLine(" * Your bot needs to have Message Content Intent enabled to read messages");
console.Output.WriteLine(); console.Output.WriteLine();
// Guild or channel ID // Guild or channel ID
@@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.2.5" /> <PackageReference Include="CliFx" Version="2.3.0" />
<PackageReference Include="Spectre.Console" Version="0.44.0" /> <PackageReference Include="Spectre.Console" Version="0.44.0" />
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
@@ -11,6 +11,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object // https://discord.com/developers/docs/resources/channel#embed-object
public partial record Embed( public partial record Embed(
string? Title, string? Title,
EmbedKind Kind,
string? Url, string? Url,
DateTimeOffset? Timestamp, DateTimeOffset? Timestamp,
Color? Color, Color? Color,
@@ -19,11 +20,9 @@ public partial record Embed(
IReadOnlyList<EmbedField> Fields, IReadOnlyList<EmbedField> Fields,
EmbedImage? Thumbnail, EmbedImage? Thumbnail,
IReadOnlyList<EmbedImage> Images, IReadOnlyList<EmbedImage> Images,
EmbedVideo? Video,
EmbedFooter? Footer) EmbedFooter? Footer)
{ {
public PlainImageEmbedProjection? TryGetPlainImage() =>
PlainImageEmbedProjection.TryResolve(this);
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
SpotifyTrackEmbedProjection.TryResolve(this); SpotifyTrackEmbedProjection.TryResolve(this);
@@ -36,9 +35,20 @@ public partial record Embed
public static Embed Parse(JsonElement json) public static Embed Parse(JsonElement json)
{ {
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset(); var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
var color = json
.GetPropertyOrNull("color")?
.GetInt32OrNull()?
.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse); var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
var description = json.GetPropertyOrNull("description")?.GetStringOrNull(); var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
@@ -59,10 +69,13 @@ public partial record Embed
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ?? json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ??
Array.Empty<EmbedImage>(); Array.Empty<EmbedImage>();
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse); var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
return new Embed( return new Embed(
title, title,
kind,
url, url,
timestamp, timestamp,
color, color,
@@ -71,6 +84,7 @@ public partial record Embed
fields, fields,
thumbnail, thumbnail,
images, images,
video,
footer footer
); );
} }
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-types
public enum EmbedKind
{
Rich,
Image,
Video,
Gifv,
Link
}
@@ -0,0 +1,22 @@
using JsonExtensions.Reading;
using System.Text.Json;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure
public record EmbedVideo(
string? Url,
string? ProxyUrl,
int? Width,
int? Height)
{
public static EmbedVideo Parse(JsonElement json)
{
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetNonWhiteSpaceStringOrNull();
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
return new EmbedVideo(url, proxyUrl, width, height);
}
}
@@ -1,33 +0,0 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public record PlainImageEmbedProjection(string Url)
{
public static PlainImageEmbedProjection? TryResolve(Embed embed)
{
if (string.IsNullOrWhiteSpace(embed.Url))
return null;
// Has to be an embed without any data (except URL and image)
if (!string.IsNullOrWhiteSpace(embed.Title) ||
embed.Timestamp is not null ||
embed.Author is not null ||
!string.IsNullOrWhiteSpace(embed.Description) ||
embed.Fields.Any() ||
embed.Footer is not null)
{
return null;
}
// Has to be an image file
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
return null;
return new PlainImageEmbedProjection(embed.Url);
}
}
@@ -21,6 +21,9 @@ public partial record SpotifyTrackEmbedProjection
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed) public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
{ {
if (embed.Kind != EmbedKind.Link)
return null;
if (string.IsNullOrWhiteSpace(embed.Url)) if (string.IsNullOrWhiteSpace(embed.Url))
return null; return null;
@@ -42,6 +42,9 @@ public partial record YouTubeVideoEmbedProjection
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed) public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
{ {
if (embed.Kind != EmbedKind.Video)
return null;
if (string.IsNullOrWhiteSpace(embed.Url)) if (string.IsNullOrWhiteSpace(embed.Url))
return null; return null;
+12 -10
View File
@@ -11,12 +11,12 @@ namespace DiscordChatExporter.Core.Discord.Data;
public partial record Emoji( public partial record Emoji(
// Only present on custom emoji // Only present on custom emoji
Snowflake? Id, Snowflake? Id,
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂) // Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
string Name, string Name,
bool IsAnimated, bool IsAnimated,
string ImageUrl) string ImageUrl)
{ {
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile) // Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => Id is not null public string Code => Id is not null
? Name ? Name
: EmojiIndex.TryGetCode(Name) ?? Name; : EmojiIndex.TryGetCode(Name) ?? Name;
@@ -24,17 +24,19 @@ public partial record Emoji(
public partial record Emoji public partial record Emoji
{ {
private static string GetTwemojiName(string name) private static string GetTwemojiId(string name)
{ {
var runes = name.GetRunes(); var runes = name.GetRunes().ToArray();
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner.
// VS = 0xfe0f; ZWJ = 0x200d.
var filteredRunes = runes.Any(r => r.Value == 0x200d)
? runes
: runes.Where(r => r.Value != 0xfe0f);
return string.Join( return string.Join(
"-", "-",
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner. filteredRunes.Select(r => r.Value.ToString("x"))
// VS = 0xfe0f. ZWJ = 0x200d.
runes.Any(r => r.Value == 0x200d)
? runes.Select(r => r.Value.ToString("x"))
: runes.Where(r => r.Value != 0xfe0f).Select(r => r.Value.ToString("x"))
); );
} }
@@ -43,7 +45,7 @@ public partial record Emoji
: $"https://cdn.discordapp.com/emojis/{id}.png"; : $"https://cdn.discordapp.com/emojis/{id}.png";
private static string GetImageUrl(string name) => private static string GetImageUrl(string name) =>
$"https://twemoji.maxcdn.com/2/svg/{GetTwemojiName(name)}.svg"; $"https://twemoji.maxcdn.com/v/latest/svg/{GetTwemojiId(name)}.svg";
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated) public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{ {
+10 -3
View File
@@ -1,4 +1,5 @@
using System.Text.Json; using System;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@@ -17,8 +18,14 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
private static string GetDefaultIconUrl() => private static string GetDefaultIconUrl() =>
"https://cdn.discordapp.com/embed/avatars/0.png"; "https://cdn.discordapp.com/embed/avatars/0.png";
private static string GetIconUrl(Snowflake id, string iconHash) => private static string GetIconUrl(Snowflake id, string iconHash)
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"; {
var extension = iconHash.StartsWith("a_", StringComparison.Ordinal)
? "gif"
: "png";
return $"https://cdn.discordapp.com/icons/{id}/{iconHash}.{extension}";
}
public static Guild Parse(JsonElement json) public static Guild Parse(JsonElement json)
{ {
@@ -44,7 +44,7 @@ public record Message(
{ {
var embed = embeds[i]; var embed = embeds[i];
if (embed.Url?.Contains("://twitter.com/") == true) if (embed.Url?.Contains("://twitter.com/", StringComparison.OrdinalIgnoreCase) == true)
{ {
// Find embeds with the same URL that only contain a single image and nothing else // Find embeds with the same URL that only contain a single image and nothing else
var trailingEmbeds = embeds var trailingEmbeds = embeds
@@ -107,6 +107,7 @@ public record Message(
MessageKind.ChannelNameChange => "Changed the channel name.", MessageKind.ChannelNameChange => "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.", MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.", MessageKind.GuildMemberJoin => "Joined the server.",
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "" _ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
}; };
@@ -11,5 +11,11 @@ public enum MessageKind
ChannelIconChange = 5, ChannelIconChange = 5,
ChannelPinnedMessage = 6, ChannelPinnedMessage = 6,
GuildMemberJoin = 7, GuildMemberJoin = 7,
ThreadCreated = 18,
Reply = 19 Reply = 19
}
public static class MessageKindExtensions
{
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
} }
@@ -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(
@@ -2,7 +2,6 @@
public enum TokenKind public enum TokenKind
{ {
Unknown,
User, User,
Bot Bot
} }
@@ -7,10 +7,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.1" /> <PackageReference Include="MiniRazor.CodeGen" Version="2.2.2" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.9.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.12.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -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,11 +10,19 @@ internal class ContainsMessageFilter : MessageFilter
public ContainsMessageFilter(string text) => _text = text; public ContainsMessageFilter(string text) => _text = text;
// Match content within word boundaries, between spaces, or as the whole input.
// For example, "max" shouldn't match on content "our maximum effort",
// but should match on content "our max effort".
// Also, "(max)" should match on content "our (max) effort", even though
// parentheses are not considered word characters.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909
private bool IsMatch(string? content) => private bool IsMatch(string? content) =>
!string.IsNullOrWhiteSpace(content) && !string.IsNullOrWhiteSpace(content) &&
Regex.IsMatch( Regex.IsMatch(
content, content,
"\\b" + Regex.Escape(_text) + "\\b", @"(?:\b|\s|^)" +
Regex.Escape(_text) +
@"(?:\b|\s|$)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
); );
@@ -15,95 +15,107 @@ internal static class FilterGrammar
from close in Character.EqualTo(open) from close in Character.EqualTo(open)
select value; select value;
private static readonly TextParser<char> FreeCharacter =
Character.Matching(c =>
!char.IsWhiteSpace(c) &&
// Avoid all special tokens used by the grammar
c is not ('(' or ')' or '"' or '\'' or '-' or '|' or '&'),
"any character except whitespace or `(`, `)`, `\"`, `'`, `-`, `|`, `&`"
);
private static readonly TextParser<string> UnquotedString = private static readonly TextParser<string> UnquotedString =
Parse.OneOf(EscapedCharacter, FreeCharacter).AtLeastOnce().Text(); Parse.OneOf(
EscapedCharacter,
// Avoid whitespace as it's treated as an implicit 'and' operator.
// Also avoid all special tokens used by other parsers.
Character.ExceptIn(' ', '(', ')', '"', '\'', '-', '~', '|', '&')
).AtLeastOnce().Text();
private static readonly TextParser<string> String = private static readonly TextParser<string> String =
Parse.OneOf(QuotedString, UnquotedString).Named("text string"); Parse.OneOf(QuotedString, UnquotedString).Named("text string");
private static readonly TextParser<MessageFilter> ContainsFilter = private static readonly TextParser<MessageFilter> ContainsFilter =
String.Select(v => (MessageFilter) new ContainsMessageFilter(v)); String.Select(v => (MessageFilter)new ContainsMessageFilter(v));
private static readonly TextParser<MessageFilter> FromFilter = Span private static readonly TextParser<MessageFilter> FromFilter =
.EqualToIgnoreCase("from:") Span
.IgnoreThen(String) .EqualToIgnoreCase("from:")
.Select(v => (MessageFilter) new FromMessageFilter(v)) .Try()
.Named("from:<value>"); .IgnoreThen(String)
.Select(v => (MessageFilter)new FromMessageFilter(v))
.Named("from:<value>");
private static readonly TextParser<MessageFilter> MentionsFilter = Span private static readonly TextParser<MessageFilter> MentionsFilter =
.EqualToIgnoreCase("mentions:") Span
.IgnoreThen(String) .EqualToIgnoreCase("mentions:")
.Select(v => (MessageFilter) new MentionsMessageFilter(v)) .Try()
.Named("mentions:<value>"); .IgnoreThen(String)
.Select(v => (MessageFilter)new MentionsMessageFilter(v))
.Named("mentions:<value>");
private static readonly TextParser<MessageFilter> ReactionFilter = Span private static readonly TextParser<MessageFilter> ReactionFilter =
.EqualToIgnoreCase("reaction:") Span
.IgnoreThen(String) .EqualToIgnoreCase("reaction:")
.Select(v => (MessageFilter) new ReactionMessageFilter(v)) .Try()
.Named("reaction:<value>"); .IgnoreThen(String)
.Select(v => (MessageFilter)new ReactionMessageFilter(v))
.Named("reaction:<value>");
private static readonly TextParser<MessageFilter> HasFilter = Span private static readonly TextParser<MessageFilter> HasFilter =
.EqualToIgnoreCase("has:") Span
.IgnoreThen(Parse.OneOf( .EqualToIgnoreCase("has:")
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)), .Try()
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)), .IgnoreThen(Parse.OneOf(
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)), Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)), Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)), Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)), Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin)) Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
)) Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
.Select(k => (MessageFilter) new HasMessageFilter(k)) Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
.Named("has:<value>"); ))
.Select(k => (MessageFilter)new HasMessageFilter(k))
.Named("has:<value>");
private static readonly TextParser<MessageFilter> NegatedFilter = Character // Make sure that property-based filters like 'has:link' don't prevent text like 'hello' from being parsed.
.EqualTo('-') // https://github.com/Tyrrrz/DiscordChatExporter/issues/909#issuecomment-1227575455
.IgnoreThen(Parse.Ref(() => StandaloneFilter!)) private static readonly TextParser<MessageFilter> PrimitiveFilter =
.Select(f => (MessageFilter) new NegatedMessageFilter(f)); Parse.OneOf(
FromFilter,
MentionsFilter,
ReactionFilter,
HasFilter,
ContainsFilter
);
private static readonly TextParser<MessageFilter> GroupedFilter = private static readonly TextParser<MessageFilter> GroupedFilter =
from open in Character.EqualTo('(') from open in Character.EqualTo('(')
from content in Parse.Ref(() => BinaryExpressionFilter!).Token() from content in Parse.Ref(() => ChainedFilter!).Token()
from close in Character.EqualTo(')') from close in Character.EqualTo(')')
select content; select content;
private static readonly TextParser<MessageFilter> StandaloneFilter = Parse.OneOf( private static readonly TextParser<MessageFilter> NegatedFilter =
GroupedFilter, Character
FromFilter, // Dash is annoying to use from CLI due to conflicts with options, so we provide tilde as an alias
MentionsFilter, .In('-', '~')
ReactionFilter, .IgnoreThen(Parse.OneOf(GroupedFilter, PrimitiveFilter))
HasFilter, .Select(f => (MessageFilter)new NegatedMessageFilter(f));
ContainsFilter
);
private static readonly TextParser<MessageFilter> UnaryExpressionFilter = Parse.OneOf( private static readonly TextParser<MessageFilter> ChainedFilter =
NegatedFilter, Parse.Chain(
StandaloneFilter // Operator
); Parse.OneOf(
// Explicit operator
private static readonly TextParser<MessageFilter> BinaryExpressionFilter = Parse.Chain( Character.In('|', '&').Token().Try(),
Parse.OneOf( // Implicit operator (resolves to 'and')
// Explicit operator Character.EqualTo(' ').AtLeastOnce().IgnoreThen(Parse.Return(' '))
Character.In('|', '&').Token().Try(), ),
// Implicit operator (resolves to 'and') // Operand
Character.WhiteSpace.AtLeastOnce().IgnoreThen(Parse.Return(' ')) Parse.OneOf(
), NegatedFilter,
UnaryExpressionFilter, GroupedFilter,
(op, left, right) => op switch PrimitiveFilter
{ ),
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or), // Reducer
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And) (op, left, right) => op switch
} {
); '|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
}
);
public static readonly TextParser<MessageFilter> Filter = public static readonly TextParser<MessageFilter> Filter =
BinaryExpressionFilter.Token().AtEnd(); ChainedFilter.Token().AtEnd();
} }
@@ -0,0 +1,22 @@
using System;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
internal static class MessageExtensions
{
// Message content is hidden if it's a link to an embedded media
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
public static bool IsContentHidden(this Message message)
{
if (message.Embeds.Count != 1)
return false;
var embed = message.Embeds[0];
return
string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) &&
embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
}
}
@@ -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);
@@ -48,438 +48,478 @@
{ {
var isFirst = i == 0; var isFirst = i == 0;
// Hide message content if it only contains a link to an image which is embedded, and nothing else
var isContentHidden =
message.Embeds.Count == 1 &&
message.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.Embeds.Single())?.Url;
var isReferencedContentHidden =
message.ReferencedMessage is not null &&
message.ReferencedMessage.Embeds.Count == 1 &&
message.ReferencedMessage.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.ReferencedMessage.Embeds.Single())?.Url;
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id"> <div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message"> <div class="chatlog__message">
@{/* Left side */} @{/* System notification */}
<div class="chatlog__message-aside"> @if (message.Kind.IsSystemNotification())
@if (isFirst) {
{ // System notifications are grouped even if the message author is different.
// Reference symbol // That's why we have to update the user values with the author of the current message.
if (message.Reference is not null) userMember = Model.ExportContext.TryGetMember(message.Author.Id);
{ userColor = Model.ExportContext.TryGetUserColor(message.Author.Id);
<div class="chatlog__reference-symbol"></div> userNick = message.Author.IsBot
} ? message.Author.Name
: userMember?.Nick ?? message.Author.Name;
// Avatar <div class="chatlog__message-aside">
<img class="chatlog__avatar" src="@await ResolveUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
} </div>
else
{
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
}
</div>
@{/* Right side */} <div class="chatlog__message-primary">
<div class="chatlog__message-primary"> @{/* Author name */}
@if (isFirst) <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>
{
// Reference
if (message.Reference is not null)
{
<div class="chatlog__reference">
@if (message.ReferencedMessage is not null)
{
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
<div class="chatlog__reference-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !isReferencedContentHidden)
{
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore-->
}
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
{
<em>Click to see attachment</em>
<span>🖼️</span>
}
else
{
<em>Click to see original message</em>
}
</span>
@if (message.ReferencedMessage.EditedTimestamp is not null) @{/* System notification content */}
{ <span class="chatlog__system-notification-content">
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span> @if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
}
</div>
}
else
{
<div class="chatlog__reference-unknown">
Original message was deleted or could not be loaded.
</div>
}
</div>
}
// Header
<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>
@{/* Bot label */}
@if (message.Author.IsBot)
{ {
<span class="chatlog__bot-label">BOT</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>
@{/* Timestamp */}
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
</div>
}
@{/* Content */}
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
{
<div class="chatlog__content chatlog__markdown">
@{/* Text */}
@if (!isContentHidden)
{
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
}
@{/* Edited timestamp */}
@if (message.EditedTimestamp is not null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
@{/* Attachments */}
@foreach (var attachment in message.Attachments)
{
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
@{/* Spoiler caption */}
@if (attachment.IsSpoiler)
{
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
}
@{/* Attachment preview */}
@if (attachment.IsImage)
{
<a href="@await ResolveUrlAsync(attachment.Url)">
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a>
}
else if (attachment.IsVideo)
{
<video class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
</video>
}
else if (attachment.IsAudio)
{
<audio class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio>
} }
else else
{ {
<div class="chatlog__attachment-generic"> <span>@message.Content.ToLowerInvariant()</span>
<svg class="chatlog__attachment-generic-icon"> }
<use href="#attachment-icon"/> </span>
</svg>
<div class="chatlog__attachment-generic-name"> @{/* Timestamp */}
<a href="@await ResolveUrlAsync(attachment.Url)"> <span class="chatlog__system-notification-timestamp">
@attachment.FileName <a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</a> </span>
</div> </div>
<div class="chatlog__attachment-generic-size"> }
@attachment.FileSize // Regular message
</div> else
{
<div class="chatlog__message-aside">
@if (isFirst)
{
// Reference symbol
if (message.Reference is not null)
{
<div class="chatlog__reference-symbol"></div>
}
// Avatar
<img class="chatlog__avatar" src="@await ResolveUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
}
else
{
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
}
</div>
<div class="chatlog__message-primary">
@if (isFirst)
{
// Reference
if (message.Reference is not null)
{
<div class="chatlog__reference">
@if (message.ReferencedMessage is not null)
{
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
<div class="chatlog__reference-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
{
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore-->
}
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
{
<em>Click to see attachment</em>
<span>🖼️</span>
}
else
{
<em>Click to see original message</em>
}
</span>
@if (message.ReferencedMessage.EditedTimestamp is not null)
{
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
else
{
<div class="chatlog__reference-unknown">
Original message was deleted or could not be loaded.
</div>
}
</div> </div>
} }
</div>
}
@{/* Embeds */} // Header
@foreach (var embed in message.Embeds) <div class="chatlog__header">
{ @{/* Author name */}
// Plain image embed <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>
if (embed.TryGetPlainImage() is { } plainImageEmbed)
{ @{/* Bot label */}
<div class="chatlog__embed"> @if (message.Author.IsBot)
<a href="@await ResolveUrlAsync(plainImageEmbed.Url)">
<img class="chatlog__embed-plainimage" src="@await ResolveUrlAsync(plainImageEmbed.Url)" alt="Embedded image" loading="lazy">
</a>
</div>
}
// Spotify embed
else if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
{
<div class="chatlog__embed">
<div class="chatlog__embed-spotify-container">
<iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
</div>
</div>
}
// YouTube embed
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
{
<div class="chatlog__embed">
@{/* Color pill */}
@if (embed.Color is not null)
{ {
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div> <span class="chatlog__bot-label">BOT</span>
}
@{/* Timestamp */}
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
</div>
}
@{/* Content */}
@if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
{
<div class="chatlog__content chatlog__markdown">
@{/* Text */}
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
{
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
}
@{/* Edited timestamp */}
@if (message.EditedTimestamp is not null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
@{/* Attachments */}
@foreach (var attachment in message.Attachments)
{
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
@{/* Spoiler caption */}
@if (attachment.IsSpoiler)
{
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
}
@{/* Attachment preview */}
@if (attachment.IsImage)
{
<a href="@await ResolveUrlAsync(attachment.Url)">
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a>
}
else if (attachment.IsVideo)
{
<video class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
</video>
}
else if (attachment.IsAudio)
{
<audio class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio>
} }
else else
{ {
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div> <div class="chatlog__attachment-generic">
<svg class="chatlog__attachment-generic-icon">
<use href="#attachment-icon"/>
</svg>
<div class="chatlog__attachment-generic-name">
<a href="@await ResolveUrlAsync(attachment.Url)">
@attachment.FileName
</a>
</div>
<div class="chatlog__attachment-generic-size">
@attachment.FileSize
</div>
</div>
} }
</div>
}
<div class="chatlog__embed-content-container"> @{/* Embeds */}
<div class="chatlog__embed-content"> @foreach (var embed in message.Embeds)
<div class="chatlog__embed-text"> {
@{/* Embed author */} // Spotify embed
@if (embed.Author is not null) if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
{ {
<div class="chatlog__embed-author-container"> <div class="chatlog__embed">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) <div class="chatlog__embed-spotify-container">
{ <iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> </div>
} </div>
}
// YouTube embed
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
{
<div class="chatlog__embed">
@{/* Color pill */}
@if (embed.Color is not null)
{
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) <div class="chatlog__embed-content-container">
{ <div class="chatlog__embed-content">
if (!string.IsNullOrWhiteSpace(embed.Author.Url)) <div class="chatlog__embed-text">
@{/* Embed author */}
@if (embed.Author is not null)
{
<div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{ {
<a class="chatlog__embed-author-link" href="@embed.Author.Url"> <img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
<div class="chatlog__embed-author">@embed.Author.Name</div>
</a>
}
else
{
<div class="chatlog__embed-author">@embed.Author.Name</div> <div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</div>
}
@{/* Embed title */}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
</a> </a>
} }
else else
{ {
<div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</div>
}
@{/* Embed title */}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
</a> }
} </div>
else }
{
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
}
</div>
}
@{/* Video player */} @{/* Video player */}
<div class="chatlog__embed-youtube-container"> <div class="chatlog__embed-youtube-container">
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe> <iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> }
} // Generic image embed
// Generic embed else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
else {
{ <div class="chatlog__embed">
<div class="chatlog__embed"> <a href="@await ResolveUrlAsync(embed.Url)">
@{/* Color pill */} <img class="chatlog__embed-generic-image" src="@await ResolveUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
@if (embed.Color is not null) </a>
{ </div>
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div> }
} // Generic gifv embed
else else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Video?.Url))
{ {
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div> <div class="chatlog__embed">
} <video class="chatlog__embed-generic-gifv" loop width="@embed.Video.Width" height="@embed.Video.Height" onmouseover="this.play()" onmouseout="this.pause()">
<source src="@await ResolveUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
</video>
</div>
}
// Rich embed
else
{
<div class="chatlog__embed">
@{/* Color pill */}
@if (embed.Color is not null)
{
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
<div class="chatlog__embed-content-container"> <div class="chatlog__embed-content-container">
<div class="chatlog__embed-content"> <div class="chatlog__embed-content">
<div class="chatlog__embed-text"> <div class="chatlog__embed-text">
@{/* Embed author */} @{/* Embed author */}
@if (embed.Author is not null) @if (embed.Author is not null)
{ {
<div class="chatlog__embed-author-container"> <div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{ {
<a class="chatlog__embed-author-link" href="@embed.Author.Url"> <img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
<div class="chatlog__embed-author">@embed.Author.Name</div>
</a>
}
else
{
<div class="chatlog__embed-author">@embed.Author.Name</div> <div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</div>
}
@{/* Embed title */}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
</a> </a>
} }
else else
{ {
<div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</div>
}
@{/* Embed title */}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
</a> }
} </div>
else }
{
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
}
</div>
}
@{/* Embed description */} @{/* Embed description */}
@if (!string.IsNullOrWhiteSpace(embed.Description)) @if (!string.IsNullOrWhiteSpace(embed.Description))
{
<div class="chatlog__embed-description">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Description))<!--/wmm:ignore--></div>
</div>
}
@{/* Embed fields */}
@if (embed.Fields.Any())
{
<div class="chatlog__embed-fields">
@foreach (var field in embed.Fields)
{
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Name))<!--/wmm:ignore--></div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Value))<!--/wmm:ignore--></div>
</div>
}
</div>
}
</div>
}
</div>
@{/* Embed content */}
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
{ {
<div class="chatlog__embed-description"> <div class="chatlog__embed-thumbnail-container">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Description))<!--/wmm:ignore--></div> <a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
</div> <img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
} </a>
@{/* Embed fields */}
@if (embed.Fields.Any())
{
<div class="chatlog__embed-fields">
@foreach (var field in embed.Fields)
{
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Name))<!--/wmm:ignore--></div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Value))<!--/wmm:ignore--></div>
</div>
}
</div>
}
</div> </div>
} }
</div> </div>
@{/* Embed content */} @{/* Embed images */}
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url)) @if (embed.Images.Any())
{ {
<div class="chatlog__embed-thumbnail-container"> <div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)"> @foreach (var image in embed.Images)
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy"> {
</a> if (!string.IsNullOrWhiteSpace(image.Url))
{
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
</a>
</div>
}
}
</div>
}
@{/* Embed footer & icon */}
@if (embed.Footer is not null || embed.Timestamp is not null)
{
<div class="chatlog__embed-footer">
@{/* Footer icon */}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
}
<span class="chatlog__embed-footer-text">
@{/* Footer text */}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
{
@embed.Footer.Text
}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
{
@(" • ")
}
@{/* Embed timestamp */}
@if (embed.Timestamp is not null)
{
@FormatDate(embed.Timestamp.Value)
}
</span>
</div> </div>
} }
</div> </div>
@{/* Embed images */}
@if (embed.Images.Any())
{
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
@foreach (var image in embed.Images)
{
if (!string.IsNullOrWhiteSpace(image.Url))
{
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
</a>
</div>
}
}
</div>
}
@{/* Embed footer & icon */}
@if (embed.Footer is not null || embed.Timestamp is not null)
{
<div class="chatlog__embed-footer">
@{/* Footer icon */}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
}
<span class="chatlog__embed-footer-text">
@{/* Footer text */}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
{
@embed.Footer.Text
}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
{
@(" • ")
}
@{/* Embed timestamp */}
@if (embed.Timestamp is not null)
{
@FormatDate(embed.Timestamp.Value)
}
</span>
</div>
}
</div> </div>
}
}
@{/* Stickers */}
@foreach (var sticker in message.Stickers)
{
<div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
{
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker">
}
else if (sticker.Format == StickerFormat.Lottie)
{
<div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div>
}
</div> </div>
} }
}
@{/* Stickers */} @{/* Message reactions */}
@foreach (var sticker in message.Stickers) @if (message.Reactions.Any())
{ {
<div class="chatlog__sticker" title="@sticker.Name"> <div class="chatlog__reactions">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated) @foreach (var reaction in message.Reactions)
{ {
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker"> <div class="chatlog__reaction" title="@reaction.Emoji.Code">
} <img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
else if (sticker.Format == StickerFormat.Lottie) <span class="chatlog__reaction-count">@reaction.Count</span>
{ </div>
<div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div> }
} </div>
</div> }
} </div>
}
@{/* Message reactions */}
@if (message.Reactions.Any())
{
<div class="chatlog__reactions">
@foreach (var reaction in message.Reactions)
{
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
<img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
<span class="chatlog__reaction-count">@reaction.Count</span>
</div>
}
</div>
}
</div>
</div> </div>
</div> </div>
} }
@@ -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,6 +255,38 @@
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
} }
.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-link {
font-weight: 500;
color: @Themed("#ffffff", "#2f3136");
}
.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 { .chatlog__header {
margin-bottom: 0.1rem; margin-bottom: 0.1rem;
} }
@@ -286,7 +319,7 @@
} }
.chatlog__timestamp a { .chatlog__timestamp a {
color: @Themed("#a3a6aa", "#5e6772"); color: inherit;
} }
.chatlog__content { .chatlog__content {
@@ -540,7 +573,14 @@
font-weight: 500; font-weight: 500;
} }
.chatlog__embed-plainimage { .chatlog__embed-generic-image {
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-generic-gifv {
max-width: 45vw; max-width: 45vw;
max-height: 500px; max-height: 500px;
vertical-align: top; vertical-align: top;
@@ -725,18 +765,17 @@
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => { document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
const imageDataUrl = e.getAttribute('data-source');
const anim = lottie.loadAnimation({ const anim = lottie.loadAnimation({
container: e, container: e,
renderer: 'svg', renderer: 'svg',
loop: true, loop: true,
autoplay: true, autoplay: true,
path: imageDataUrl path: e.getAttribute('data-source')
}); });
anim.addEventListener('data_failed', () => anim.addEventListener(
e.innerHTML = '<strong>[Sticker cannot be rendered]</strong>' 'data_failed',
() => e.innerHTML = '<strong>[Sticker cannot be rendered]</strong>'
); );
}); });
}); });
@@ -745,7 +784,7 @@
@{/* Scripts */} @{/* Scripts */}
<script> <script>
function scrollToMessage(event, id) { function scrollToMessage(event, id) {
var element = document.getElementById('chatlog__message-container-' + id); const element = document.getElementById('chatlog__message-container-' + id);
if (!element) if (!element)
return; return;
@@ -757,9 +796,10 @@
behavior: 'smooth' behavior: 'smooth'
}); });
window.setTimeout(function() { window.setTimeout(
element.classList.remove('chatlog__message-container--highlighted'); () => element.classList.remove('chatlog__message-container--highlighted'),
}, 2000); 2000
);
} }
function showSpoiler(event, element) { function showSpoiler(event, element) {
@@ -779,13 +819,24 @@
</script> </script>
@{/* Icons */} @{/* Icons */}
<svg style="display: none"> <svg style="display: none" xmlns="http://www.w3.org/2000/svg">
<symbol id="attachment-icon" viewBox="0 0 720 960"> <defs>
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z"/> <symbol id="attachment-icon" viewBox="0 0 720 960">
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/> <path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" />
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/> <path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" />
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/> <path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" />
</symbol> <path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" />
</symbol>
<symbol id="channel-pinned-message-icon" viewBox="0 0 18 18">
<path fill="#b9bbbe" d="m16.908 8.39684-8.29587-8.295827-1.18584 1.184157 1.18584 1.18584-4.14834 4.1475v.00167l-1.18583-1.18583-1.185 1.18583 3.55583 3.55502-4.740831 4.74 1.185001 1.185 4.74083-4.74 3.55581 3.555 1.185-1.185-1.185-1.185 4.1475-4.14836h.0009l1.185 1.185z" />
</symbol>
<symbol id="call-icon" viewBox="0 0 18 18">
<path fill="#3ba55c" fill-rule="evenodd" d="M17.7163041 15.36645368c-.0190957.02699568-1.9039523 2.6680735-2.9957762 2.63320406-3.0676659-.09785935-6.6733809-3.07188394-9.15694343-5.548738C3.08002193 9.9740657.09772497 6.3791404 0 3.3061316v-.024746C0 2.2060575 2.61386252.3152347 2.64082114.2972376c.7110335-.4971705 1.4917101-.3149497 1.80959713.1372281.19320342.2744561 2.19712724 3.2811005 2.42290565 3.6489167.09884826.1608492.14714912.3554431.14714912.5702838 0 .2744561-.07975258.5770327-.23701117.8751101-.1527655.2902036-.65262318 1.1664385-.89862055 1.594995.2673396.3768148.94804468 1.26429792 2.351016 2.66357424 1.39173858 1.39027775 2.28923588 2.07641807 2.67002628 2.34187563.4302146-.2452108 1.3086162-.74238132 1.5972981-.89423205.5447887-.28682915 1.0907006-.31944893 1.4568885-.08661115.3459689.2182151 3.3383754 2.21027167 3.6225641 2.41611376.2695862.19234426.4144887.5399137.4144887.91672846 0 .2969525-.089862.61190215-.2808189.88523346" />
</symbol>
<symbol id="guild-member-join-icon" viewBox="0 0 18 18">
<path fill="#3ba55c" d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" />
</symbol>
</defs>
</svg> </svg>
</head> </head>
<body> <body>
@@ -25,17 +25,46 @@ internal class HtmlMessageWriter : MessageWriter
_themeName = themeName; _themeName = themeName;
} }
private bool CanJoinGroup(Message message) => private bool CanJoinGroup(Message message)
// First message in the group can always join {
_messageGroup.LastOrDefault() is not { } lastMessage || // If the group is empty, any message can join it
// Must be from the same author if (_messageGroup.LastOrDefault() is not { } lastMessage)
lastMessage.Author.Id == message.Author.Id && return true;
// Author's name must not have changed between messages
string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) && // Reply messages cannot join existing groups because they need to appear first
// Duration between messages must be 7 minutes or less if (message.Kind == MessageKind.Reply)
(message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 && return false;
// Other message must not be a reply
message.Reference is null; // Grouping for system notifications
if (message.Kind.IsSystemNotification())
{
// Can only be grouped with other system notifications
if (!lastMessage.Kind.IsSystemNotification())
return false;
}
// Grouping for normal messages
else
{
// Can only be grouped with other normal messages
if (lastMessage.Kind.IsSystemNotification())
return false;
// Messages must be within 7 minutes of each other
if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7)
return false;
// Messages must be from the same author
if (message.Author.Id != lastMessage.Author.Id)
return false;
// If the user changed their name after the last message, their new messages
// cannot join an existing group.
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
return false;
}
return true;
}
// Use <!--wmm:ignore--> to preserve blocks of code inside the templates // Use <!--wmm:ignore--> to preserve blocks of code inside the templates
private string Minify(string html) => _minifier private string Minify(string html) => _minifier
@@ -77,7 +106,7 @@ internal class HtmlMessageWriter : MessageWriter
await base.WriteMessageAsync(message, cancellationToken); await base.WriteMessageAsync(message, cancellationToken);
// If the message can be grouped, buffer it for now // If the message can be grouped, buffer it for now
if (CanJoinGroup( message)) if (CanJoinGroup(message))
{ {
_messageGroup.Add(message); _messageGroup.Add(message);
} }
@@ -6,13 +6,13 @@ namespace DiscordChatExporter.Core.Markdown;
internal record EmojiNode( internal record EmojiNode(
// Only present on custom emoji // Only present on custom emoji
Snowflake? Id, Snowflake? Id,
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂) // Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
string Name, string Name,
bool IsAnimated) : MarkdownNode bool IsAnimated) : MarkdownNode
{ {
public bool IsCustomEmoji => Id is not null; public bool IsCustomEmoji => Id is not null;
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile) // Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => IsCustomEmoji public string Code => IsCustomEmoji
? Name ? Name
: EmojiIndex.TryGetCode(Name) ?? Name; : EmojiIndex.TryGetCode(Name) ?? Name;
@@ -1,5 +1,7 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
@@ -25,6 +27,14 @@ 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) => public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 builder.Length > 0
? builder.Append(value) ? builder.Append(value)
+7 -5
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;
@@ -13,14 +14,15 @@ public static class Http
{ {
public static HttpClient Client { get; } = new(); public static HttpClient Client { get; } = new();
private static bool IsRetryableStatusCode(HttpStatusCode statusCode) => statusCode is private static bool IsRetryableStatusCode(HttpStatusCode statusCode) =>
HttpStatusCode.TooManyRequests or statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout ||
HttpStatusCode.RequestTimeout or // Treat all server-side errors as retryable.
HttpStatusCode.InternalServerError; // https://github.com/Tyrrrz/DiscordChatExporter/issues/908
(int)statusCode >= 500;
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)
); );
+4
View File
@@ -17,4 +17,8 @@ public static class PathEx
return buffer.ToString(); return buffer.ToString();
} }
public static bool IsDirectoryPath(string path) =>
path.EndsWith(Path.DirectorySeparatorChar) ||
path.EndsWith(Path.AltDirectorySeparatorChar);
} }
@@ -105,7 +105,11 @@ public class DashboardViewModel : PropertyChangedBase
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>(); var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync()) await foreach (var guild in discord.GetUserGuildsAsync())
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id); {
guildChannelMap[guild] = (await discord.GetGuildChannelsAsync(guild.Id))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.ToArray();
}
_discord = discord; _discord = discord;
GuildChannelMap = guildChannelMap; GuildChannelMap = guildChannelMap;
@@ -135,22 +135,14 @@
Grid.Row="2" Grid.Row="2"
Background="{DynamicResource MaterialDesignCardBackground}" Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}"> IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="Light" />
</Style>
</Grid.Resources>
<!-- Placeholder / usage instructions --> <!-- Placeholder / usage instructions -->
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock <TextBlock
Margin="32,16" Margin="32,16"
FontSize="14" FontSize="14"
FontWeight="Light"
LineHeight="20"> LineHeight="20">
<Run FontSize="18" Text="Please provide authentication token to continue" />
<LineBreak />
<LineBreak />
<!-- User token --> <!-- User token -->
<InlineUIContainer> <InlineUIContainer>
<materialDesign:PackIcon <materialDesign:PackIcon
@@ -158,7 +150,7 @@
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" /> Kind="Account" />
</InlineUIContainer> </InlineUIContainer>
<Run FontSize="16" Text="To get token for your personal account:" /> <Run FontSize="16" FontWeight="SemiBold" Text="To get token for your personal account:" />
<LineBreak /> <LineBreak />
<Run Text="* Automating user accounts is technically against TOS —" /> <Run Text="* Automating user accounts is technically against TOS —" />
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" /> <Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
@@ -169,31 +161,33 @@
</Hyperlink> </Hyperlink>
<Run Text="and login" /> <Run Text="and login" />
<LineBreak /> <LineBreak />
<Run Text="2. Press" /> <Run Text="2. Open any server or direct message channel" />
<LineBreak />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" /> <Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" /> <Run Text="to show developer tools" />
<LineBreak /> <LineBreak />
<Run Text="3. Navigate to the" /> <Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Network" /> <Run FontWeight="SemiBold" Text="Network" />
<Run Text="tab" /> <Run Text="tab" />
<LineBreak /> <LineBreak />
<Run Text="4. Press" /> <Run Text="5. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" /> <Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" /> <Run Text="to reload" />
<LineBreak /> <LineBreak />
<Run Text="5. Search for a request containing" /> <Run Text="6. Search for a request containing" />
<Run FontWeight="SemiBold" Text="messages?limit=50" /> <Run FontWeight="SemiBold" Text="messages?limit=50" />
<Run Text="or similar" /> <Run Text="or similar" />
<LineBreak /> <LineBreak />
<Run Text="6. Select the" /> <Run Text="7. Select the" />
<Run FontWeight="SemiBold" Text="Headers" /> <Run FontWeight="SemiBold" Text="Headers" />
<Run Text=" tab on the right" /> <Run Text=" tab on the right" />
<LineBreak /> <LineBreak />
<Run Text="7. Scroll down to the" /> <Run Text="8. Scroll down to the" />
<Run FontWeight="SemiBold" Text="Request Headers" /> <Run FontWeight="SemiBold" Text="Request Headers" />
<Run Text="section" /> <Run Text="section" />
<LineBreak /> <LineBreak />
<Run Text="8. Copy the value of the" /> <Run Text="9. Copy the value of the" />
<Run FontWeight="SemiBold" Text="authorization" /> <Run FontWeight="SemiBold" Text="authorization" />
<Run Text="header" /> <Run Text="header" />
<LineBreak /> <LineBreak />
@@ -206,7 +200,7 @@
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" /> Kind="Robot" />
</InlineUIContainer> </InlineUIContainer>
<Run FontSize="16" Text="To get token for a bot account:" /> <Run FontSize="16" FontWeight="SemiBold" Text="To get token for a bot account:" />
<LineBreak /> <LineBreak />
<Run Text="1. Open Discord" /> <Run Text="1. Open Discord" />
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}"> <Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
@@ -224,10 +218,14 @@
<Run Text="click" /> <Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" /> <Run FontWeight="SemiBold" Text="Copy" />
<LineBreak /> <LineBreak />
<Run Text="* Your bot needs to have" />
<Run FontWeight="SemiBold" Text="Message Content Intent" />
<Run Text="enabled to read messages" />
<LineBreak />
<LineBreak /> <LineBreak />
<Run FontSize="16" Text="If you have questions or issues, please refer to the" /> <Run Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." /> <Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock> </TextBlock>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
+20 -11
View File
@@ -9,7 +9,7 @@
[![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate) [![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate)
[![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848) [![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848)
> 🟢 **Project status**: active<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup> > 🟡 **Project status**: maintenance mode<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
**DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file. **DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file.
It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features. It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
@@ -61,10 +61,11 @@ The following table lists all available download options:
<td><b>CLI</b></td> <td><b>CLI</b></td>
<td> <td>
<ul> <ul>
<li>🟢 <b><a href="https://github.com/Tyrrrz/DiscordChatExporter/releases/latest">Stable release</a></b> (<code>DiscordChatExporter.CLI.zip</code>) ⚙️</li> <li>🟢 <b><a href="https://github.com/Tyrrrz/DiscordChatExporter/releases/latest">Stable release</a></b> (<code>DiscordChatExporter.CLI.zip</code></li>
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>) ⚙️</li> <li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>)</li>
<li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li> <li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li>
<li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>) 🦄</li> <li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>)</li>
<li>📦 <a href="https://search.nixos.org/packages?query=discordchatexporter-cli">Nix</a> (<code>discordchatexporter-cli</code>)</li>
</ul> </ul>
</td> </td>
<td> <td>
@@ -78,12 +79,20 @@ The following table lists all available download options:
</tbody> </tbody>
</table> </table>
- ⚙️ - Requires .NET runtime to be installed manually: > **Note**:
- [.NET v6.0 Runtime for **Windows x64**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-6.0.0-windows-x64-installer) > AUR and Nix packages linked above are maintained by the community.
- [.NET v6.0 Runtime for **Windows x86**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-6.0.0-windows-x86-installer) > If you have any issues with them, please contact the corresponding maintainers.
- [.NET v6.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-6.0.0-macos-x64-installer)
- [.NET v6.0 Runtime for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find your distro) > **Warning**:
- 🦄 - Community-maintained resource > To run **DiscordChatExporter** on macOS and Linux, you need to make sure that **.NET Runtime v6** is installed.
> You can download it here:
>
> - [.NET Runtime v6 for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-x64-installer)
> - [.NET Runtime v6 for **macOS Arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-arm64-installer)
> - [.NET Runtime v6 for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find the correct download for your distro)
>
> This should not be necessary if you install **DiscordChatExporter** using a package manager, as it should take care of the dependencies for you.
> This is also not necessary if you are running **DiscordChatExporter** via Docker, because the image already contains the runtime.
## Features ## Features
@@ -98,4 +107,4 @@ The following table lists all available download options:
## Screenshots ## Screenshots
![channel list](.assets/list.png) ![channel list](.assets/list.png)
![rendered output](.assets/output.png) ![rendered output](.assets/output.png)