mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 15:14:37 +02:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc92b2cbe6 | |||
| 08718425f1 | |||
| 540ba34fb4 | |||
| 22134108a4 | |||
| 82499af25a | |||
| 4a74ff8a02 | |||
| 7dfcf5ac19 | |||
| 19c5fb40b2 | |||
| d4fa8f0954 | |||
| 0d9168e0d2 | |||
| aa8ace8389 | |||
| 97cb8e2347 | |||
| 92ee97a5da | |||
| 612ae2e894 | |||
| 1506afc4a2 | |||
| b56c7db3ec | |||
| 59d803d9f1 | |||
| 30ba273fb1 | |||
| f9fa35b6ad | |||
| 7d2ebb04de | |||
| 1c9f4275b2 | |||
| 1fadc0755b | |||
| 7ddd55951c | |||
| 6fb197cf0b | |||
| 7add81a472 | |||
| cf7580014c | |||
| 606c082759 | |||
| 5bbb706b3c | |||
| deca3fc1bf | |||
| fca6729ef0 | |||
| 6f877cf543 | |||
| 62babc0de5 | |||
| 9e115562dc | |||
| db50a2bb96 | |||
| b39d015133 | |||
| b4c3dce760 | |||
| f31e73bb7a | |||
| 75ff5c2f7e | |||
| 7663f8ed31 | |||
| c0e7334084 | |||
| a9eca1ceb2 | |||
| 45ff1daff3 | |||
| 5299c2ebc4 | |||
| f6c189f57c | |||
| a9acf17375 | |||
| bf417db80c | |||
| d78d22d066 | |||
| 0fa5e93f0d | |||
| 7b892c8e5b | |||
| 692d886442 | |||
| e664eab5fb | |||
| 1fb6156187 | |||
| 789e5af8ba | |||
| 9c15baf799 | |||
| 56fa6ee150 | |||
| 09e0b3f133 | |||
| e8192b2b53 | |||
| 34e9b0bfcc | |||
| abd7adc9e0 | |||
| f5cdd5c8be | |||
| 9fb78509b4 | |||
| 161e91ccf4 | |||
| 8519ec0fff | |||
| 9f8f718c94 | |||
| bb14e3431d | |||
| aa377f3131 | |||
| 522789e01d | |||
| 10adba3a4d | |||
| 73e2e0624b | |||
| c978a4cf60 | |||
| 6d82930505 | |||
| 4e98c79313 | |||
| 42e6de359e | |||
| 281e0f608a | |||
| c63313e328 | |||
| 767ca1b177 | |||
| 429801183c | |||
| 520c06dceb | |||
| 46d8deba87 | |||
| 0e13be2fdf | |||
| 15f8a13f02 | |||
| c8ea365c04 | |||
| 949abc2582 | |||
| 3b3423dd9b | |||
| 8380e88744 | |||
| f1bc5a2266 | |||
| dc35f25293 | |||
| 0e3969ca2d | |||
| 91b7486f45 | |||
| b7548756a8 | |||
| 8f9c0f8660 | |||
| b12be796c7 | |||
| 7a69c87b56 | |||
| 9e7ad4d85c | |||
| 0fb4bea64d | |||
| d8abd011e5 | |||
| 9a5697b5b5 | |||
| 886b8442d3 | |||
| bedf7de8c6 | |||
| 5ef329b63c | |||
| 060e55a0d7 | |||
| 61444aad30 | |||
| 9b3421e8a5 | |||
| 5623c2ca34 | |||
| 8525917a4c | |||
| b9c1c47474 | |||
| 74f99b4e59 | |||
| 12b590d9f9 | |||
| 6d1e8c2e02 | |||
| 72f02cd9a0 | |||
| 47d560229d | |||
| 0e87098889 | |||
| 0139eeaeed |
+7
-7
@@ -10,7 +10,7 @@ Docker distribution of DiscordChatExporter provides a way to run the app in a vi
|
|||||||
This will download the [Docker image from the registry](https://hub.docker.com/r/tyrrrz/discordchatexporter) to your computer. You can run this command again to update when a new version is released.
|
This will download the [Docker image from the registry](https://hub.docker.com/r/tyrrrz/discordchatexporter) to your computer. You can run this command again to update when a new version is released.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
docker pull tyrrrz/discordchatexporter:stable
|
$ docker pull tyrrrz/discordchatexporter:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:
|
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:
|
||||||
@@ -26,19 +26,19 @@ You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordcha
|
|||||||
To run the CLI in Docker and render help text:
|
To run the CLI in Docker and render help text:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
docker run --rm tyrrrz/discordchatexporter:stable
|
$ docker run --rm tyrrrz/discordchatexporter:stable
|
||||||
```
|
```
|
||||||
|
|
||||||
To export a channel:
|
To export a channel:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
$ docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||||
```
|
```
|
||||||
|
|
||||||
If you want colored output and real-time progress reporting, pass the `-it` (interactive + pseudo-terminal) option:
|
If you want colored output and real-time progress reporting, pass the `-it` (interactive + pseudo-terminal) option:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
$ docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||||
```
|
```
|
||||||
|
|
||||||
The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` directory inside the container to a path on your host machine. Replace `/path/on/machine` with the directory you want the files to be saved at.
|
The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` directory inside the container to a path on your host machine. Replace `/path/on/machine` with the directory you want the files to be saved at.
|
||||||
@@ -47,7 +47,7 @@ The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` direct
|
|||||||
> If you are running SELinux, you will need to add the `:z` option after `/out`, e.g.:
|
> If you are running SELinux, you will need to add the `:z` option after `/out`, e.g.:
|
||||||
>
|
>
|
||||||
> ```console
|
> ```console
|
||||||
> docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
> $ docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||||
> ```
|
> ```
|
||||||
>
|
>
|
||||||
> For more information, refer to the [Docker docs SELinux labels for bind mounts page](https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label).
|
> For more information, refer to the [Docker docs SELinux labels for bind mounts page](https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label).
|
||||||
@@ -68,8 +68,8 @@ This image was designed with a user running as uid:gid of 1000:1000.
|
|||||||
If your current user has different IDs, and you want to generate files directly editable for your user, you might want to run the container like this:
|
If your current user has different IDs, and you want to generate files directly editable for your user, you might want to run the container like this:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
mkdir data # or chown -R $(id -u):$(id -g) data
|
$ mkdir data # or chown -R $(id -u):$(id -g) data
|
||||||
docker run -it --rm -v $PWD/data:/out --user $(id -u):$(id -g) tyrrrz/discordchatexporter:stable export -t TOKEN -g CHANNELID
|
$ docker run -it --rm -v $PWD/data:/out --user $(id -u):$(id -g) tyrrrz/discordchatexporter:stable export -t TOKEN -g CHANNELID
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
# Install .NET runtime
|
|
||||||
|
|
||||||
**.NET 8.0 Runtime** is required by **DiscordChatExporter**.
|
|
||||||
|
|
||||||
> **Note**:
|
|
||||||
> Installing the **.NET Runtime** is not required if you running **DiscordChatExporter** using [Docker](Docker.md).
|
|
||||||
|
|
||||||
## Windows
|
|
||||||
|
|
||||||
Both the GUI and the CLI flavors of **DiscordChatExporter** for Windows come with a bootstrapper that should automatically install all the required prerequisites. Simply run the application and follow the presented instructions.
|
|
||||||
|
|
||||||
If, for some reason, you need to install the runtime manually, use one of the download links below:
|
|
||||||
|
|
||||||
- [.NET 8.0 Runtime for **Windows x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x86-installer)
|
|
||||||
- [.NET 8.0 Runtime for **Windows x86**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x64-installer)
|
|
||||||
- [.NET 8.0 Runtime for **Windows arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-arm64-installer)
|
|
||||||
|
|
||||||
> **Note**:
|
|
||||||
> [How can I tell if my computer is running a 32-bit (x86) or a 64-bit (x64) version of Windows?](https://support.microsoft.com/help/15056/windows-32-64-bit-faq)
|
|
||||||
|
|
||||||
## macOS
|
|
||||||
|
|
||||||
Use one of the download links below:
|
|
||||||
|
|
||||||
- [.NET 8.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-x64-installer)
|
|
||||||
- [.NET 8.0 Runtime for **macOS arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-arm64-installer)
|
|
||||||
|
|
||||||
> **Note**:
|
|
||||||
> [Is my Mac using an Intel processor (x64) or Apple Silicon (arm64)?](https://support.apple.com/HT211814)
|
|
||||||
|
|
||||||
## Linux
|
|
||||||
|
|
||||||
Installing the .NET Runtime works differently depending on which Linux distribution you use. Check out the instructions for your distro:
|
|
||||||
|
|
||||||
- [Ubuntu](https://docs.microsoft.com/dotnet/core/install/linux-ubuntu)
|
|
||||||
- [Debian](https://docs.microsoft.com/dotnet/core/install/linux-debian)
|
|
||||||
- [Fedora](https://docs.microsoft.com/dotnet/core/install/linux-fedora)
|
|
||||||
|
|
||||||
For other distros, please check the _'Install on Linux'_ menu on the left of [this page](https://docs.microsoft.com/dotnet/core/install/linux).
|
|
||||||
|
|
||||||
## Verify installation (optional)
|
|
||||||
|
|
||||||
You can check which version of **.NET Runtime** is installed by running the following command in a terminal:
|
|
||||||
|
|
||||||
```console
|
|
||||||
dotnet --info
|
|
||||||
```
|
|
||||||
|
|
||||||
If the **.NET Runtime** is correctly installed, the command will output something similar to the following:
|
|
||||||
|
|
||||||
```console
|
|
||||||
.NET runtimes installed:
|
|
||||||
Microsoft.NETCore.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.NETCore.App]
|
|
||||||
Microsoft.WindowsDesktop.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.WindowsDesktop.App]
|
|
||||||
```
|
|
||||||
@@ -15,12 +15,9 @@ The information presented on this page is valid for **all** platforms.
|
|||||||
**DCE** has two different versions:
|
**DCE** has two different versions:
|
||||||
|
|
||||||
- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use.
|
- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use.
|
||||||
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.zip` file.
|
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.*.zip` file.
|
||||||
- **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges.
|
- **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges.
|
||||||
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.CLI.zip` file.
|
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.Cli.*.zip` file.
|
||||||
|
|
||||||
If you're not comfortable with **Windows'** Command-line (cmd), please choose the GUI.
|
|
||||||
[**macOS**](MacOS.md), [**Linux**](Linux.md) and [**Docker**](Docker.md) users can only use the CLI version.
|
|
||||||
|
|
||||||
There are dedicated guides for each version:
|
There are dedicated guides for each version:
|
||||||
|
|
||||||
@@ -38,13 +35,13 @@ You can open `.html` files with a web browser, such as Google Chrome.
|
|||||||
|
|
||||||
> **Warning**:
|
> **Warning**:
|
||||||
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
||||||
> Export using the "Download referenced assets" (`--media`) option to avoid this.
|
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||||
|
|
||||||
### Plain Text
|
### Plain Text
|
||||||
|
|
||||||
<img src="https://i.imgur.com/PbUyRXD.png" height="400"/>
|
<img src="https://i.imgur.com/PbUyRXD.png" height="400"/>
|
||||||
|
|
||||||
The Plain Text format is the best option for archiving due to its small size.
|
The Plain Text format formats messages as plain text, and has the smallest size.
|
||||||
You can open `.txt` files with a text editor, such as Notepad.
|
You can open `.txt` files with a text editor, such as Notepad.
|
||||||
|
|
||||||
### JSON
|
### JSON
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
# Linux usage instructions
|
|
||||||
|
|
||||||
## Installing .NET Runtime
|
|
||||||
|
|
||||||
Please follow the [instructions provided here](Dotnet.md).
|
|
||||||
|
|
||||||
## Downloading and using DiscordChatExporter.Cli
|
|
||||||
|
|
||||||
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
|
|
||||||
2. Open Terminal.
|
|
||||||
3. `cd` into the extracted folder. You can do this in Terminal by typing `cd`, then press the SPACE key, drag and drop the extracted folder into the Terminal window, and press the ENTER key.
|
|
||||||
4. Replace `TOKEN` and `CHANNEL`, then execute this command to export:
|
|
||||||
|
|
||||||
```console
|
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
|
|
||||||
```
|
|
||||||
|
|
||||||
If the above command throws a "Permission denied" error, execute this command to fix the permissions:
|
|
||||||
|
|
||||||
```console
|
|
||||||
chmod 644 *.dll DiscordChatExporter.*
|
|
||||||
```
|
|
||||||
|
|
||||||
> [How to get Token and Channel IDs](Token-and-IDs.md).
|
|
||||||
|
|
||||||
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# macOS usage instructions
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Installing .NET Runtime
|
|
||||||
|
|
||||||
Please follow the [instructions provided here](Dotnet.md).
|
|
||||||
|
|
||||||
## Downloading and using DiscordChatExporter.Cli
|
|
||||||
|
|
||||||
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
|
|
||||||
2. Search for `Terminal.app` in Spotlight (⌘+SPACE), then open it.
|
|
||||||
3. In the Terminal window, type `cd` , press the SPACE key, then drag and drop the extracted folder into the window, then press the RETURN key.
|
|
||||||
4. Execute the following command to export, replacing `TOKEN` and `CHANNEL` with your own values:
|
|
||||||
|
|
||||||
```console
|
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
|
|
||||||
```
|
|
||||||
|
|
||||||
> [How to get Token and Channel IDs](Token-and-IDs.md).
|
|
||||||
|
|
||||||
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
|
|
||||||
@@ -89,17 +89,17 @@ from:96\-LB
|
|||||||
In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
|
In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
|
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
|
||||||
```
|
```
|
||||||
|
|
||||||
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
|
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
|
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
|
||||||
```
|
```
|
||||||
|
|
||||||
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
|
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
|
||||||
|
|
||||||
```console
|
```console
|
||||||
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
|
$ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
## Installation & Usage
|
## Installation & Usage
|
||||||
|
|
||||||
- [Get .NET Core Runtime](Dotnet.md) (Required for CLI; Installed automatically for GUI; Not required in Docker)
|
|
||||||
- [Windows](Getting-started.md#gui-or-cli) | [macOS](MacOS.md) | [Linux](Linux.md) | [Docker](Docker.md)
|
|
||||||
- Getting started:
|
- Getting started:
|
||||||
- [Using the GUI](Using-the-GUI.md)
|
- [Using the GUI](Using-the-GUI.md)
|
||||||
- [Using the CLI](Using-the-CLI.md)
|
- [Using the CLI](Using-the-CLI.md)
|
||||||
@@ -29,6 +27,5 @@
|
|||||||
- [General questions](Troubleshooting.md#general)
|
- [General questions](Troubleshooting.md#general)
|
||||||
- [First steps help](Troubleshooting.md#first-steps)
|
- [First steps help](Troubleshooting.md#first-steps)
|
||||||
- [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing)
|
- [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing)
|
||||||
- [.NET Core Runtime is required](Troubleshooting.md#net-core-runtime-is-required)
|
|
||||||
- [Errors](Troubleshooting.md#errors)
|
- [Errors](Troubleshooting.md#errors)
|
||||||
- [**More help**](Troubleshooting.md)
|
- [**More help**](Troubleshooting.md)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
# Scheduling exports with Cron
|
# Scheduling exports with Cron
|
||||||
|
|
||||||
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](Linux.md)).
|
|
||||||
|
|
||||||
## Creating the script
|
## Creating the script
|
||||||
|
|
||||||
1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh`
|
1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh`
|
||||||
@@ -45,7 +43,7 @@ fi
|
|||||||
cd $DLLFOLDER || exit 1
|
cd $DLLFOLDER || exit 1
|
||||||
|
|
||||||
# This will export your chat
|
# This will export your chat
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
||||||
|
|
||||||
# This sets the current time to a variable
|
# This sets the current time to a variable
|
||||||
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
|
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# Scheduling exports on macOS
|
# Scheduling exports on macOS
|
||||||
|
|
||||||
Scheduling on macOS is a bit tricky, but it should work if you follow the instructions accordingly.
|
|
||||||
|
|
||||||
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](MacOS.md)).
|
|
||||||
|
|
||||||
## Creating the script
|
## Creating the script
|
||||||
|
|
||||||
1. Open TextEdit.app and create a new file
|
1. Open TextEdit.app and create a new file
|
||||||
@@ -51,7 +47,7 @@ fi
|
|||||||
cd $DLLFOLDER || exit 1
|
cd $DLLFOLDER || exit 1
|
||||||
|
|
||||||
# This will export your chat
|
# This will export your chat
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
||||||
|
|
||||||
# This sets the current time to a variable
|
# This sets the current time to a variable
|
||||||
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
|
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
# Scheduling exports on Windows
|
# Scheduling exports on Windows
|
||||||
|
|
||||||
We'll be using [DiscordChatExporter CLI](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest), PowerShell, and Task Scheduler.
|
|
||||||
|
|
||||||
## Creating the script
|
## Creating the script
|
||||||
|
|
||||||
1. Open a text editor such as Notepad and paste:
|
1. Open a text editor such as Notepad and paste:
|
||||||
@@ -19,7 +17,7 @@ $EXPORTFORMAT = "formathere"
|
|||||||
|
|
||||||
cd $EXEPATH
|
cd $EXEPATH
|
||||||
|
|
||||||
.\DiscordChatExporter.Cli.exe export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
|
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
|
||||||
|
|
||||||
$Date = Get-Date -Format "yyyy-MM-dd-HH-mm"
|
$Date = Get-Date -Format "yyyy-MM-dd-HH-mm"
|
||||||
|
|
||||||
|
|||||||
+55
-10
@@ -1,10 +1,11 @@
|
|||||||
# Obtaining Token and Channel IDs
|
# Obtaining Token and Channel IDs
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> **Do not share your token!**
|
> **Do not share your token!** A token gives full access to an account.
|
||||||
> A token gives full access to an account. To reset a user token, change your account password. To reset a bot token, click on [Regenerate](#how-to-get-a-bot-token) in the bot settings.
|
> To reset a user token, change your account password.
|
||||||
|
> To reset a bot token, click on [Reset Token](#how-to-export-with-a-bot-token) in the bot settings.
|
||||||
|
|
||||||
## How to get a User Token
|
## How to get a user token
|
||||||
|
|
||||||
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
|
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
|
|
||||||
3. Type
|
3. Type
|
||||||
|
|
||||||
```console
|
```js
|
||||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
|
|
||||||
1. Type
|
1. Type
|
||||||
|
|
||||||
```console
|
```js
|
||||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -254,17 +255,61 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
4. Press <kbd>Esc</kbd>. The settings page will close.
|
4. Press <kbd>Esc</kbd>. The settings page will close.
|
||||||
5. To find your user token, continue [here](#in-chrome).
|
5. To find your user token, continue [here](#in-chrome).
|
||||||
|
|
||||||
## How to get a Bot Token
|
## How to export with a bot token
|
||||||
|
|
||||||
|
### Step 1 - Create an application
|
||||||
|
|
||||||
|
You can create a new application or use an existing one. If you want to create a new one:
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Click on **New Application** in the top right corner
|
||||||
|
3. Enter a name for your application and click **Create**
|
||||||
|
|
||||||
|
### Step 2 - Invite the bot to your server
|
||||||
|
|
||||||
|
The bot needs to be invited to the server you'd like to export from.
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Navigate to **General Information** on the left
|
||||||
|
3. Copy the **Application ID**
|
||||||
|
4. Open the following URL in your browser, replacing `YOUR_APP_ID` with the copied Client ID:
|
||||||
|
|
||||||
|
<!-- Permission code 66560 corresponds to "View Channels" and "Read Message History" permissions.
|
||||||
|
User can uncheck these when adding the bot to their server. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
https://discord.com/oauth2/authorize?scope=bot&permissions=66560&client_id=YOUR_APP_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 - Ensure message content intent is enabled
|
||||||
|
|
||||||
|
If this option is not enabled, the exported files will be empty.
|
||||||
|
|
||||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
2. Open your Application's settings
|
2. Open your Application's settings
|
||||||
3. Navigate to the **Bot** section on the left
|
3. Navigate to the **Bot** section on the left
|
||||||
4. Under **Token** click **Copy**
|
4. Scroll down to the **Privileged Gateway Intents** section
|
||||||
|
5. Enable **Message Content Intent** by toggling the switch
|
||||||
|
|
||||||
|
<img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />
|
||||||
|
|
||||||
|
### Step 4 - Copy the bot token
|
||||||
|
|
||||||
|
If you don't have a bot token yet or if you've lost it, follow these steps to reset it:
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Open your Application's settings
|
||||||
|
3. Navigate to the **Bot** section on the left
|
||||||
|
4. Under **Token** click **Reset Token**
|
||||||
|
5. Click **Yes, do it!** and authenticate to confirm
|
||||||
|
|
||||||
|
> **Tip**:
|
||||||
|
> As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.
|
||||||
|
|
||||||
> **Warning**:
|
> **Warning**:
|
||||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+12
-31
@@ -24,7 +24,7 @@ Follow the [instructions here](Token-and-IDs.md).
|
|||||||
|
|
||||||
### Will I get banned if I use this?
|
### Will I get banned if I use this?
|
||||||
|
|
||||||
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bots) don't have this restriction.
|
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bot-users) don't have this restriction.
|
||||||
|
|
||||||
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
|
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
|
||||||
|
|
||||||
@@ -70,14 +70,7 @@ Check the following page: [Obtaining token](Token-and-IDs.md)
|
|||||||
|
|
||||||
### When I open DCE a black window pops up quickly or nothing shows up
|
### When I open DCE a black window pops up quickly or nothing shows up
|
||||||
|
|
||||||
If you have [.NET Core Runtime correctly installed](Dotnet.md), you might have downloaded the CLi flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead.
|
You might have downloaded the CLI flavor of the app, which is meant to be run in a terminal. Try [downloading the GUI](Getting-started.md#gui-or-cli) instead if that's what you want.
|
||||||
|
|
||||||
### How do I run DCE on macOS or Linux?
|
|
||||||
|
|
||||||
Check the following pages:
|
|
||||||
|
|
||||||
- [macOS usage instructions](MacOS.md)
|
|
||||||
- [Linux usage instructions](Linux.md)
|
|
||||||
|
|
||||||
### How can I set DCE to export automatically at certain times?
|
### How can I set DCE to export automatically at certain times?
|
||||||
|
|
||||||
@@ -91,16 +84,6 @@ Check the following pages to learn how to schedule **DiscordChatExporter.CLI** r
|
|||||||
|
|
||||||
Try opening it with a different program, try partitioning or use a different file format, like `PlainText`.
|
Try opening it with a different program, try partitioning or use a different file format, like `PlainText`.
|
||||||
|
|
||||||
### DCE is crashing/failing
|
|
||||||
|
|
||||||
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
|
|
||||||
|
|
||||||
If you already have .NET Core installed, please check if your problem is a [known issue](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) before [opening a bug report](https://github.com/Tyrrrz/DiscordChatExporter/issues/new).
|
|
||||||
|
|
||||||
### .NET Core Runtime is required
|
|
||||||
|
|
||||||
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
|
|
||||||
|
|
||||||
### I see messages in the export, but they have no content
|
### I see messages in the export, but they have no content
|
||||||
|
|
||||||
Your bot is missing the 'Message Content Intent'. Go to the [Discord Developer Portal](https://discord.com/developers/applications), navigate to the 'Bot' section and enable it.
|
Your bot is missing the 'Message Content Intent'. Go to the [Discord Developer Portal](https://discord.com/developers/applications), navigate to the 'Bot' section and enable it.
|
||||||
@@ -133,34 +116,26 @@ Make sure you're [copying the DM Channel ID](Token-and-IDs.md#how-to-get-a-direc
|
|||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
```console
|
```yml
|
||||||
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid.
|
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid.
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
↳ Make sure the provided token is correct.
|
↳ Make sure the provided token is correct.
|
||||||
|
|
||||||
```console
|
```yml
|
||||||
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Requested resource does not exist.
|
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Requested resource does not exist.
|
||||||
```
|
```
|
||||||
|
|
||||||
↳ Check your channel ID, it might be invalid. [Read this if you need help](Token-and-IDs.md).
|
↳ Check your channel ID, it might be invalid. [Read this if you need help](Token-and-IDs.md).
|
||||||
|
|
||||||
```console
|
```yml
|
||||||
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Access is forbidden.
|
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Access is forbidden.
|
||||||
```
|
```
|
||||||
|
|
||||||
↳ This means you don't have access to the channel.
|
↳ This means you don't have access to the channel.
|
||||||
|
|
||||||
```console
|
```yml
|
||||||
The application to execute does not exist:
|
|
||||||
```
|
|
||||||
|
|
||||||
↳ The `DiscordChatExporter.Cli.dll` file is missing. Keep the `.exe` and all the `.dll` files together. If you didn't move the files, try unzipping again.
|
|
||||||
|
|
||||||
```console
|
|
||||||
System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server.
|
System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server.
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
↳ Try running cert-sync.
|
↳ Try running cert-sync.
|
||||||
@@ -171,6 +146,12 @@ Red Hat: `cert-sync --user /etc/pki/tls/certs/ca-bundle.crt`
|
|||||||
|
|
||||||
If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
|
If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
|
||||||
|
|
||||||
|
## macOS-specific
|
||||||
|
|
||||||
|
### DiscordChatExporter is damaged and can’t be opened. You should move it to the Trash.
|
||||||
|
|
||||||
|
Check the [Using the GUI page](Using-the-GUI.md#step-1) for instructions on how to run the app.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
||||||
|
|||||||
+49
-42
@@ -1,39 +1,35 @@
|
|||||||
# Using the CLI
|
# Using the CLI
|
||||||
|
|
||||||
## Guide
|
|
||||||
|
|
||||||
> **Note**:
|
|
||||||
> Make sure you have [.NET Core installed](Dotnet.md) before attempting to run the commands below.
|
|
||||||
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
|
||||||
|
|
||||||
## Step 1
|
## Step 1
|
||||||
|
|
||||||
After extracting the `.zip`, open Command Prompt, aka `cmd` (`Terminal` on **macOS** and **Linux**).
|
After extracting the `.zip` archive, open your preferred terminal.
|
||||||
|
|
||||||
## Step 2
|
## Step 2
|
||||||
|
|
||||||
Change the current directory to DCE's folder with `cd C:\path\to\directory`, then press ENTER to run the command.
|
Change the current directory to DCE's folder with `cd C:\path\to\DiscordChatExporter` (`cd /path/to/DiscordChatExporter` on **MacOS** and **Linux**), then press ENTER to run the command.
|
||||||
|
|
||||||
**Windows** users can quickly get the directory's path by clicking the address bar while inside the folder.
|
**Windows** users can quickly get the folder's path by clicking the address bar while inside the folder.
|
||||||

|

|
||||||
|
|
||||||
**macOS** users can select the `.exe`, hit Command+I (⌘I), and copy what's after `Where:` to get the directory
|
**macOS** users can press Command+Option+C (⌘⌥C) while inside the folder (or selecting it) to copy its path to the clipboard.
|
||||||

|
|
||||||
|
|
||||||
You can also drag and drop the folder on **every platform**.
|
You can also drag and drop the folder on **every platform**.
|
||||||

|

|
||||||
|
|
||||||
## Step 3
|
## Step 3
|
||||||
|
|
||||||
Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file
|
Now we're ready to run the commands.
|
||||||
paths according to your system.
|
|
||||||
|
|
||||||
Type the following in Command Prompt (Terminal), then press ENTER to run it. This will list DCE's options.
|
Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll
|
./DiscordChatExporter.Cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note**:
|
||||||
|
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
|
||||||
|
|
||||||
|
|
||||||
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
@@ -49,18 +45,18 @@ dotnet DiscordChatExporter.Cli.dll
|
|||||||
| guilds | Outputs the list of accessible servers |
|
| guilds | Outputs the list of accessible servers |
|
||||||
| guide | Explains how to obtain token, server, and channel ID |
|
| guide | Explains how to obtain token, server, and channel ID |
|
||||||
|
|
||||||
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `dotnet DiscordChatExporter.Cli.dll guide`.
|
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
|
||||||
|
|
||||||
To get help with a specific command, run:
|
To get help with a specific command, run:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll command --help
|
./DiscordChatExporter.Cli command --help
|
||||||
```
|
```
|
||||||
|
|
||||||
For example, to figure out how to use the `export` command, run:
|
For example, to figure out how to use the `export` command, run:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export --help
|
./DiscordChatExporter.Cli export --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Export a specific channel
|
## Export a specific channel
|
||||||
@@ -68,7 +64,7 @@ dotnet DiscordChatExporter.Cli.dll export --help
|
|||||||
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
|
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changing the format
|
#### Changing the format
|
||||||
@@ -77,7 +73,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
|
|||||||
format is `HtmlDark`.
|
format is `HtmlDark`.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -f Json
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changing the output filename
|
#### Changing the output filename
|
||||||
@@ -85,7 +81,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -f Json
|
|||||||
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
|
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changing the output directory
|
#### Changing the output directory
|
||||||
@@ -95,7 +91,7 @@ extension.
|
|||||||
If any of the folders in the path have a space in its name, escape them with quotes (").
|
If any of the folders in the path have a space in its name, escape them with quotes (").
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changing the filename and output directory
|
#### Changing the filename and output directory
|
||||||
@@ -105,7 +101,7 @@ Note that the filename must have an extension, otherwise it will be considered a
|
|||||||
If any of the folders in the path have a space in its name, escape them with quotes (").
|
If any of the folders in the path have a space in its name, escape them with quotes (").
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Generating the filename and output directory dynamically
|
#### Generating the filename and output directory dynamically
|
||||||
@@ -113,7 +109,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord
|
|||||||
You can use template tokens to generate the output file path based on the server and channel metadata.
|
You can use template tokens to generate the output file path based on the server and channel metadata.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
|
||||||
```
|
```
|
||||||
|
|
||||||
Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
|
Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
|
||||||
@@ -132,6 +128,7 @@ Here is the full list of supported template tokens:
|
|||||||
- `%P` - category position
|
- `%P` - category position
|
||||||
- `%a` - the "after" date
|
- `%a` - the "after" date
|
||||||
- `%b` - the "before" date
|
- `%b` - the "before" date
|
||||||
|
- `%d` - the current date
|
||||||
- `%%` - escapes `%`
|
- `%%` - escapes `%`
|
||||||
|
|
||||||
#### Partitioning
|
#### Partitioning
|
||||||
@@ -140,13 +137,13 @@ You can use partitioning to split files after a given number of messages or file
|
|||||||
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
|
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -p 10
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
|
||||||
```
|
```
|
||||||
|
|
||||||
A 45 MB channel set to be partitioned every 20 MB will output 3 files.
|
A 45 MB channel set to be partitioned every 20 MB will output 3 files.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -p 20mb
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Downloading assets
|
#### Downloading assets
|
||||||
@@ -157,7 +154,7 @@ downloaded when using the plain text (TXT) export format.
|
|||||||
A folder containing the assets will be created along with the exported chat. They must be kept together.
|
A folder containing the assets will be created along with the exported chat. They must be kept together.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Reusing assets
|
#### Reusing assets
|
||||||
@@ -166,7 +163,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
|
|||||||
same folder. Using this option can speed up future exports. This option requires the `--media` option.
|
same folder. Using this option can speed up future exports. This option requires the `--media` option.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media --reuse-media
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changing the media directory
|
#### Changing the media directory
|
||||||
@@ -175,7 +172,7 @@ By default, the media directory is created alongside the exported chat. You can
|
|||||||
providing a path that ends with a slash. All of the exported media will be stored in this directory.
|
providing a path that ends with a slash. All of the exported media will be stored in this directory.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changing the date format
|
#### Changing the date format
|
||||||
@@ -184,7 +181,7 @@ You can customize how dates are formatted in the exported files by using `--loca
|
|||||||
locales. The default locale is `en-US`.
|
locales. The default locale is `en-US`.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Date ranges
|
#### Date ranges
|
||||||
@@ -193,14 +190,14 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --locale "de-DE
|
|||||||
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
|
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
|
||||||
```
|
```
|
||||||
|
|
||||||
**Messages sent after a date**
|
**Messages sent after a date**
|
||||||
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
|
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Messages sent in a date range**
|
**Messages sent in a date range**
|
||||||
@@ -208,7 +205,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
|
|||||||
September 17th, 2019 11:34 PM and September 18th:
|
September 17th, 2019 11:34 PM and September 18th:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
|
||||||
```
|
```
|
||||||
|
|
||||||
You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
|
You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
|
||||||
@@ -217,12 +214,22 @@ Don't forget to quote (") the date if it has spaces!
|
|||||||
More info about .NET date
|
More info about .NET date
|
||||||
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||||
|
|
||||||
|
#### Filtering messages
|
||||||
|
|
||||||
|
Use `--filter` to filter what messages are included in the export.
|
||||||
|
|
||||||
|
```console
|
||||||
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
|
||||||
|
```
|
||||||
|
|
||||||
|
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md).
|
||||||
|
|
||||||
### Export channels from a specific server
|
### Export channels from a specific server
|
||||||
|
|
||||||
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
|
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814
|
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Including threads
|
#### Including threads
|
||||||
@@ -232,7 +239,7 @@ specifying which threads should be included. It has possible values of `none`, `
|
|||||||
threads should be included. To include both active and archived threads, use `--include-threads all`.
|
threads should be included. To include both active and archived threads, use `--include-threads all`.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
|
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Including voice channels
|
#### Including voice channels
|
||||||
@@ -242,7 +249,7 @@ specifying whether to include voice channels in the export. It has possible valu
|
|||||||
voice channels, use `--include-vc false`.
|
voice channels, use `--include-vc false`.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
|
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Export all channels
|
### Export all channels
|
||||||
@@ -250,7 +257,7 @@ dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-
|
|||||||
To export all accessible channels, use the `exportall` command:
|
To export all accessible channels, use the `exportall` command:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
|
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Excluding DMs
|
#### Excluding DMs
|
||||||
@@ -258,7 +265,7 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
|
|||||||
To exclude DMs, add the `--include-dm false` option.
|
To exclude DMs, add the `--include-dm false` option.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
|
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||||
```
|
```
|
||||||
|
|
||||||
### List channels in a server
|
### List channels in a server
|
||||||
@@ -266,7 +273,7 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
|
|||||||
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
|
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
|
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||||
```
|
```
|
||||||
|
|
||||||
### List direct message channels
|
### List direct message channels
|
||||||
@@ -274,7 +281,7 @@ dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
|
|||||||
To list all DM channels accessible to the current account, use the `dm` command:
|
To list all DM channels accessible to the current account, use the `dm` command:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
|
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||||
```
|
```
|
||||||
|
|
||||||
### List servers
|
### List servers
|
||||||
@@ -282,5 +289,5 @@ dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
|
|||||||
To list all servers accessible by the current account, use the `guilds` command:
|
To list all servers accessible by the current account, use the `guilds` command:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
dotnet DiscordChatExporter.Cli.dll guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||||
```
|
```
|
||||||
|
|||||||
+26
-18
@@ -10,7 +10,22 @@
|
|||||||
|
|
||||||
### Step 1
|
### Step 1
|
||||||
|
|
||||||
After extracting the `.zip`, open `DiscordChatExporter.exe`
|
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.
|
||||||
|
|
||||||
|
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||||
|
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.
|
||||||
|
|
||||||
|
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
|
||||||
|
2. Paste the following into the terminal window:
|
||||||
|
```bash
|
||||||
|
xattr -rd com.apple.quarantine
|
||||||
|
```
|
||||||
|
3. Hit <kbd>Space</kbd> once to add a space after the command
|
||||||
|
4. Drag and drop DiscordChatExporter.app into the terminal window
|
||||||
|
5. Press <kbd>Return</kbd> to run the command
|
||||||
|
6. Open DiscordChatExporter.app normally
|
||||||
|
|
||||||
|
> Apple requires apps to be notarized and signed in order to run on macOS without warnings, which in turn requires an Apple Developer membership ($99/year). This open-source project is distributed for free and without commercial intent.
|
||||||
|
|
||||||
### Step 2
|
### Step 2
|
||||||
|
|
||||||
@@ -61,37 +76,30 @@ In this screen you can customize the following:
|
|||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
- **Auto-update**
|
- **Auto-update** - Perform automatic updates on every launch.
|
||||||
Perform automatic updates on every launch.
|
|
||||||
Default: Enabled
|
Default: Enabled
|
||||||
|
|
||||||
> **Note**:
|
> **Note**:
|
||||||
> Keep this option enabled to receive the latest features and bug fixes!
|
> Keep this option enabled to receive the latest features and bug fixes!
|
||||||
|
|
||||||
- **Dark mode**
|
- **Dark mode** - Use darker colors in the UI (User Interface).
|
||||||
Use darker colors in the UI (User Interface).
|
|
||||||
Default: Disabled
|
Default: Disabled
|
||||||
|
|
||||||
- **Persist token**
|
- **Persist token** - Persist last used token between sessions.
|
||||||
Persist last used token between sessions.
|
|
||||||
Default: Enabled
|
Default: Enabled
|
||||||
|
|
||||||
- **Show threads**
|
- **Show threads** - Controls whether threads are shown in the channel list.
|
||||||
Controls whether threads are shown in the channel list.
|
|
||||||
Default: none
|
Default: none
|
||||||
|
|
||||||
- **Locale**
|
- **Locale** - Customize how dates are formatted in the exported files.
|
||||||
Customize how dates are formatted in the exported files.
|
|
||||||
|
|
||||||
- **Date format**
|
- **Date format** - Customize how dates are formatted in the exported files in the settings menu ().
|
||||||
Customize how dates are formatted in the exported files in the settings menu ().
|
|
||||||
|
|
||||||
- **Parallel limit**
|
- **Parallel limit** - The number of channels that will be exported at the same time.
|
||||||
The number of channels that will be exported at the same time.
|
|
||||||
Default: 1
|
Default: 1
|
||||||
|
|
||||||
- **Normalize to UTC**
|
|
||||||
Convert all dates to UTC before exporting.
|
|
||||||
|
|
||||||
> **Note**:
|
> **Note**:
|
||||||
> Try to keep this number low so that your account doesn't get flagged.
|
> Try to keep this number low so that your account doesn't get flagged.
|
||||||
|
|
||||||
|
- **Normalize to UTC** - Convert all dates to UTC before exporting.
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ body:
|
|||||||
required: true
|
required: true
|
||||||
- label: I have sponsored this project
|
- label: I have sponsored this project
|
||||||
required: false
|
required: false
|
||||||
|
- label: I have not read any of the above and just checked all the boxes to submit the issue
|
||||||
|
required: false
|
||||||
|
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
groups:
|
||||||
|
docker:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
- package-ecosystem: nuget
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: monthly
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
groups:
|
||||||
|
nuget:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
@@ -12,8 +12,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Outputs from this job aren't really used, but it's here to verify that
|
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
|
||||||
# the Dockerfile builds correctly on pull requests.
|
|
||||||
pack:
|
pack:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -24,10 +23,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: >
|
run: >
|
||||||
@@ -38,7 +37,7 @@ jobs:
|
|||||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter.Cli.Docker
|
name: DiscordChatExporter.Cli.Docker
|
||||||
path: DiscordChatExporter.Cli.Docker.tar
|
path: DiscordChatExporter.Cli.Docker.tar
|
||||||
@@ -56,10 +55,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
run: >
|
run: >
|
||||||
|
|||||||
+74
-21
@@ -18,7 +18,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format:
|
format:
|
||||||
runs-on: windows-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -26,24 +26,37 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
|
# Build the project separately to discern between build and format errors
|
||||||
|
- name: Build
|
||||||
|
run: >
|
||||||
|
dotnet build
|
||||||
|
-p:CSharpier_Bypass=true
|
||||||
|
--configuration Release
|
||||||
|
|
||||||
- name: Verify formatting
|
- name: Verify formatting
|
||||||
|
id: verify
|
||||||
run: >
|
run: >
|
||||||
dotnet build
|
dotnet build
|
||||||
-t:CSharpierFormat
|
-t:CSharpierFormat
|
||||||
--configuration Release
|
--configuration Release
|
||||||
|
--no-restore
|
||||||
|
|
||||||
|
- name: Report issues
|
||||||
|
if: ${{ failure() && steps.verify.outcome == 'failure' }}
|
||||||
|
run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them."
|
||||||
|
|
||||||
test:
|
test:
|
||||||
# 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
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
|
||||||
runs-on: windows-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -51,12 +64,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
@@ -72,7 +85,7 @@ jobs:
|
|||||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0
|
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -82,8 +95,24 @@ jobs:
|
|||||||
app:
|
app:
|
||||||
- DiscordChatExporter.Cli
|
- DiscordChatExporter.Cli
|
||||||
- DiscordChatExporter.Gui
|
- DiscordChatExporter.Gui
|
||||||
|
rid:
|
||||||
|
- win-arm64
|
||||||
|
- win-x86
|
||||||
|
- win-x64
|
||||||
|
- linux-arm
|
||||||
|
- linux-arm64
|
||||||
|
- linux-musl-x64
|
||||||
|
- linux-x64
|
||||||
|
- osx-arm64
|
||||||
|
- osx-x64
|
||||||
|
include:
|
||||||
|
- app: DiscordChatExporter.Cli
|
||||||
|
asset: DiscordChatExporter.Cli
|
||||||
|
- app: DiscordChatExporter.Gui
|
||||||
|
# GUI assets aren't suffixed, unlike the CLI assets
|
||||||
|
asset: DiscordChatExporter
|
||||||
|
|
||||||
runs-on: windows-latest
|
runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -92,25 +121,28 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 8.0.x
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
- name: Publish app
|
- name: Publish app
|
||||||
run: >
|
run: >
|
||||||
dotnet publish ${{ matrix.app }}
|
dotnet publish ${{ matrix.app }}
|
||||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||||
-p:CSharpier_Bypass=true
|
-p:CSharpier_Bypass=true
|
||||||
|
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||||
--output ${{ matrix.app }}/bin/publish/
|
--output ${{ matrix.app }}/bin/publish/
|
||||||
--configuration Release
|
--configuration Release
|
||||||
|
--runtime ${{ matrix.rid }}
|
||||||
|
--self-contained
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.app }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/bin/publish/
|
path: ${{ matrix.app }}/bin/publish/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -147,11 +179,21 @@ jobs:
|
|||||||
app:
|
app:
|
||||||
- DiscordChatExporter.Cli
|
- DiscordChatExporter.Cli
|
||||||
- DiscordChatExporter.Gui
|
- DiscordChatExporter.Gui
|
||||||
|
rid:
|
||||||
|
- win-arm64
|
||||||
|
- win-x86
|
||||||
|
- win-x64
|
||||||
|
- linux-arm
|
||||||
|
- linux-arm64
|
||||||
|
- linux-musl-x64
|
||||||
|
- linux-x64
|
||||||
|
- osx-arm64
|
||||||
|
- osx-x64
|
||||||
include:
|
include:
|
||||||
- app: DiscordChatExporter.Cli
|
- app: DiscordChatExporter.Cli
|
||||||
asset: DiscordChatExporter.Cli
|
asset: DiscordChatExporter.Cli
|
||||||
- app: DiscordChatExporter.Gui
|
- app: DiscordChatExporter.Gui
|
||||||
# GUI asset isn't suffixed, unlike the CLI asset
|
# GUI assets aren't suffixed, unlike the CLI assets
|
||||||
asset: DiscordChatExporter
|
asset: DiscordChatExporter
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -163,22 +205,33 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.app }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/
|
path: ${{ matrix.app }}/
|
||||||
|
|
||||||
|
- name: Set permissions
|
||||||
|
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
||||||
|
run: |
|
||||||
|
if [ -f ${{ matrix.app }}/${{ matrix.asset }} ]; then
|
||||||
|
chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ]; then
|
||||||
|
chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }}
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Create package
|
- name: Create package
|
||||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||||
working-directory: ${{ matrix.app }}/
|
working-directory: ${{ matrix.app }}/
|
||||||
run: zip -r ../${{ matrix.asset }}.zip .
|
run: zip -r ../${{ matrix.asset }}.${{ matrix.rid }}.zip .
|
||||||
|
|
||||||
- name: Upload release asset
|
- name: Upload release asset
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: >
|
run: >
|
||||||
gh release upload ${{ github.ref_name }}
|
gh release upload ${{ github.ref_name }}
|
||||||
${{ matrix.asset }}.zip
|
${{ matrix.asset }}.${{ matrix.rid }}.zip
|
||||||
--repo ${{ github.event.repository.full_name }}
|
--repo ${{ github.event.repository.full_name }}
|
||||||
|
|
||||||
notify:
|
notify:
|
||||||
@@ -191,7 +244,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Notify Discord
|
- name: Notify Discord
|
||||||
uses: tyrrrz/action-http-request@64c70c67f5ebc54d4c7ea09cbe3553322778afd5 # 1.1.2
|
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
|
||||||
with:
|
with:
|
||||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
method: POST
|
method: POST
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Version>999.9.9-dev</Version>
|
<Version>999.9.9-dev</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>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,33 +1,30 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<UserSecretsId>d1fe5ae2-2a19-404d-a36e-81ba9eada1c1</UserSecretsId>
|
<UserSecretsId>d1fe5ae2-2a19-404d-a36e-81ba9eada1c1</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.0.7" />
|
<PackageReference Include="AngleSharp" Version="1.3.0" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
<PackageReference Include="FluentAssertions" Version="8.3.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.5" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
<PackageReference
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
Version="9.0.5"
|
||||||
<PackageReference Include="ReflectionMagic" Version="5.0.0" />
|
/>
|
||||||
<PackageReference Include="xunit" Version="2.6.4" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.5" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" PrivateAssets="all" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public static class ChannelIds
|
|||||||
|
|
||||||
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
||||||
|
|
||||||
|
public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636");
|
||||||
|
|
||||||
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
|
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
|
||||||
|
|
||||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||||
|
|||||||
@@ -19,12 +19,7 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
|
|||||||
|
|
||||||
public static class ExportWrapper
|
public static class ExportWrapper
|
||||||
{
|
{
|
||||||
private static readonly AsyncKeyedLocker<string> Locker =
|
private static readonly AsyncKeyedLocker<string> Locker = new();
|
||||||
new(o =>
|
|
||||||
{
|
|
||||||
o.PoolSize = 20;
|
|
||||||
o.PoolInitialFill = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
private static readonly string DirPath = Path.Combine(
|
private static readonly string DirPath = Path.Combine(
|
||||||
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||||
@@ -61,7 +56,7 @@ public static class ExportWrapper
|
|||||||
ExportFormat = format,
|
ExportFormat = format,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
Locale = "en-US",
|
Locale = "en-US",
|
||||||
IsUtcNormalizationEnabled = true
|
IsUtcNormalizationEnabled = true,
|
||||||
}.ExecuteAsync(console);
|
}.ExecuteAsync(console);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,13 +88,12 @@ public static class ExportWrapper
|
|||||||
Snowflake messageId
|
Snowflake messageId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(
|
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
|
||||||
e =>
|
string.Equals(
|
||||||
string.Equals(
|
e.GetAttribute("data-message-id"),
|
||||||
e.GetAttribute("data-message-id"),
|
messageId.ToString(),
|
||||||
messageId.ToString(),
|
StringComparison.OrdinalIgnoreCase
|
||||||
StringComparison.OrdinalIgnoreCase
|
)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (message is null)
|
if (message is null)
|
||||||
@@ -117,13 +111,12 @@ public static class ExportWrapper
|
|||||||
Snowflake messageId
|
Snowflake messageId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(
|
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
|
||||||
j =>
|
string.Equals(
|
||||||
string.Equals(
|
j.GetProperty("id").GetString(),
|
||||||
j.GetProperty("id").GetString(),
|
messageId.ToString(),
|
||||||
messageId.ToString(),
|
StringComparison.OrdinalIgnoreCase
|
||||||
StringComparison.OrdinalIgnoreCase
|
)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (message.ValueKind == JsonValueKind.Undefined)
|
if (message.ValueKind == JsonValueKind.Undefined)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public class DateRangeSpecs
|
|||||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
After = Snowflake.FromDate(after)
|
After = Snowflake.FromDate(after),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -50,13 +50,11 @@ public class DateRangeSpecs
|
|||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
|
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
|
||||||
],
|
],
|
||||||
o =>
|
o =>
|
||||||
o.Using<DateTimeOffset>(
|
o.Using<DateTimeOffset>(ctx =>
|
||||||
ctx =>
|
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||||
ctx.Subject.Should()
|
|
||||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
|
||||||
)
|
)
|
||||||
.WhenTypeIs<DateTimeOffset>()
|
.WhenTypeIs<DateTimeOffset>()
|
||||||
);
|
);
|
||||||
@@ -76,7 +74,7 @@ public class DateRangeSpecs
|
|||||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
Before = Snowflake.FromDate(before)
|
Before = Snowflake.FromDate(before),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -94,13 +92,11 @@ public class DateRangeSpecs
|
|||||||
[
|
[
|
||||||
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
|
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero),
|
||||||
],
|
],
|
||||||
o =>
|
o =>
|
||||||
o.Using<DateTimeOffset>(
|
o.Using<DateTimeOffset>(ctx =>
|
||||||
ctx =>
|
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||||
ctx.Subject.Should()
|
|
||||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
|
||||||
)
|
)
|
||||||
.WhenTypeIs<DateTimeOffset>()
|
.WhenTypeIs<DateTimeOffset>()
|
||||||
);
|
);
|
||||||
@@ -122,7 +118,7 @@ public class DateRangeSpecs
|
|||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
Before = Snowflake.FromDate(before),
|
Before = Snowflake.FromDate(before),
|
||||||
After = Snowflake.FromDate(after)
|
After = Snowflake.FromDate(after),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -141,15 +137,40 @@ public class DateRangeSpecs
|
|||||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
|
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||||
],
|
],
|
||||||
o =>
|
o =>
|
||||||
o.Using<DateTimeOffset>(
|
o.Using<DateTimeOffset>(ctx =>
|
||||||
ctx =>
|
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||||
ctx.Subject.Should()
|
|
||||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
|
||||||
)
|
)
|
||||||
.WhenTypeIs<DateTimeOffset>()
|
.WhenTypeIs<DateTimeOffset>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_filter_the_export_to_not_include_any_messages()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var before = new DateTimeOffset(2020, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
using var file = TempFile.Create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = file.Path,
|
||||||
|
Before = Snowflake.FromDate(before),
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
timestamps.Should().BeEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class FilterSpecs
|
|||||||
ChannelIds = [ChannelIds.FilterTestCases],
|
ChannelIds = [ChannelIds.FilterTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
MessageFilter = MessageFilter.Parse("some text")
|
MessageFilter = MessageFilter.Parse("some text"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -54,7 +54,7 @@ public class FilterSpecs
|
|||||||
ChannelIds = [ChannelIds.FilterTestCases],
|
ChannelIds = [ChannelIds.FilterTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
MessageFilter = MessageFilter.Parse("from:Tyrrrz"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -79,7 +79,7 @@ public class FilterSpecs
|
|||||||
ChannelIds = [ChannelIds.FilterTestCases],
|
ChannelIds = [ChannelIds.FilterTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
MessageFilter = MessageFilter.Parse("has:image")
|
MessageFilter = MessageFilter.Parse("has:image"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -104,7 +104,7 @@ public class FilterSpecs
|
|||||||
ChannelIds = [ChannelIds.FilterTestCases],
|
ChannelIds = [ChannelIds.FilterTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
MessageFilter = MessageFilter.Parse("has:pin")
|
MessageFilter = MessageFilter.Parse("has:pin"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -129,7 +129,7 @@ public class FilterSpecs
|
|||||||
ChannelIds = [ChannelIds.FilterTestCases],
|
ChannelIds = [ChannelIds.FilterTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
MessageFilter = MessageFilter.Parse("has:invite")
|
MessageFilter = MessageFilter.Parse("has:invite"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -154,7 +154,7 @@ public class FilterSpecs
|
|||||||
ChannelIds = [ChannelIds.FilterTestCases],
|
ChannelIds = [ChannelIds.FilterTestCases],
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = file.Path,
|
OutputPath = file.Path,
|
||||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -90,12 +90,11 @@ public class HtmlEmbedSpecs
|
|||||||
.QuerySelectorAll("source")
|
.QuerySelectorAll("source")
|
||||||
.Select(e => e.GetAttribute("src"))
|
.Select(e => e.GetAttribute("src"))
|
||||||
.WhereNotNull()
|
.WhereNotNull()
|
||||||
.Where(
|
.Where(s =>
|
||||||
s =>
|
s.Contains(
|
||||||
s.Contains(
|
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
|
||||||
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
|
StringComparison.Ordinal
|
||||||
StringComparison.Ordinal
|
)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.Should()
|
.Should()
|
||||||
.ContainSingle();
|
.ContainSingle();
|
||||||
@@ -205,42 +204,38 @@ public class HtmlEmbedSpecs
|
|||||||
|
|
||||||
imageUrls
|
imageUrls
|
||||||
.Should()
|
.Should()
|
||||||
.Contain(
|
.Contain(u =>
|
||||||
u =>
|
u.EndsWith(
|
||||||
u.EndsWith(
|
"https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
|
||||||
"https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
|
StringComparison.Ordinal
|
||||||
StringComparison.Ordinal
|
)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
imageUrls
|
imageUrls
|
||||||
.Should()
|
.Should()
|
||||||
.Contain(
|
.Contain(u =>
|
||||||
u =>
|
u.EndsWith(
|
||||||
u.EndsWith(
|
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
|
||||||
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
|
StringComparison.Ordinal
|
||||||
StringComparison.Ordinal
|
)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
imageUrls
|
imageUrls
|
||||||
.Should()
|
.Should()
|
||||||
.Contain(
|
.Contain(u =>
|
||||||
u =>
|
u.EndsWith(
|
||||||
u.EndsWith(
|
"https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
|
||||||
"https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
|
StringComparison.Ordinal
|
||||||
StringComparison.Ordinal
|
)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
imageUrls
|
imageUrls
|
||||||
.Should()
|
.Should()
|
||||||
.Contain(
|
.Contain(u =>
|
||||||
u =>
|
u.EndsWith(
|
||||||
u.EndsWith(
|
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
|
||||||
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
|
StringComparison.Ordinal
|
||||||
StringComparison.Ordinal
|
)
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
|
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class HtmlGroupingSpecs
|
|||||||
Token = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
ChannelIds = [ChannelIds.GroupingTestCases],
|
ChannelIds = [ChannelIds.GroupingTestCases],
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = file.Path
|
OutputPath = file.Path,
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
using DiscordChatExporter.Cli.Tests.Infra;
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -19,8 +20,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Default timestamp: 2/12/2023 1:36 PM");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -33,8 +39,8 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Short time timestamp: 1:36 PM");
|
message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -47,8 +53,8 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Long time timestamp: 1:36:12 PM");
|
message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -61,8 +67,8 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Short date timestamp: 2/12/2023");
|
message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -75,8 +81,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -89,8 +100,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -105,9 +121,11 @@ public class HtmlMarkdownSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message
|
message
|
||||||
.Text()
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
.Should()
|
.Should()
|
||||||
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
|
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -120,8 +138,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class JsonEmojiSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.EmojiTestCases,
|
||||||
|
Snowflake.Parse("866768521052553216")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
|
||||||
|
inlineEmojis.Should().HaveCount(4);
|
||||||
|
|
||||||
|
inlineEmojis[0].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||||
|
inlineEmojis[0].GetProperty("name").GetString().Should().Be("🙂");
|
||||||
|
inlineEmojis[0].GetProperty("code").GetString().Should().Be("slight_smile");
|
||||||
|
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||||
|
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
inlineEmojis[1].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||||
|
inlineEmojis[1].GetProperty("name").GetString().Should().Be("😦");
|
||||||
|
inlineEmojis[1].GetProperty("code").GetString().Should().Be("frowning");
|
||||||
|
inlineEmojis[1].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||||
|
inlineEmojis[1].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
inlineEmojis[2].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||||
|
inlineEmojis[2].GetProperty("name").GetString().Should().Be("😔");
|
||||||
|
inlineEmojis[2].GetProperty("code").GetString().Should().Be("pensive");
|
||||||
|
inlineEmojis[2].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||||
|
inlineEmojis[2].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
inlineEmojis[3].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||||
|
inlineEmojis[3].GetProperty("name").GetString().Should().Be("😂");
|
||||||
|
inlineEmojis[3].GetProperty("code").GetString().Should().Be("joy");
|
||||||
|
inlineEmojis[3].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||||
|
inlineEmojis[3].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.EmojiTestCases,
|
||||||
|
Snowflake.Parse("1299804867447230594")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
|
||||||
|
inlineEmojis.Should().HaveCount(1);
|
||||||
|
|
||||||
|
inlineEmojis[0].GetProperty("id").GetString().Should().Be("754441880066064584");
|
||||||
|
inlineEmojis[0].GetProperty("name").GetString().Should().Be("lemon_blush");
|
||||||
|
inlineEmojis[0].GetProperty("code").GetString().Should().Be("lemon_blush");
|
||||||
|
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||||
|
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ public class PartitioningSpecs
|
|||||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
PartitionLimit = PartitionLimit.Parse("3")
|
PartitionLimit = PartitionLimit.Parse("3"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -48,7 +48,7 @@ public class PartitioningSpecs
|
|||||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
PartitionLimit = PartitionLimit.Parse("1kb")
|
PartitionLimit = PartitionLimit.Parse("1kb"),
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class SelfContainedSpecs
|
|||||||
ChannelIds = [ChannelIds.SelfContainedTestCases],
|
ChannelIds = [ChannelIds.SelfContainedTestCases],
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
ShouldDownloadAssets = true
|
ShouldDownloadAssets = true,
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class StringExtensions
|
||||||
|
{
|
||||||
|
public static string ReplaceWhiteSpace(this string str, string replacement = " ")
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(str.Length);
|
||||||
|
|
||||||
|
foreach (var ch in str)
|
||||||
|
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# -- Build
|
# -- Build
|
||||||
# Specify the platform here so that we pull the SDK image matching the host platform,
|
# Specify the platform here so that we pull the SDK image matching the host platform,
|
||||||
# instead of the target platform specified during build by the `--platform` option.
|
# instead of the target platform specified during build by the `--platform` option.
|
||||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
|
||||||
|
|
||||||
# Expose the target architecture set by the `docker build --platform` option, so that
|
# Expose the target architecture set by the `docker build --platform` option, so that
|
||||||
# we can build the assembly for the correct platform.
|
# we can build the assembly for the correct platform.
|
||||||
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
|
|||||||
|
|
||||||
# -- Run
|
# -- Run
|
||||||
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
||||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS run
|
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
||||||
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
||||||
@@ -45,6 +45,9 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
|||||||
ENV LC_ALL=en_US.UTF-8
|
ENV LC_ALL=en_US.UTF-8
|
||||||
ENV LANG=en_US.UTF-8
|
ENV LANG=en_US.UTF-8
|
||||||
|
|
||||||
|
# Alpine is missing tzdata, which we need to support timezones
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
# Use a non-root user to ensure that the files shared with the host are accessible by the host user
|
# Use a non-root user to ensure that the files shared with the host are accessible by the host user
|
||||||
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
|
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
|
||||||
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
|
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
@@ -27,8 +28,19 @@ public abstract class DiscordCommandBase : ICommand
|
|||||||
)]
|
)]
|
||||||
public bool IsBotToken { get; init; } = false;
|
public bool IsBotToken { get; init; } = false;
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
[CommandOption(
|
||||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
|
"respect-rate-limits",
|
||||||
|
Description = "Whether to respect advisory rate limits. "
|
||||||
|
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
|
||||||
|
)]
|
||||||
|
public bool ShouldRespectRateLimits { get; init; } = true;
|
||||||
|
|
||||||
|
[field: AllowNull, MaybeNull]
|
||||||
|
protected DiscordClient Discord =>
|
||||||
|
field ??= new DiscordClient(
|
||||||
|
Token,
|
||||||
|
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
||||||
|
);
|
||||||
|
|
||||||
public virtual ValueTask ExecuteAsync(IConsole console)
|
public virtual ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -8,6 +9,7 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Exceptions;
|
using CliFx.Exceptions;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
using DiscordChatExporter.Cli.Commands.Converters;
|
||||||
|
using DiscordChatExporter.Cli.Commands.Shared;
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
@@ -15,7 +17,6 @@ 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.Extensions;
|
|
||||||
using Gress;
|
using Gress;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
|||||||
|
|
||||||
public abstract class ExportCommandBase : DiscordCommandBase
|
public abstract class ExportCommandBase : DiscordCommandBase
|
||||||
{
|
{
|
||||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"output",
|
"output",
|
||||||
'o',
|
'o',
|
||||||
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public string OutputPath
|
public string OutputPath
|
||||||
{
|
{
|
||||||
get => _outputPath;
|
get;
|
||||||
// Handle ~/ in paths on Unix systems
|
// Handle ~/ in paths on Unix systems
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||||
init => _outputPath = Path.GetFullPath(value);
|
init => field = Path.GetFullPath(value);
|
||||||
}
|
} = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Export format.")]
|
[CommandOption("format", 'f', Description = "Export format.")]
|
||||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||||
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"include-threads",
|
||||||
|
Description = "Which types of threads should be included.",
|
||||||
|
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||||
|
)]
|
||||||
|
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"filter",
|
"filter",
|
||||||
Description = "Only include messages that satisfy this filter. "
|
Description = "Only include messages that satisfy this filter. "
|
||||||
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public bool ShouldReuseAssets { get; init; } = false;
|
public bool ShouldReuseAssets { get; init; } = false;
|
||||||
|
|
||||||
private readonly string? _assetsDirPath;
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"media-dir",
|
"media-dir",
|
||||||
Description = "Download assets to this directory. "
|
Description = "Download assets to this directory. "
|
||||||
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public string? AssetsDirPath
|
public string? AssetsDirPath
|
||||||
{
|
{
|
||||||
get => _assetsDirPath;
|
get;
|
||||||
// Handle ~/ in paths on Unix systems
|
// Handle ~/ in paths on Unix systems
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||||
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
|
init => field = value is not null ? Path.GetFullPath(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||||
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
||||||
|
|
||||||
private ChannelExporter? _channelExporter;
|
[field: AllowNull, MaybeNull]
|
||||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
// Asset reuse can only be enabled if the download assets option is set
|
// Asset reuse can only be enabled if the download assets option is set
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||||
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var unwrappedChannels = new List<Channel>(channels);
|
||||||
|
|
||||||
|
// Unwrap threads
|
||||||
|
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync("Fetching threads...");
|
||||||
|
|
||||||
|
var fetchedThreadsCount = 0;
|
||||||
|
await console
|
||||||
|
.CreateStatusTicker()
|
||||||
|
.StartAsync(
|
||||||
|
"...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
await foreach (
|
||||||
|
var thread in Discord.GetChannelThreadsAsync(
|
||||||
|
channels,
|
||||||
|
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
unwrappedChannels.Add(thread);
|
||||||
|
|
||||||
|
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
||||||
|
|
||||||
|
fetchedThreadsCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove forums, as they cannot be exported directly and their constituent threads
|
||||||
|
// have already been fetched.
|
||||||
|
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure the user does not try to export multiple channels into one file.
|
// Make sure the user does not try to export multiple channels into one file.
|
||||||
// Output path must either be a directory or contain template tokens for this to work.
|
// Output path must either be a directory or contain template tokens for this to work.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||||
var isValidOutputPath =
|
var isValidOutputPath =
|
||||||
// Anything is valid when exporting a single channel
|
// Anything is valid when exporting a single channel
|
||||||
channels.Count <= 1
|
unwrappedChannels.Count <= 1
|
||||||
// When using template tokens, assume the user knows what they're doing
|
// When using template tokens, assume the user knows what they're doing
|
||||||
|| OutputPath.Contains('%')
|
|| OutputPath.Contains('%')
|
||||||
// Otherwise, require an existing directory or an unambiguous directory path
|
// Otherwise, require an existing directory or an unambiguous directory path
|
||||||
@@ -177,10 +223,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
|
||||||
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
|
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
|
||||||
await console
|
await console
|
||||||
.CreateProgressTicker()
|
.CreateProgressTicker()
|
||||||
.HideCompleted(
|
.HideCompleted(
|
||||||
@@ -192,11 +238,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
.StartAsync(async ctx =>
|
.StartAsync(async ctx =>
|
||||||
{
|
{
|
||||||
await Parallel.ForEachAsync(
|
await Parallel.ForEachAsync(
|
||||||
channels,
|
unwrappedChannels,
|
||||||
new ParallelOptions
|
new ParallelOptions
|
||||||
{
|
{
|
||||||
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
||||||
CancellationToken = cancellationToken
|
CancellationToken = cancellationToken,
|
||||||
},
|
},
|
||||||
async (channel, innerCancellationToken) =>
|
async (channel, innerCancellationToken) =>
|
||||||
{
|
{
|
||||||
@@ -236,6 +282,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
catch (ChannelEmptyException ex)
|
||||||
|
{
|
||||||
|
warningsByChannel[channel] = ex.Message;
|
||||||
|
}
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
{
|
{
|
||||||
errorsByChannel[channel] = ex.Message;
|
errorsByChannel[channel] = ex.Message;
|
||||||
@@ -248,10 +298,32 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
{
|
{
|
||||||
await console.Output.WriteLineAsync(
|
await console.Output.WriteLineAsync(
|
||||||
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
|
$"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print warnings
|
||||||
|
if (warningsByChannel.Any())
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
{
|
||||||
|
await console.Error.WriteLineAsync(
|
||||||
|
"Warnings reported for the following channel(s):"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (channel, message) in warningsByChannel)
|
||||||
|
{
|
||||||
|
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
await console.Error.WriteLineAsync(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await console.Error.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Print errors
|
// Print errors
|
||||||
if (errorsByChannel.Any())
|
if (errorsByChannel.Any())
|
||||||
{
|
{
|
||||||
@@ -259,16 +331,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
{
|
{
|
||||||
await console.Error.WriteLineAsync(
|
await console.Error.WriteLineAsync("Failed to export the following channel(s):");
|
||||||
$"Failed to export {errorsByChannel.Count} the following channel(s):"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (channel, error) in errorsByChannel)
|
foreach (var (channel, message) in errorsByChannel)
|
||||||
{
|
{
|
||||||
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
await console.Error.WriteLineAsync(error);
|
await console.Error.WriteLineAsync(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
await console.Error.WriteLineAsync();
|
await console.Error.WriteLineAsync();
|
||||||
@@ -276,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
// Fail the command only if ALL channels failed to export.
|
// Fail the command only if ALL channels failed to export.
|
||||||
// If only some channels failed to export, it's okay.
|
// If only some channels failed to export, it's okay.
|
||||||
if (errorsByChannel.Count >= channels.Count)
|
if (errorsByChannel.Count >= unwrappedChannels.Count)
|
||||||
throw new CommandException("Export failed.");
|
throw new CommandException("Export failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
|
|
||||||
{
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
|
||||||
|
|
||||||
var channels = new List<Channel>();
|
|
||||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
|
||||||
|
|
||||||
foreach (var channelId in channelIds)
|
|
||||||
{
|
|
||||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
|
||||||
|
|
||||||
// Unwrap categories
|
|
||||||
if (channel.IsCategory)
|
|
||||||
{
|
|
||||||
var guildChannels =
|
|
||||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
|
||||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
|
||||||
|
|
||||||
foreach (var guildChannel in guildChannels)
|
|
||||||
{
|
|
||||||
if (guildChannel.Parent?.Id == channel.Id)
|
|
||||||
channels.Add(guildChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the guild channels to avoid redundant work
|
|
||||||
channelsByGuild[channel.GuildId] = guildChannels;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
channels.Add(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
// Support Ukraine callout
|
// Support Ukraine callout
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ 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.Cli.Commands.Converters;
|
|
||||||
using DiscordChatExporter.Cli.Commands.Shared;
|
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Discord.Dump;
|
using DiscordChatExporter.Core.Discord.Dump;
|
||||||
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||||
public bool IncludeVoiceChannels { get; init; } = true;
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
[CommandOption(
|
|
||||||
"include-threads",
|
|
||||||
Description = "Which types of threads should be included.",
|
|
||||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
|
||||||
)]
|
|
||||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"data-package",
|
"data-package",
|
||||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||||
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
);
|
);
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||||
|
|
||||||
// Threads
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
|
||||||
{
|
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Fetching threads for server '{guild.Name}'..."
|
|
||||||
);
|
|
||||||
|
|
||||||
var fetchedThreadsCount = 0;
|
|
||||||
await console
|
|
||||||
.CreateStatusTicker()
|
|
||||||
.StartAsync(
|
|
||||||
"...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await foreach (
|
|
||||||
var thread in Discord.GetGuildThreadsAsync(
|
|
||||||
guild.Id,
|
|
||||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
|
||||||
Before,
|
|
||||||
After,
|
|
||||||
cancellationToken
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
channels.Add(thread);
|
|
||||||
|
|
||||||
ctx.Status(
|
|
||||||
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
|
|
||||||
);
|
|
||||||
|
|
||||||
fetchedThreadsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Fetched {fetchedThreadsCount} thread(s)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pull from the data package
|
// Pull from the data package
|
||||||
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
channels.RemoveAll(c => c.IsGuild);
|
channels.RemoveAll(c => c.IsGuild);
|
||||||
if (!IncludeVoiceChannels)
|
if (!IncludeVoiceChannels)
|
||||||
channels.RemoveAll(c => c.IsVoice);
|
channels.RemoveAll(c => c.IsVoice);
|
||||||
if (ThreadInclusionMode == ThreadInclusionMode.None)
|
|
||||||
channels.RemoveAll(c => c.IsThread);
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.All)
|
|
||||||
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ 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;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands;
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
await base.ExecuteAsync(console);
|
await base.ExecuteAsync(console);
|
||||||
await ExportAsync(console, ChannelIds);
|
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||||
|
|
||||||
|
var channels = new List<Channel>();
|
||||||
|
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||||
|
|
||||||
|
foreach (var channelId in ChannelIds)
|
||||||
|
{
|
||||||
|
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||||
|
|
||||||
|
// Unwrap categories
|
||||||
|
if (channel.IsCategory)
|
||||||
|
{
|
||||||
|
var guildChannels =
|
||||||
|
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||||
|
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var guildChannel in guildChannels)
|
||||||
|
{
|
||||||
|
if (guildChannel.Parent?.Id == channel.Id)
|
||||||
|
channels.Add(guildChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the guild channels to avoid redundant work
|
||||||
|
channelsByGuild[channel.GuildId] = guildChannels;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
channels.Add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ 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.Cli.Commands.Converters;
|
|
||||||
using DiscordChatExporter.Cli.Commands.Shared;
|
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||||
public bool IncludeVoiceChannels { get; init; } = true;
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
[CommandOption(
|
|
||||||
"include-threads",
|
|
||||||
Description = "Which types of threads should be included.",
|
|
||||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
|
||||||
)]
|
|
||||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
await base.ExecuteAsync(console);
|
await base.ExecuteAsync(console);
|
||||||
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
var channels = new List<Channel>();
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
// Regular channels
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
|
|
||||||
var fetchedChannelsCount = 0;
|
var fetchedChannelsCount = 0;
|
||||||
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||||
|
|
||||||
// Threads
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
|
||||||
{
|
|
||||||
await console.Output.WriteLineAsync("Fetching threads...");
|
|
||||||
|
|
||||||
var fetchedThreadsCount = 0;
|
|
||||||
await console
|
|
||||||
.CreateStatusTicker()
|
|
||||||
.StartAsync(
|
|
||||||
"...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await foreach (
|
|
||||||
var thread in Discord.GetGuildThreadsAsync(
|
|
||||||
GuildId,
|
|
||||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
|
||||||
Before,
|
|
||||||
After,
|
|
||||||
cancellationToken
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
channels.Add(thread);
|
|
||||||
|
|
||||||
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
|
||||||
|
|
||||||
fetchedThreadsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using DiscordChatExporter.Cli.Commands.Base;
|
|||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
using DiscordChatExporter.Cli.Commands.Converters;
|
||||||
using DiscordChatExporter.Cli.Commands.Shared;
|
using DiscordChatExporter.Cli.Commands.Shared;
|
||||||
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;
|
||||||
@@ -59,7 +58,7 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||||||
)
|
)
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name)
|
||||||
.ToArray()
|
.ToArray()
|
||||||
: Array.Empty<Channel>();
|
: [];
|
||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ public class GuideCommand : ICommand
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get the token for your bot:");
|
console.Output.WriteLine("To get the token for your bot:");
|
||||||
|
|
||||||
|
console.Output.WriteLine(
|
||||||
|
" The token is generated during bot creation. If you lost it, generate a new one:"
|
||||||
|
);
|
||||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||||
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 Reset Token");
|
||||||
|
console.Output.WriteLine(" 5. Click Yes, do it! and authenticate to confirm");
|
||||||
|
console.Output.WriteLine(
|
||||||
|
" * Integrations using the previous token will stop working until updated"
|
||||||
|
);
|
||||||
console.Output.WriteLine(
|
console.Output.WriteLine(
|
||||||
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
|
|||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
Active,
|
Active,
|
||||||
All
|
All,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
|
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
<PackageReference Include="CliFx" Version="2.3.6" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.2" PrivateAssets="all" />
|
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.48.0" />
|
<PackageReference Include="Spectre.Console" Version="0.50.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -1,3 +1,41 @@
|
|||||||
using CliFx;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CliFx;
|
||||||
|
using DiscordChatExporter.Cli.Commands;
|
||||||
|
using DiscordChatExporter.Cli.Commands.Converters;
|
||||||
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
|
|
||||||
return await new CliApplicationBuilder().AddCommandsFromThisAssembly().Build().RunAsync(args);
|
namespace DiscordChatExporter.Cli;
|
||||||
|
|
||||||
|
public static class Program
|
||||||
|
{
|
||||||
|
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
|
||||||
|
[DynamicDependency(
|
||||||
|
DynamicallyAccessedMemberTypes.All,
|
||||||
|
typeof(ThreadInclusionModeBindingConverter)
|
||||||
|
)]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
|
||||||
|
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
|
||||||
|
public static async Task<int> Main(string[] args) =>
|
||||||
|
await new CliApplicationBuilder()
|
||||||
|
.AddCommand<ExportAllCommand>()
|
||||||
|
.AddCommand<ExportChannelsCommand>()
|
||||||
|
.AddCommand<ExportDirectMessagesCommand>()
|
||||||
|
.AddCommand<ExportGuildCommand>()
|
||||||
|
.AddCommand<GetChannelsCommand>()
|
||||||
|
.AddCommand<GetDirectChannelsCommand>()
|
||||||
|
.AddCommand<GetGuildsCommand>()
|
||||||
|
.AddCommand<GuideCommand>()
|
||||||
|
.Build()
|
||||||
|
.RunAsync(args);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ internal static class ConsoleExtensions
|
|||||||
{
|
{
|
||||||
Ansi = AnsiSupport.Detect,
|
Ansi = AnsiSupport.Detect,
|
||||||
ColorSystem = ColorSystemSupport.Detect,
|
ColorSystem = ColorSystemSupport.Detect,
|
||||||
Out = new AnsiConsoleOutput(console.Output)
|
Out = new AnsiConsoleOutput(console.Output),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||||||
// https://discord.com/developers/docs/resources/application#application-object
|
// https://discord.com/developers/docs/resources/application#application-object
|
||||||
public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags)
|
public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags)
|
||||||
{
|
{
|
||||||
public bool IsMessageContentIntentEnabled =>
|
public bool IsMessageContentIntentEnabled { get; } =
|
||||||
Flags.HasFlag(ApplicationFlags.GatewayMessageContent)
|
Flags.HasFlag(ApplicationFlags.GatewayMessageContent)
|
||||||
|| Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
|
|| Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,5 +16,5 @@ public enum ApplicationFlags
|
|||||||
Embedded = 131072,
|
Embedded = 131072,
|
||||||
GatewayMessageContent = 262144,
|
GatewayMessageContent = 262144,
|
||||||
GatewayMessageContentLimited = 524288,
|
GatewayMessageContentLimited = 524288,
|
||||||
ApplicationCommandBadge = 8388608
|
ApplicationCommandBadge = 8388608,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public partial record Attachment(
|
|||||||
FileSize FileSize
|
FileSize FileSize
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
public string FileExtension => Path.GetExtension(FileName);
|
public string FileExtension { get; } = Path.GetExtension(FileName);
|
||||||
|
|
||||||
public bool IsImage =>
|
public bool IsImage =>
|
||||||
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
|
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
|
||||||
@@ -41,7 +41,7 @@ public partial record Attachment(
|
|||||||
|| string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
|
|| string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
public bool IsSpoiler { get; } = FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Attachment
|
public partial record Attachment
|
||||||
|
|||||||
@@ -21,21 +21,23 @@ public partial record Channel(
|
|||||||
Snowflake? LastMessageId
|
Snowflake? LastMessageId
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsDirect => Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
|
public bool IsDirect { get; } =
|
||||||
|
Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
|
||||||
|
|
||||||
public bool IsGuild => !IsDirect;
|
public bool IsGuild => !IsDirect;
|
||||||
|
|
||||||
public bool IsCategory => Kind == ChannelKind.GuildCategory;
|
public bool IsCategory { get; } = Kind == ChannelKind.GuildCategory;
|
||||||
|
|
||||||
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
public bool IsVoice { get; } =
|
||||||
|
Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
||||||
|
|
||||||
public bool IsThread =>
|
public bool IsThread { get; } =
|
||||||
Kind
|
Kind
|
||||||
is ChannelKind.GuildNewsThread
|
is ChannelKind.GuildNewsThread
|
||||||
or ChannelKind.GuildPublicThread
|
or ChannelKind.GuildPublicThread
|
||||||
or ChannelKind.GuildPrivateThread;
|
or ChannelKind.GuildPrivateThread;
|
||||||
|
|
||||||
public bool IsEmpty => LastMessageId is null;
|
public bool IsEmpty { get; } = LastMessageId is null;
|
||||||
|
|
||||||
public IEnumerable<Channel> GetParents()
|
public IEnumerable<Channel> GetParents()
|
||||||
{
|
{
|
||||||
@@ -76,6 +78,7 @@ public partial record Channel
|
|||||||
?? json.GetPropertyOrNull("recipients")
|
?? json.GetPropertyOrNull("recipients")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(User.Parse)
|
?.Select(User.Parse)
|
||||||
|
.OrderBy(u => u.Id)
|
||||||
.Select(u => u.DisplayName)
|
.Select(u => u.DisplayName)
|
||||||
.Pipe(s => string.Join(", ", s))
|
.Pipe(s => string.Join(", ", s))
|
||||||
// Fallback
|
// Fallback
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
public record ChannelConnection(Channel Channel, IReadOnlyList<ChannelConnection> Children)
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<ChannelConnection> BuildTree(IReadOnlyList<Channel> channels)
|
||||||
|
{
|
||||||
|
IReadOnlyList<ChannelConnection> GetChildren(Channel parent) =>
|
||||||
|
channels
|
||||||
|
.Where(c => c.Parent?.Id == parent.Id)
|
||||||
|
.Select(c => new ChannelConnection(c, GetChildren(c)))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return channels
|
||||||
|
.Where(c => c.Parent is null)
|
||||||
|
.Select(c => new ChannelConnection(c, GetChildren(c)))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,5 +14,5 @@ public enum ChannelKind
|
|||||||
GuildPrivateThread = 12,
|
GuildPrivateThread = 12,
|
||||||
GuildStageVoice = 13,
|
GuildStageVoice = 13,
|
||||||
GuildDirectory = 14,
|
GuildDirectory = 14,
|
||||||
GuildForum = 15
|
GuildForum = 15,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("fields")
|
json.GetPropertyOrNull("fields")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(EmbedField.Parse)
|
?.Select(EmbedField.Parse)
|
||||||
.ToArray() ?? Array.Empty<EmbedField>();
|
.ToArray() ?? [];
|
||||||
|
|
||||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("image")
|
json.GetPropertyOrNull("image")
|
||||||
?.Pipe(EmbedImage.Parse)
|
?.Pipe(EmbedImage.Parse)
|
||||||
.ToSingletonEnumerable()
|
.ToSingletonEnumerable()
|
||||||
.ToArray() ?? Array.Empty<EmbedImage>();
|
.ToArray() ?? [];
|
||||||
|
|
||||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ public enum EmbedKind
|
|||||||
Image,
|
Image,
|
||||||
Video,
|
Video,
|
||||||
Gifv,
|
Gifv,
|
||||||
Link
|
Link,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
|||||||
|
|
||||||
public partial record SpotifyTrackEmbedProjection(string TrackId)
|
public partial record SpotifyTrackEmbedProjection(string TrackId)
|
||||||
{
|
{
|
||||||
public string Url => $"https://open.spotify.com/embed/track/{TrackId}";
|
public string Url { get; } = $"https://open.spotify.com/embed/track/{TrackId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record SpotifyTrackEmbedProjection
|
public partial record SpotifyTrackEmbedProjection
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
|||||||
|
|
||||||
public partial record TwitchClipEmbedProjection(string ClipId)
|
public partial record TwitchClipEmbedProjection(string ClipId)
|
||||||
{
|
{
|
||||||
public string Url => $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
|
public string Url { get; } = $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record TwitchClipEmbedProjection
|
public partial record TwitchClipEmbedProjection
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||||
{
|
{
|
||||||
public string Url => $"https://www.youtube.com/embed/{VideoId}";
|
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record YouTubeVideoEmbedProjection
|
public partial record YouTubeVideoEmbedProjection
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System.Text.Json;
|
||||||
using System.Text.Json;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
@@ -13,29 +12,22 @@ public partial record Emoji(
|
|||||||
Snowflake? Id,
|
Snowflake? Id,
|
||||||
// Name of a custom emoji (e.g. LUL) or actual representation of a 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
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
public bool IsCustomEmoji { get; } = Id is not null;
|
||||||
|
|
||||||
// Name of a custom emoji (e.g. LUL) or name of a 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 ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||||
|
|
||||||
|
public string ImageUrl { get; } =
|
||||||
|
Id is not null
|
||||||
|
? ImageCdn.GetCustomEmojiUrl(Id.Value, IsAnimated)
|
||||||
|
: ImageCdn.GetStandardEmojiUrl(Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Emoji
|
public partial record Emoji
|
||||||
{
|
{
|
||||||
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
|
|
||||||
{
|
|
||||||
// Custom emoji
|
|
||||||
if (id is not null)
|
|
||||||
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
|
|
||||||
|
|
||||||
// Standard emoji
|
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
|
||||||
return ImageCdn.GetStandardEmojiUrl(name);
|
|
||||||
|
|
||||||
throw new InvalidOperationException("Either the emoji ID or name should be provided.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Emoji Parse(JsonElement json)
|
public static Emoji Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetPropertyOrNull("id")
|
var id = json.GetPropertyOrNull("id")
|
||||||
@@ -47,8 +39,7 @@ public partial record Emoji
|
|||||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
|
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
|
||||||
|
|
||||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
||||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
|
||||||
|
|
||||||
return new Emoji(id, name, isAnimated, imageUrl);
|
return new Emoji(id, name, isAnimated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||||
public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsDirect => Id == DirectMessages.Id;
|
public bool IsDirect { get; } = Id == Snowflake.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Guild
|
public partial record Guild
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
@@ -16,13 +15,12 @@ public partial record Member(
|
|||||||
IReadOnlyList<Snowflake> RoleIds
|
IReadOnlyList<Snowflake> RoleIds
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
public Snowflake Id => User.Id;
|
public Snowflake Id { get; } = User.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Member
|
public partial record Member
|
||||||
{
|
{
|
||||||
public static Member CreateFallback(User user) =>
|
public static Member CreateFallback(User user) => new(user, null, null, []);
|
||||||
new(user, null, null, Array.Empty<Snowflake>());
|
|
||||||
|
|
||||||
public static Member Parse(JsonElement json, Snowflake? guildId = null)
|
public static Member Parse(JsonElement json, Snowflake? guildId = null)
|
||||||
{
|
{
|
||||||
@@ -34,7 +32,7 @@ public partial record Member
|
|||||||
?.EnumerateArray()
|
?.EnumerateArray()
|
||||||
.Select(j => j.GetNonWhiteSpaceString())
|
.Select(j => j.GetNonWhiteSpaceString())
|
||||||
.Select(Snowflake.Parse)
|
.Select(Snowflake.Parse)
|
||||||
.ToArray() ?? Array.Empty<Snowflake>();
|
.ToArray() ?? [];
|
||||||
|
|
||||||
var avatarUrl = guildId is not null
|
var avatarUrl = guildId is not null
|
||||||
? json.GetPropertyOrNull("avatar")
|
? json.GetPropertyOrNull("avatar")
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ public partial record Message(
|
|||||||
Interaction? Interaction
|
Interaction? Interaction
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsSystemNotification =>
|
public bool IsSystemNotification { get; } =
|
||||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||||
|
|
||||||
public bool IsReply => Kind == MessageKind.Reply;
|
public bool IsReply { get; } = Kind == MessageKind.Reply;
|
||||||
|
|
||||||
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
||||||
public bool IsReplyLike => IsReply || Interaction is not null;
|
public bool IsReplyLike => IsReply || Interaction is not null;
|
||||||
|
|
||||||
public bool IsEmpty =>
|
public bool IsEmpty { get; } =
|
||||||
string.IsNullOrWhiteSpace(Content)
|
string.IsNullOrWhiteSpace(Content)
|
||||||
&& !Attachments.Any()
|
&& !Attachments.Any()
|
||||||
&& !Embeds.Any()
|
&& !Embeds.Any()
|
||||||
@@ -83,16 +83,15 @@ public partial record Message
|
|||||||
// 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
|
||||||
.Skip(i + 1)
|
.Skip(i + 1)
|
||||||
.TakeWhile(
|
.TakeWhile(e =>
|
||||||
e =>
|
e.Url == embed.Url
|
||||||
e.Url == embed.Url
|
&& e.Timestamp is null
|
||||||
&& e.Timestamp is null
|
&& e.Author is null
|
||||||
&& e.Author is null
|
&& e.Color is null
|
||||||
&& e.Color is null
|
&& string.IsNullOrWhiteSpace(e.Description)
|
||||||
&& string.IsNullOrWhiteSpace(e.Description)
|
&& !e.Fields.Any()
|
||||||
&& !e.Fields.Any()
|
&& e.Images.Count == 1
|
||||||
&& e.Images.Count == 1
|
&& e.Footer is null
|
||||||
&& e.Footer is null
|
|
||||||
)
|
)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -144,28 +143,28 @@ public partial record Message
|
|||||||
json.GetPropertyOrNull("attachments")
|
json.GetPropertyOrNull("attachments")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Attachment.Parse)
|
?.Select(Attachment.Parse)
|
||||||
.ToArray() ?? Array.Empty<Attachment>();
|
.ToArray() ?? [];
|
||||||
|
|
||||||
var embeds = NormalizeEmbeds(
|
var embeds = NormalizeEmbeds(
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
?? Array.Empty<Embed>()
|
?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
var stickers =
|
var stickers =
|
||||||
json.GetPropertyOrNull("sticker_items")
|
json.GetPropertyOrNull("sticker_items")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Sticker.Parse)
|
?.Select(Sticker.Parse)
|
||||||
.ToArray() ?? Array.Empty<Sticker>();
|
.ToArray() ?? [];
|
||||||
|
|
||||||
var reactions =
|
var reactions =
|
||||||
json.GetPropertyOrNull("reactions")
|
json.GetPropertyOrNull("reactions")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Reaction.Parse)
|
?.Select(Reaction.Parse)
|
||||||
.ToArray() ?? Array.Empty<Reaction>();
|
.ToArray() ?? [];
|
||||||
|
|
||||||
var mentionedUsers =
|
var mentionedUsers =
|
||||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||||
?? Array.Empty<User>();
|
?? [];
|
||||||
|
|
||||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||||
?.Pipe(MessageReference.Parse);
|
?.Pipe(MessageReference.Parse);
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ public enum MessageFlags
|
|||||||
Urgent = 16,
|
Urgent = 16,
|
||||||
HasThread = 32,
|
HasThread = 32,
|
||||||
Ephemeral = 64,
|
Ephemeral = 64,
|
||||||
Loading = 128
|
Loading = 128,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ public enum MessageKind
|
|||||||
ChannelPinnedMessage = 6,
|
ChannelPinnedMessage = 6,
|
||||||
GuildMemberJoin = 7,
|
GuildMemberJoin = 7,
|
||||||
ThreadCreated = 18,
|
ThreadCreated = 18,
|
||||||
Reply = 19
|
Reply = 19,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||||||
// https://discord.com/developers/docs/resources/sticker#sticker-resource
|
// https://discord.com/developers/docs/resources/sticker#sticker-resource
|
||||||
public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
|
public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
|
||||||
{
|
{
|
||||||
public bool IsImage => Format != StickerFormat.Lottie;
|
public bool IsImage { get; } = Format != StickerFormat.Lottie;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Sticker
|
public partial record Sticker
|
||||||
@@ -28,7 +28,7 @@ public partial record Sticker
|
|||||||
StickerFormat.Apng => "png",
|
StickerFormat.Apng => "png",
|
||||||
StickerFormat.Lottie => "json",
|
StickerFormat.Lottie => "json",
|
||||||
StickerFormat.Gif => "gif",
|
StickerFormat.Gif => "gif",
|
||||||
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.")
|
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'."),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ public enum StickerFormat
|
|||||||
Png = 1,
|
Png = 1,
|
||||||
Apng = 2,
|
Apng = 2,
|
||||||
Lottie = 3,
|
Lottie = 3,
|
||||||
Gif = 4
|
Gif = 4,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public partial record User(
|
|||||||
string AvatarUrl
|
string AvatarUrl
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
public string DiscriminatorFormatted =>
|
public string DiscriminatorFormatted { get; } =
|
||||||
Discriminator is not null ? $"{Discriminator:0000}" : "0000";
|
Discriminator is not null ? $"{Discriminator:0000}" : "0000";
|
||||||
|
|
||||||
// This effectively represents the user's true identity.
|
// This effectively represents the user's true identity.
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ using JsonExtensions.Reading;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord;
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
public class DiscordClient(string token)
|
public class DiscordClient(
|
||||||
|
string token,
|
||||||
|
RateLimitPreference rateLimitPreference = RateLimitPreference.RespectAll
|
||||||
|
)
|
||||||
{
|
{
|
||||||
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
|
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
|
||||||
private TokenKind? _resolvedTokenKind;
|
private TokenKind? _resolvedTokenKind;
|
||||||
@@ -47,33 +50,41 @@ public class DiscordClient(string token)
|
|||||||
innerCancellationToken
|
innerCancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
// If this was the last request available before hitting the rate limit,
|
// Discord has advisory rate limits (communicated via response headers), but they are typically
|
||||||
// wait out the reset time so that future requests can succeed.
|
// way stricter than the actual rate limits enforced by the server.
|
||||||
// This may add an unnecessary delay in case the user doesn't intend to
|
// The user may choose to ignore the advisory rate limits and only retry on hard rate limits,
|
||||||
// make any more requests, but implementing a smarter solution would
|
// if they want to prioritize speed over compliance (and safety of their account/bot).
|
||||||
// require properly keeping track of Discord's global/per-route/per-resource
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1021
|
||||||
// rate limits and that's just way too much effort.
|
if (rateLimitPreference.IsRespectedFor(tokenKind))
|
||||||
// https://discord.com/developers/docs/topics/rate-limits
|
|
||||||
var remainingRequestCount = response
|
|
||||||
.Headers.TryGetValue("X-RateLimit-Remaining")
|
|
||||||
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
var resetAfterDelay = response
|
|
||||||
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
|
||||||
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
|
||||||
.Pipe(TimeSpan.FromSeconds);
|
|
||||||
|
|
||||||
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
|
||||||
{
|
{
|
||||||
var delay =
|
var remainingRequestCount = response
|
||||||
// Adding a small buffer to the reset time reduces the chance of getting
|
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||||
// rate limited again, because it allows for more requests to be released.
|
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||||
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
|
||||||
// Sometimes Discord returns an absurdly high value for the reset time, which
|
|
||||||
// is not actually enforced by the server. So we cap it at a reasonable value.
|
|
||||||
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
|
||||||
|
|
||||||
await Task.Delay(delay, innerCancellationToken);
|
var resetAfterDelay = response
|
||||||
|
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||||
|
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
||||||
|
.Pipe(TimeSpan.FromSeconds);
|
||||||
|
|
||||||
|
// If this was the last request available before hitting the rate limit,
|
||||||
|
// wait out the reset time so that future requests can succeed.
|
||||||
|
// This may add an unnecessary delay in case the user doesn't intend to
|
||||||
|
// make any more requests, but implementing a smarter solution would
|
||||||
|
// require properly keeping track of Discord's global/per-route/per-resource
|
||||||
|
// rate limits and that's just way too much effort.
|
||||||
|
// https://discord.com/developers/docs/topics/rate-limits
|
||||||
|
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
||||||
|
{
|
||||||
|
var delay =
|
||||||
|
// Adding a small buffer to the reset time reduces the chance of getting
|
||||||
|
// rate limited again, because it allows for more requests to be released.
|
||||||
|
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
||||||
|
// Sometimes Discord returns an absurdly high value for the reset time, which
|
||||||
|
// is not actually enforced by the server. So we cap it at a reasonable value.
|
||||||
|
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
|
await Task.Delay(delay, innerCancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -133,30 +144,31 @@ public class DiscordClient(string token)
|
|||||||
{
|
{
|
||||||
throw response.StatusCode switch
|
throw response.StatusCode switch
|
||||||
{
|
{
|
||||||
HttpStatusCode.Unauthorized
|
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
|
||||||
=> throw new DiscordChatExporterException(
|
"Authentication token is invalid.",
|
||||||
"Authentication token is invalid.",
|
true
|
||||||
true
|
),
|
||||||
),
|
|
||||||
|
|
||||||
HttpStatusCode.Forbidden
|
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
|
||||||
=> throw new DiscordChatExporterException(
|
$"Request to '{url}' failed: forbidden."
|
||||||
$"Request to '{url}' failed: forbidden."
|
),
|
||||||
),
|
|
||||||
|
|
||||||
HttpStatusCode.NotFound
|
HttpStatusCode.NotFound => throw new DiscordChatExporterException(
|
||||||
=> throw new DiscordChatExporterException(
|
$"Request to '{url}' failed: not found."
|
||||||
$"Request to '{url}' failed: not found."
|
),
|
||||||
),
|
|
||||||
|
|
||||||
_
|
_ => throw new DiscordChatExporterException(
|
||||||
=> throw new DiscordChatExporterException(
|
$"""
|
||||||
$"""
|
Request to '{url}' failed: {response
|
||||||
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
|
.StatusCode.ToString()
|
||||||
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
|
.ToSpaceSeparatedWords()
|
||||||
""",
|
.ToLowerInvariant()}.
|
||||||
true
|
Response content: {await response.Content.ReadAsStringAsync(
|
||||||
)
|
cancellationToken
|
||||||
|
)}
|
||||||
|
""",
|
||||||
|
true
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,184 +305,19 @@ public class DiscordClient(string token)
|
|||||||
if (guildId == Guild.DirectMessages.Id)
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken))
|
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
|
||||||
// Categories cannot have threads
|
|
||||||
.Where(c => !c.IsCategory)
|
|
||||||
// Voice channels cannot have threads
|
|
||||||
.Where(c => !c.IsVoice)
|
|
||||||
// Empty channels cannot have threads
|
|
||||||
.Where(c => !c.IsEmpty)
|
|
||||||
// If the 'before' boundary is specified, skip channels that don't have messages
|
|
||||||
// for that range, because thread-start event should always be accompanied by a message.
|
|
||||||
// Note that we don't perform a similar check for the 'after' boundary, because
|
|
||||||
// threads may have messages in range, even if the parent channel doesn't.
|
|
||||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// User accounts can only fetch threads using the search endpoint
|
foreach (
|
||||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
var channel in await GetChannelThreadsAsync(
|
||||||
|
channels,
|
||||||
|
includeArchived,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Active threads
|
yield return channel;
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
var currentOffset = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var url = new UrlBuilder()
|
|
||||||
.SetPath($"channels/{channel.Id}/threads/search")
|
|
||||||
.SetQueryParameter("sort_by", "last_message_time")
|
|
||||||
.SetQueryParameter("sort_order", "desc")
|
|
||||||
.SetQueryParameter("archived", "false")
|
|
||||||
.SetQueryParameter("offset", currentOffset.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Can be null on channels that the user cannot access or channels without threads
|
|
||||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
|
||||||
if (response is null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var breakOuter = false;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var thread = Channel.Parse(threadJson, channel);
|
|
||||||
|
|
||||||
// If the 'after' boundary is specified, we can break early,
|
|
||||||
// because threads are sorted by last message time.
|
|
||||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
|
||||||
{
|
|
||||||
breakOuter = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return thread;
|
|
||||||
currentOffset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (breakOuter)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archived threads
|
|
||||||
if (includeArchived)
|
|
||||||
{
|
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
var currentOffset = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var url = new UrlBuilder()
|
|
||||||
.SetPath($"channels/{channel.Id}/threads/search")
|
|
||||||
.SetQueryParameter("sort_by", "last_message_time")
|
|
||||||
.SetQueryParameter("sort_order", "desc")
|
|
||||||
.SetQueryParameter("archived", "true")
|
|
||||||
.SetQueryParameter("offset", currentOffset.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Can be null on channels that the user cannot access or channels without threads
|
|
||||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
|
||||||
if (response is null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var breakOuter = false;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var thread = Channel.Parse(threadJson, channel);
|
|
||||||
|
|
||||||
// If the 'after' boundary is specified, we can break early,
|
|
||||||
// because threads are sorted by last message time.
|
|
||||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
|
||||||
{
|
|
||||||
breakOuter = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return thread;
|
|
||||||
currentOffset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (breakOuter)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bot accounts can only fetch threads using the threads endpoint
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Active threads
|
|
||||||
{
|
|
||||||
var parentsById = channels.ToDictionary(c => c.Id);
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(
|
|
||||||
$"guilds/{guildId}/threads/active",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
|
||||||
{
|
|
||||||
var parent = threadJson
|
|
||||||
.GetPropertyOrNull("parent_id")
|
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
|
||||||
?.Pipe(Snowflake.Parse)
|
|
||||||
.Pipe(parentsById.GetValueOrDefault);
|
|
||||||
|
|
||||||
yield return Channel.Parse(threadJson, parent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archived threads
|
|
||||||
if (includeArchived)
|
|
||||||
{
|
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
// Public archived threads
|
|
||||||
{
|
|
||||||
// Can be null on certain channels
|
|
||||||
var response = await TryGetJsonResponseAsync(
|
|
||||||
$"channels/{channel.Id}/threads/archived/public",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
yield return Channel.Parse(threadJson, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private archived threads
|
|
||||||
{
|
|
||||||
// Can be null on certain channels
|
|
||||||
var response = await TryGetJsonResponseAsync(
|
|
||||||
$"channels/{channel.Id}/threads/archived/private",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
yield return Channel.Parse(threadJson, channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +388,163 @@ public class DiscordClient(string token)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
|
||||||
|
IReadOnlyList<Channel> channels,
|
||||||
|
bool includeArchived = false,
|
||||||
|
Snowflake? before = null,
|
||||||
|
Snowflake? after = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var filteredChannels = channels
|
||||||
|
// Categories cannot have threads
|
||||||
|
.Where(c => !c.IsCategory)
|
||||||
|
// Voice channels cannot have threads
|
||||||
|
.Where(c => !c.IsVoice)
|
||||||
|
// Empty channels cannot have threads
|
||||||
|
.Where(c => !c.IsEmpty)
|
||||||
|
// If the 'before' boundary is specified, skip channels that don't have messages
|
||||||
|
// for that range, because thread-start event should always be accompanied by a message.
|
||||||
|
// Note that we don't perform a similar check for the 'after' boundary, because
|
||||||
|
// threads may have messages in range, even if the parent channel doesn't.
|
||||||
|
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// User accounts can only fetch threads using the search endpoint
|
||||||
|
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||||
|
{
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
{
|
||||||
|
// Either include both active and archived threads, or only active threads
|
||||||
|
foreach (
|
||||||
|
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Offset is just the index of the last thread in the previous batch
|
||||||
|
var currentOffset = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channel.Id}/threads/search")
|
||||||
|
.SetQueryParameter("sort_by", "last_message_time")
|
||||||
|
.SetQueryParameter("sort_order", "desc")
|
||||||
|
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
|
||||||
|
.SetQueryParameter("offset", currentOffset.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on channels that the user cannot access or channels without threads
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var breakOuter = false;
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, channel);
|
||||||
|
|
||||||
|
// If the 'after' boundary is specified, we can break early,
|
||||||
|
// because threads are sorted by last message timestamp.
|
||||||
|
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||||
|
{
|
||||||
|
breakOuter = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return thread;
|
||||||
|
currentOffset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakOuter)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bot accounts can only fetch threads using the threads endpoint
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var guilds = new HashSet<Snowflake>();
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
guilds.Add(channel.GuildId);
|
||||||
|
|
||||||
|
// Active threads
|
||||||
|
foreach (var guildId in guilds)
|
||||||
|
{
|
||||||
|
var parentsById = filteredChannels.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(
|
||||||
|
$"guilds/{guildId}/threads/active",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||||
|
{
|
||||||
|
var parent = threadJson
|
||||||
|
.GetPropertyOrNull("parent_id")
|
||||||
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
?.Pipe(Snowflake.Parse)
|
||||||
|
.Pipe(parentsById.GetValueOrDefault);
|
||||||
|
|
||||||
|
if (filteredChannels.Contains(parent))
|
||||||
|
yield return Channel.Parse(threadJson, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archived threads
|
||||||
|
if (includeArchived)
|
||||||
|
{
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
{
|
||||||
|
foreach (var archiveType in new[] { "public", "private" })
|
||||||
|
{
|
||||||
|
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
|
||||||
|
var currentBefore = before
|
||||||
|
?.ToDate()
|
||||||
|
.ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Threads are sorted by archive timestamp, not by last message timestamp
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
|
||||||
|
.SetQueryParameter("before", currentBefore)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on certain channels
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var threadJson in response
|
||||||
|
.Value.GetProperty("threads")
|
||||||
|
.EnumerateArray()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, channel);
|
||||||
|
yield return thread;
|
||||||
|
|
||||||
|
currentBefore = threadJson
|
||||||
|
.GetProperty("thread_metadata")
|
||||||
|
.GetProperty("archive_timestamp")
|
||||||
|
.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake? before = null,
|
Snowflake? before = null,
|
||||||
@@ -671,10 +675,14 @@ public class DiscordClient(string token)
|
|||||||
.SetQueryParameter("after", currentAfter.ToString())
|
.SetQueryParameter("after", currentAfter.ToString())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
// Can be null on reactions with an emoji that has been deleted (?)
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1226
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
yield break;
|
||||||
|
|
||||||
var count = 0;
|
var count = 0;
|
||||||
foreach (var userJson in response.EnumerateArray())
|
foreach (var userJson in response.Value.EnumerateArray())
|
||||||
{
|
{
|
||||||
var user = User.Parse(userJson);
|
var user = User.Parse(userJson);
|
||||||
yield return user;
|
yield return user;
|
||||||
@@ -683,9 +691,7 @@ public class DiscordClient(string token)
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each batch can contain up to 100 users.
|
if (count <= 0)
|
||||||
// If we got fewer, then it's definitely the last batch.
|
|
||||||
if (count < 100)
|
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum RateLimitPreference
|
||||||
|
{
|
||||||
|
IgnoreAll = 0,
|
||||||
|
RespectForUserTokens = 0b1,
|
||||||
|
RespectForBotTokens = 0b10,
|
||||||
|
RespectAll = RespectForUserTokens | RespectForBotTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RateLimitPreferenceExtensions
|
||||||
|
{
|
||||||
|
internal static bool IsRespectedFor(
|
||||||
|
this RateLimitPreference rateLimitPreference,
|
||||||
|
TokenKind tokenKind
|
||||||
|
) =>
|
||||||
|
tokenKind switch
|
||||||
|
{
|
||||||
|
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens) != 0,
|
||||||
|
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens) != 0,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string GetDisplayName(this RateLimitPreference rateLimitPreference) =>
|
||||||
|
rateLimitPreference switch
|
||||||
|
{
|
||||||
|
RateLimitPreference.IgnoreAll => "Always ignore",
|
||||||
|
RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
|
||||||
|
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
|
||||||
|
RateLimitPreference.RespectAll => "Always respect",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
public enum TokenKind
|
public enum TokenKind
|
||||||
{
|
{
|
||||||
User,
|
User,
|
||||||
Bot
|
Bot,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncKeyedLock" Version="6.2.4" />
|
<PackageReference Include="AsyncKeyedLock" Version="7.1.6" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Polly" Version="8.2.0" />
|
<PackageReference Include="Polly" Version="8.5.2" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.5.0" />
|
<PackageReference Include="RazorBlade" Version="0.9.0" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
|
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.3.10" />
|
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Exceptions;
|
||||||
|
|
||||||
|
public class ChannelEmptyException(string message) : DiscordChatExporterException(message);
|
||||||
@@ -27,45 +27,43 @@ public class ChannelExporter(DiscordClient discord)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the channel is empty
|
|
||||||
if (request.Channel.IsEmpty)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the 'after' boundary is valid
|
|
||||||
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the 'before' boundary is valid
|
|
||||||
if (
|
|
||||||
request.Before is not null
|
|
||||||
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build context
|
// Build context
|
||||||
var context = new ExportContext(discord, request);
|
var context = new ExportContext(discord, request);
|
||||||
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
||||||
|
|
||||||
// Export messages
|
// Initialize the exporter before further checks to ensure the file is created even if
|
||||||
|
// an exception is thrown after this point.
|
||||||
await using var messageExporter = new MessageExporter(context);
|
await using var messageExporter = new MessageExporter(context);
|
||||||
|
|
||||||
|
// Check if the channel is empty
|
||||||
|
if (request.Channel.IsEmpty)
|
||||||
|
{
|
||||||
|
throw new ChannelEmptyException(
|
||||||
|
$"Channel '{request.Channel.Name}' "
|
||||||
|
+ $"of guild '{request.Guild.Name}' "
|
||||||
|
+ $"does not contain any messages; an empty file will be created."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the 'before' and 'after' boundaries are valid
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
request.Before is not null
|
||||||
|
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
||||||
|
)
|
||||||
|
|| (
|
||||||
|
request.After is not null
|
||||||
|
&& !request.Channel.MayHaveMessagesAfter(request.After.Value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
throw new ChannelEmptyException(
|
||||||
|
$"Channel '{request.Channel.Name}' "
|
||||||
|
+ $"of guild '{request.Guild.Name}' "
|
||||||
|
+ $"does not contain any messages within the specified period; an empty file will be created."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await foreach (
|
await foreach (
|
||||||
var message in discord.GetMessagesAsync(
|
var message in discord.GetMessagesAsync(
|
||||||
request.Channel.Id,
|
request.Channel.Id,
|
||||||
@@ -98,15 +96,5 @@ public class ChannelExporter(DiscordClient discord)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw if no messages were exported
|
|
||||||
if (messageExporter.MessagesExported <= 0)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
|
||||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
|
||||||
+ $"does not contain any matching messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -16,12 +15,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
|
|
||||||
internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||||
{
|
{
|
||||||
private static readonly AsyncKeyedLocker<string> Locker =
|
private static readonly AsyncKeyedLocker<string> Locker = new();
|
||||||
new(o =>
|
|
||||||
{
|
|
||||||
o.PoolSize = 20;
|
|
||||||
o.PoolInitialFill = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
// File paths of the previously downloaded assets
|
// File paths of the previously downloaded assets
|
||||||
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
|
||||||
@@ -50,39 +44,8 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
|||||||
{
|
{
|
||||||
// Download the file
|
// Download the file
|
||||||
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
|
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
|
||||||
await using (var output = File.Create(filePath))
|
await using var output = File.Create(filePath);
|
||||||
await response.Content.CopyToAsync(output, innerCancellationToken);
|
await response.Content.CopyToAsync(output, innerCancellationToken);
|
||||||
|
|
||||||
// Try to set the file date according to the last-modified header
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lastModified = response
|
|
||||||
.Content.Headers.TryGetValue("Last-Modified")
|
|
||||||
?.Pipe(
|
|
||||||
s =>
|
|
||||||
DateTimeOffset.TryParse(
|
|
||||||
s,
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
DateTimeStyles.None,
|
|
||||||
out var instant
|
|
||||||
)
|
|
||||||
? instant
|
|
||||||
: (DateTimeOffset?)null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastModified is not null)
|
|
||||||
{
|
|
||||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// This can apparently fail for some reason.
|
|
||||||
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
@@ -18,8 +19,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
||||||
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
||||||
|
|
||||||
private readonly ExportAssetDownloader _assetDownloader =
|
private readonly ExportAssetDownloader _assetDownloader = new(
|
||||||
new(request.AssetsDirPath, request.ShouldReuseAssets);
|
request.AssetsDirPath,
|
||||||
|
request.ShouldReuseAssets
|
||||||
|
);
|
||||||
|
|
||||||
public DiscordClient Discord { get; } = discord;
|
public DiscordClient Discord { get; } = discord;
|
||||||
|
|
||||||
@@ -93,11 +96,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
|
|
||||||
public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
|
public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
|
||||||
TryGetMember(id)
|
TryGetMember(id)
|
||||||
?.RoleIds
|
?.RoleIds.Select(TryGetRole)
|
||||||
.Select(TryGetRole)
|
|
||||||
.WhereNotNull()
|
.WhereNotNull()
|
||||||
.OrderByDescending(r => r.Position)
|
.OrderByDescending(r => r.Position)
|
||||||
.ToArray() ?? Array.Empty<Role>();
|
.ToArray() ?? [];
|
||||||
|
|
||||||
public Color? TryGetUserColor(Snowflake id) =>
|
public Color? TryGetUserColor(Snowflake id) =>
|
||||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||||
@@ -116,7 +118,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
||||||
|
|
||||||
// Prefer the relative path so that the export package can be copied around without breaking references.
|
// Prefer the relative path so that the export package can be copied around without breaking references.
|
||||||
// However, if the assets directory lies outside of the export directory, use the absolute path instead.
|
// However, if the assets directory lies outside the export directory, use the absolute path instead.
|
||||||
var shouldUseAbsoluteFilePath =
|
var shouldUseAbsoluteFilePath =
|
||||||
relativeFilePath.StartsWith(
|
relativeFilePath.StartsWith(
|
||||||
".." + Path.DirectorySeparatorChar,
|
".." + Path.DirectorySeparatorChar,
|
||||||
@@ -131,14 +133,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
|
|
||||||
// For HTML, the path needs to be properly formatted
|
// For HTML, the path needs to be properly formatted
|
||||||
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
||||||
{
|
return Url.EncodeFilePath(optimalFilePath);
|
||||||
// Format the path into a valid file URI
|
|
||||||
var href = new Uri(new Uri("file:///"), optimalFilePath).ToString();
|
|
||||||
|
|
||||||
// File schema does not support relative paths, so strip it if that's the case
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1155
|
|
||||||
return shouldUseAbsoluteFilePath ? href : href[8..];
|
|
||||||
}
|
|
||||||
|
|
||||||
return optimalFilePath;
|
return optimalFilePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ public enum ExportFormat
|
|||||||
HtmlDark,
|
HtmlDark,
|
||||||
HtmlLight,
|
HtmlLight,
|
||||||
Csv,
|
Csv,
|
||||||
Json
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ExportFormatExtensions
|
public static class ExportFormatExtensions
|
||||||
@@ -21,7 +21,7 @@ public static class ExportFormatExtensions
|
|||||||
ExportFormat.HtmlLight => "html",
|
ExportFormat.HtmlLight => "html",
|
||||||
ExportFormat.Csv => "csv",
|
ExportFormat.Csv => "csv",
|
||||||
ExportFormat.Json => "json",
|
ExportFormat.Json => "json",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static string GetDisplayName(this ExportFormat format) =>
|
public static string GetDisplayName(this ExportFormat format) =>
|
||||||
@@ -32,6 +32,6 @@ public static class ExportFormatExtensions
|
|||||||
ExportFormat.HtmlLight => "HTML (Light)",
|
ExportFormat.HtmlLight => "HTML (Light)",
|
||||||
ExportFormat.Csv => "CSV",
|
ExportFormat.Csv => "CSV",
|
||||||
ExportFormat.Json => "JSON",
|
ExportFormat.Json => "JSON",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,24 +172,21 @@ public partial class ExportRequest
|
|||||||
"%C" => channel.Name,
|
"%C" => channel.Name,
|
||||||
|
|
||||||
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
|
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
|
||||||
"%P"
|
"%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
|
||||||
=> channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
|
?? "0",
|
||||||
?? "0",
|
|
||||||
|
|
||||||
"%a"
|
"%a" => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||||
=> after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
?? "",
|
||||||
?? "",
|
"%b" => before
|
||||||
"%b"
|
?.ToDate()
|
||||||
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
|
||||||
?? "",
|
"%d" => DateTimeOffset.Now.ToString(
|
||||||
"%d"
|
"yyyy-MM-dd",
|
||||||
=> DateTimeOffset.Now.ToString(
|
CultureInfo.InvariantCulture
|
||||||
"yyyy-MM-dd",
|
),
|
||||||
CultureInfo.InvariantCulture
|
|
||||||
),
|
|
||||||
|
|
||||||
"%%" => "%",
|
"%%" => "%",
|
||||||
_ => m.Value
|
_ => m.Value,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
internal enum BinaryExpressionKind
|
internal enum BinaryExpressionKind
|
||||||
{
|
{
|
||||||
Or,
|
Or,
|
||||||
And
|
And,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ internal class BinaryExpressionMessageFilter(
|
|||||||
{
|
{
|
||||||
BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
|
BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
|
||||||
BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
|
BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
|
||||||
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.")
|
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,11 @@ internal class ContainsMessageFilter(string text) : MessageFilter
|
|||||||
|
|
||||||
public override bool IsMatch(Message message) =>
|
public override bool IsMatch(Message message) =>
|
||||||
IsMatch(message.Content)
|
IsMatch(message.Content)
|
||||||
|| message.Embeds.Any(
|
|| message.Embeds.Any(e =>
|
||||||
e =>
|
IsMatch(e.Title)
|
||||||
IsMatch(e.Title)
|
|| IsMatch(e.Author?.Name)
|
||||||
|| IsMatch(e.Author?.Name)
|
|| IsMatch(e.Description)
|
||||||
|| IsMatch(e.Description)
|
|| IsMatch(e.Footer?.Text)
|
||||||
|| IsMatch(e.Footer?.Text)
|
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|
||||||
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,15 +17,13 @@ internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter
|
|||||||
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
||||||
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||||
MessageContentMatchKind.Pin => message.IsPinned,
|
MessageContentMatchKind.Pin => message.IsPinned,
|
||||||
MessageContentMatchKind.Invite
|
MessageContentMatchKind.Invite => MarkdownParser
|
||||||
=> MarkdownParser
|
.ExtractLinks(message.Content)
|
||||||
.ExtractLinks(message.Content)
|
.Select(l => l.Url)
|
||||||
.Select(l => l.Url)
|
.Select(Invite.TryGetCodeFromUrl)
|
||||||
.Select(Invite.TryGetCodeFromUrl)
|
.Any(c => !string.IsNullOrWhiteSpace(c)),
|
||||||
.Any(c => !string.IsNullOrWhiteSpace(c)),
|
_ => throw new InvalidOperationException(
|
||||||
_
|
$"Unknown message content match kind '{kind}'."
|
||||||
=> throw new InvalidOperationException(
|
),
|
||||||
$"Unknown message content match kind '{kind}'."
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
|
|||||||
internal class MentionsMessageFilter(string value) : MessageFilter
|
internal class MentionsMessageFilter(string value) : MessageFilter
|
||||||
{
|
{
|
||||||
public override bool IsMatch(Message message) =>
|
public override bool IsMatch(Message message) =>
|
||||||
message.MentionedUsers.Any(
|
message.MentionedUsers.Any(user =>
|
||||||
user =>
|
string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ internal enum MessageContentMatchKind
|
|||||||
Image,
|
Image,
|
||||||
Sound,
|
Sound,
|
||||||
Pin,
|
Pin,
|
||||||
Invite
|
Invite,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ internal static class FilterGrammar
|
|||||||
.OneOf(QuotedString, UnquotedString)
|
.OneOf(QuotedString, UnquotedString)
|
||||||
.Named("text string");
|
.Named("text string");
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(
|
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(v =>
|
||||||
v => (MessageFilter)new ContainsMessageFilter(v)
|
(MessageFilter)new ContainsMessageFilter(v)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:")
|
private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:")
|
||||||
@@ -125,7 +125,7 @@ internal static class FilterGrammar
|
|||||||
op switch
|
op switch
|
||||||
{
|
{
|
||||||
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
||||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
|
|||||||
internal class ReactionMessageFilter(string value) : MessageFilter
|
internal class ReactionMessageFilter(string value) : MessageFilter
|
||||||
{
|
{
|
||||||
public override bool IsMatch(Message message) =>
|
public override bool IsMatch(Message message) =>
|
||||||
message.Reactions.Any(
|
message.Reactions.Any(r =>
|
||||||
r =>
|
string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||||
string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using System.Text;
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
|
||||||
using DiscordChatExporter.Core.Markdown;
|
using DiscordChatExporter.Core.Markdown;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
@@ -34,58 +33,51 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
{
|
{
|
||||||
var (openingTag, closingTag) = formatting.Kind switch
|
var (openingTag, closingTag) = formatting.Kind switch
|
||||||
{
|
{
|
||||||
FormattingKind.Bold
|
FormattingKind.Bold => (
|
||||||
=> (
|
// lang=html
|
||||||
// lang=html
|
"<strong>",
|
||||||
"<strong>",
|
// lang=html
|
||||||
// lang=html
|
"</strong>"
|
||||||
"</strong>"
|
),
|
||||||
),
|
|
||||||
|
|
||||||
FormattingKind.Italic
|
FormattingKind.Italic => (
|
||||||
=> (
|
// lang=html
|
||||||
// lang=html
|
"<em>",
|
||||||
"<em>",
|
// lang=html
|
||||||
// lang=html
|
"</em>"
|
||||||
"</em>"
|
),
|
||||||
),
|
|
||||||
|
|
||||||
FormattingKind.Underline
|
FormattingKind.Underline => (
|
||||||
=> (
|
// lang=html
|
||||||
// lang=html
|
"<u>",
|
||||||
"<u>",
|
// lang=html
|
||||||
// lang=html
|
"</u>"
|
||||||
"</u>"
|
),
|
||||||
),
|
|
||||||
|
|
||||||
FormattingKind.Strikethrough
|
FormattingKind.Strikethrough => (
|
||||||
=> (
|
// lang=html
|
||||||
// lang=html
|
"<s>",
|
||||||
"<s>",
|
// lang=html
|
||||||
// lang=html
|
"</s>"
|
||||||
"</s>"
|
),
|
||||||
),
|
|
||||||
|
|
||||||
FormattingKind.Spoiler
|
FormattingKind.Spoiler => (
|
||||||
=> (
|
// lang=html
|
||||||
// lang=html
|
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
||||||
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
// lang=html
|
||||||
// lang=html
|
"""</span>"""
|
||||||
"""</span>"""
|
),
|
||||||
),
|
|
||||||
|
|
||||||
FormattingKind.Quote
|
FormattingKind.Quote => (
|
||||||
=> (
|
// lang=html
|
||||||
// lang=html
|
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
|
||||||
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
|
// lang=html
|
||||||
// lang=html
|
"""</div></div>"""
|
||||||
"""</div></div>"""
|
),
|
||||||
),
|
|
||||||
|
|
||||||
_
|
_ => throw new InvalidOperationException(
|
||||||
=> throw new InvalidOperationException(
|
$"Unknown formatting kind '{formatting.Kind}'."
|
||||||
$"Unknown formatting kind '{formatting.Kind}'."
|
),
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
buffer.Append(openingTag);
|
buffer.Append(openingTag);
|
||||||
@@ -155,7 +147,9 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
buffer.Append(
|
buffer.Append(
|
||||||
// lang=html
|
// lang=html
|
||||||
$"""
|
$"""
|
||||||
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code>
|
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(
|
||||||
|
inlineCodeBlock.Code
|
||||||
|
)}</code>
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -174,7 +168,9 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
buffer.Append(
|
buffer.Append(
|
||||||
// lang=html
|
// lang=html
|
||||||
$"""
|
$"""
|
||||||
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code>
|
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(
|
||||||
|
multiLineCodeBlock.Code
|
||||||
|
)}</code>
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,7 +209,6 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
|
||||||
var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
|
var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
|
||||||
|
|
||||||
buffer.Append(
|
buffer.Append(
|
||||||
@@ -224,7 +219,7 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
class="chatlog__emoji {jumboClass}"
|
class="chatlog__emoji {jumboClass}"
|
||||||
alt="{emoji.Name}"
|
alt="{emoji.Name}"
|
||||||
title="{emoji.Code}"
|
title="{emoji.Code}"
|
||||||
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
|
src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,7 +262,9 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
buffer.Append(
|
buffer.Append(
|
||||||
// lang=html
|
// lang=html
|
||||||
$"""
|
$"""
|
||||||
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span>
|
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(
|
||||||
|
displayName
|
||||||
|
)}</span>
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,8 +289,12 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
|
|
||||||
var style = color is not null
|
var style = color is not null
|
||||||
? $"""
|
? $"""
|
||||||
color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);
|
color: rgb({color.Value.R}, {color.Value.G}, {color
|
||||||
"""
|
.Value
|
||||||
|
.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color
|
||||||
|
.Value
|
||||||
|
.B}, 0.1);
|
||||||
|
"""
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
buffer.Append(
|
buffer.Append(
|
||||||
@@ -321,7 +322,9 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
buffer.Append(
|
buffer.Append(
|
||||||
// lang=html
|
// lang=html
|
||||||
$"""
|
$"""
|
||||||
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span>
|
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(
|
||||||
|
formattedLong
|
||||||
|
)}">{HtmlEncode(formatted)}</span>
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -344,10 +347,8 @@ internal partial class HtmlMarkdownVisitor
|
|||||||
|
|
||||||
var isJumbo =
|
var isJumbo =
|
||||||
isJumboAllowed
|
isJumboAllowed
|
||||||
&& nodes.All(
|
&& nodes.All(n =>
|
||||||
n =>
|
n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
|
||||||
n is EmojiNode
|
|
||||||
|| n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
|||||||
private readonly TextWriter _writer = new StreamWriter(stream);
|
private readonly TextWriter _writer = new StreamWriter(stream);
|
||||||
|
|
||||||
private readonly HtmlMinifier _minifier = new();
|
private readonly HtmlMinifier _minifier = new();
|
||||||
private readonly List<Message> _messageGroup = new();
|
private readonly List<Message> _messageGroup = [];
|
||||||
|
|
||||||
private bool CanJoinGroup(Message message)
|
private bool CanJoinGroup(Message message)
|
||||||
{
|
{
|
||||||
@@ -90,7 +90,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
|||||||
await new MessageGroupTemplate
|
await new MessageGroupTemplate
|
||||||
{
|
{
|
||||||
Context = Context,
|
Context = Context,
|
||||||
Messages = messages
|
Messages = messages,
|
||||||
}.RenderAsync(cancellationToken)
|
}.RenderAsync(cancellationToken)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -131,7 +131,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
|||||||
await new PostambleTemplate
|
await new PostambleTemplate
|
||||||
{
|
{
|
||||||
Context = Context,
|
Context = Context,
|
||||||
MessagesWritten = MessagesWritten
|
MessagesWritten = MessagesWritten,
|
||||||
}.RenderAsync(cancellationToken)
|
}.RenderAsync(cancellationToken)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Writing;
|
using JsonExtensions.Writing;
|
||||||
|
|
||||||
@@ -15,19 +17,18 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
internal class JsonMessageWriter(Stream stream, ExportContext context)
|
internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
: MessageWriter(stream, context)
|
: MessageWriter(stream, context)
|
||||||
{
|
{
|
||||||
private readonly Utf8JsonWriter _writer =
|
private readonly Utf8JsonWriter _writer = new(
|
||||||
new(
|
stream,
|
||||||
stream,
|
new JsonWriterOptions
|
||||||
new JsonWriterOptions
|
{
|
||||||
{
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
Indented = true,
|
||||||
Indented = true,
|
// Validation errors may mask actual failures
|
||||||
// Validation errors may mask actual failures
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
SkipValidation = true,
|
||||||
SkipValidation = true
|
}
|
||||||
}
|
);
|
||||||
);
|
|
||||||
|
|
||||||
private async ValueTask<string> FormatMarkdownAsync(
|
private async ValueTask<string> FormatMarkdownAsync(
|
||||||
string markdown,
|
string markdown,
|
||||||
@@ -37,22 +38,31 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
|
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
|
||||||
: markdown;
|
: markdown;
|
||||||
|
|
||||||
private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default)
|
private async ValueTask WriteUserAsync(
|
||||||
|
User user,
|
||||||
|
bool includeRoles = true,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("id", user.Id.ToString());
|
_writer.WriteString("id", user.Id.ToString());
|
||||||
_writer.WriteString("name", user.Name);
|
_writer.WriteString("name", user.Name);
|
||||||
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
|
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
|
||||||
|
|
||||||
_writer.WriteString(
|
_writer.WriteString(
|
||||||
"nickname",
|
"nickname",
|
||||||
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
|
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
|
||||||
);
|
);
|
||||||
|
|
||||||
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
|
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
|
||||||
_writer.WriteBoolean("isBot", user.IsBot);
|
_writer.WriteBoolean("isBot", user.IsBot);
|
||||||
|
|
||||||
_writer.WritePropertyName("roles");
|
if (includeRoles)
|
||||||
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
{
|
||||||
|
_writer.WritePropertyName("roles");
|
||||||
|
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
_writer.WriteString(
|
_writer.WriteString(
|
||||||
"avatarUrl",
|
"avatarUrl",
|
||||||
@@ -66,6 +76,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteEmojiAsync(
|
||||||
|
Emoji emoji,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", emoji.Id.ToString());
|
||||||
|
_writer.WriteString("name", emoji.Name);
|
||||||
|
_writer.WriteString("code", emoji.Code);
|
||||||
|
_writer.WriteBoolean("isAnimated", emoji.IsAnimated);
|
||||||
|
_writer.WriteString(
|
||||||
|
"imageUrl",
|
||||||
|
await Context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteRolesAsync(
|
private async ValueTask WriteRolesAsync(
|
||||||
IReadOnlyList<Role> roles,
|
IReadOnlyList<Role> roles,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -273,6 +303,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Inline emoji
|
||||||
|
_writer.WriteStartArray("inlineEmojis");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
foreach (
|
||||||
|
var emoji in MarkdownParser
|
||||||
|
.ExtractEmojis(embed.Description)
|
||||||
|
.DistinctBy(e => e.Name, StringComparer.Ordinal)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await WriteEmojiAsync(
|
||||||
|
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -373,7 +423,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
// Author
|
// Author
|
||||||
_writer.WritePropertyName("author");
|
_writer.WritePropertyName("author");
|
||||||
await WriteUserAsync(message.Author, cancellationToken);
|
await WriteUserAsync(message.Author, true, cancellationToken);
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
_writer.WriteStartArray("attachments");
|
_writer.WriteStartArray("attachments");
|
||||||
@@ -431,20 +481,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
// Emoji
|
// Emoji
|
||||||
_writer.WriteStartObject("emoji");
|
_writer.WritePropertyName("emoji");
|
||||||
_writer.WriteString("id", reaction.Emoji.Id.ToString());
|
await WriteEmojiAsync(reaction.Emoji, cancellationToken);
|
||||||
_writer.WriteString("name", reaction.Emoji.Name);
|
|
||||||
_writer.WriteString("code", reaction.Emoji.Code);
|
|
||||||
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
|
||||||
_writer.WriteString(
|
|
||||||
"imageUrl",
|
|
||||||
await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)
|
|
||||||
);
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
|
|
||||||
_writer.WriteNumber("count", reaction.Count);
|
_writer.WriteNumber("count", reaction.Count);
|
||||||
|
|
||||||
|
// Reaction authors
|
||||||
_writer.WriteStartArray("users");
|
_writer.WriteStartArray("users");
|
||||||
|
|
||||||
await foreach (
|
await foreach (
|
||||||
var user in Context.Discord.GetMessageReactionsAsync(
|
var user in Context.Discord.GetMessageReactionsAsync(
|
||||||
Context.Request.Channel.Id,
|
Context.Request.Channel.Id,
|
||||||
@@ -454,28 +498,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
await WriteUserAsync(user, false, cancellationToken);
|
||||||
|
|
||||||
// Write limited user information without color and roles,
|
|
||||||
// so we can avoid fetching guild member information for each user.
|
|
||||||
_writer.WriteString("id", user.Id.ToString());
|
|
||||||
_writer.WriteString("name", user.Name);
|
|
||||||
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
|
|
||||||
_writer.WriteString(
|
|
||||||
"nickname",
|
|
||||||
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
|
|
||||||
);
|
|
||||||
_writer.WriteBoolean("isBot", user.IsBot);
|
|
||||||
|
|
||||||
_writer.WriteString(
|
|
||||||
"avatarUrl",
|
|
||||||
await Context.ResolveAssetUrlAsync(
|
|
||||||
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
|
|
||||||
cancellationToken
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
@@ -487,9 +510,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
// Mentions
|
// Mentions
|
||||||
_writer.WriteStartArray("mentions");
|
_writer.WriteStartArray("mentions");
|
||||||
|
|
||||||
foreach (var user in message.MentionedUsers)
|
foreach (var user in message.MentionedUsers)
|
||||||
await WriteUserAsync(user, cancellationToken);
|
await WriteUserAsync(user, true, cancellationToken);
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -512,11 +534,28 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
_writer.WriteString("name", message.Interaction.Name);
|
_writer.WriteString("name", message.Interaction.Name);
|
||||||
|
|
||||||
_writer.WritePropertyName("user");
|
_writer.WritePropertyName("user");
|
||||||
await WriteUserAsync(message.Interaction.User, cancellationToken);
|
await WriteUserAsync(message.Interaction.User, true, cancellationToken);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline emoji
|
||||||
|
_writer.WriteStartArray("inlineEmojis");
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var emoji in MarkdownParser
|
||||||
|
.ExtractEmojis(message.Content)
|
||||||
|
.DistinctBy(e => e.Name, StringComparer.Ordinal)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await WriteEmojiAsync(
|
||||||
|
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,24 +13,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
|
|
||||||
public long MessagesExported { get; private set; }
|
public long MessagesExported { get; private set; }
|
||||||
|
|
||||||
private async ValueTask ResetWriterAsync(CancellationToken cancellationToken = default)
|
private async ValueTask<MessageWriter> InitializeWriterAsync(
|
||||||
{
|
|
||||||
if (_writer is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _writer.WritePostambleAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
// Writer must be disposed, even if it fails to write the postamble
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await _writer.DisposeAsync();
|
|
||||||
_writer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<MessageWriter> GetWriterAsync(
|
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -43,7 +26,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await ResetWriterAsync(cancellationToken);
|
await UninitializeWriterAsync(cancellationToken);
|
||||||
_partitionIndex++;
|
_partitionIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,17 +43,41 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
return _writer = writer;
|
return _writer = writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask UninitializeWriterAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_writer is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _writer.WritePostambleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
// Writer must be disposed, even if it fails to write the postamble
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _writer.DisposeAsync();
|
||||||
|
_writer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask ExportMessageAsync(
|
public async ValueTask ExportMessageAsync(
|
||||||
Message message,
|
Message message,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var writer = await GetWriterAsync(cancellationToken);
|
var writer = await InitializeWriterAsync(cancellationToken);
|
||||||
await writer.WriteMessageAsync(message, cancellationToken);
|
await writer.WriteMessageAsync(message, cancellationToken);
|
||||||
MessagesExported++;
|
MessagesExported++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
// If not messages were written, force the creation of an empty file
|
||||||
|
if (MessagesExported <= 0)
|
||||||
|
_ = await InitializeWriterAsync();
|
||||||
|
|
||||||
|
await UninitializeWriterAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MessageExporter
|
internal partial class MessageExporter
|
||||||
@@ -100,13 +107,15 @@ internal partial class MessageExporter
|
|||||||
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
|
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
|
||||||
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
|
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
|
||||||
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
|
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
|
||||||
ExportFormat.HtmlLight
|
ExportFormat.HtmlLight => new HtmlMessageWriter(
|
||||||
=> new HtmlMessageWriter(File.Create(filePath), context, "Light"),
|
File.Create(filePath),
|
||||||
|
context,
|
||||||
|
"Light"
|
||||||
|
),
|
||||||
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
|
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
|
||||||
_
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
=> throw new ArgumentOutOfRangeException(
|
nameof(format),
|
||||||
nameof(format),
|
$"Unknown export format '{format}'."
|
||||||
$"Unknown export format '{format}'."
|
),
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ public partial class PartitionLimit
|
|||||||
"M" => 1_000_000,
|
"M" => 1_000_000,
|
||||||
"K" => 1_000,
|
"K" => 1_000,
|
||||||
"" => 1,
|
"" => 1,
|
||||||
_ => -1
|
_ => -1,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (magnitude < 0)
|
if (magnitude < 0)
|
||||||
|
|||||||
@@ -10,20 +10,18 @@ internal static class PlainTextMessageExtensions
|
|||||||
public static string GetFallbackContent(this Message message) =>
|
public static string GetFallbackContent(this Message message) =>
|
||||||
message.Kind switch
|
message.Kind switch
|
||||||
{
|
{
|
||||||
MessageKind.RecipientAdd
|
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||||
=> message.MentionedUsers.Any()
|
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
: "Added a recipient.",
|
||||||
: "Added a recipient.",
|
|
||||||
|
|
||||||
MessageKind.RecipientRemove
|
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||||
=> message.MentionedUsers.Any()
|
? message.Author.Id == message.MentionedUsers.First().Id
|
||||||
? message.Author.Id == message.MentionedUsers.First().Id
|
? "Left the group."
|
||||||
? "Left the group."
|
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
: "Removed a recipient.",
|
||||||
: "Removed a recipient.",
|
|
||||||
|
|
||||||
MessageKind.Call
|
MessageKind.Call =>
|
||||||
=> $"Started a call that lasted {
|
$"Started a call that lasted {
|
||||||
message
|
message
|
||||||
.CallEndedTimestamp?
|
.CallEndedTimestamp?
|
||||||
.Pipe(t => t - message.Timestamp)
|
.Pipe(t => t - message.Timestamp)
|
||||||
@@ -31,16 +29,15 @@ internal static class PlainTextMessageExtensions
|
|||||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||||
} minutes.",
|
} minutes.",
|
||||||
|
|
||||||
MessageKind.ChannelNameChange
|
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||||
=> !string.IsNullOrWhiteSpace(message.Content)
|
? $"Changed the channel name: {message.Content}"
|
||||||
? $"Changed the channel name: {message.Content}"
|
: "Changed the channel name.",
|
||||||
: "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.ThreadCreated => "Started a thread.",
|
||||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||||
|
|
||||||
_ => message.Content
|
_ => message.Content,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
@@ -172,18 +173,21 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
await _writer.WriteLineAsync("{Reactions}");
|
await _writer.WriteLineAsync("{Reactions}");
|
||||||
|
|
||||||
foreach (var reaction in reactions)
|
foreach (var (reaction, i) in reactions.WithIndex())
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (i > 0)
|
||||||
|
{
|
||||||
|
await _writer.WriteAsync(' ');
|
||||||
|
}
|
||||||
|
|
||||||
await _writer.WriteAsync(reaction.Emoji.Name);
|
await _writer.WriteAsync(reaction.Emoji.Name);
|
||||||
|
|
||||||
if (reaction.Count > 1)
|
if (reaction.Count > 1)
|
||||||
{
|
{
|
||||||
await _writer.WriteAsync($" ({reaction.Count})");
|
await _writer.WriteAsync($" ({reaction.Count})");
|
||||||
}
|
}
|
||||||
|
|
||||||
await _writer.WriteAsync(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown;
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
@@ -11,11 +11,17 @@ internal record EmojiNode(
|
|||||||
bool IsAnimated
|
bool IsAnimated
|
||||||
) : MarkdownNode
|
) : MarkdownNode
|
||||||
{
|
{
|
||||||
public bool IsCustomEmoji => Id is not null;
|
// This coupling is unsound from the domain-design perspective, but it helps us reuse
|
||||||
|
// some code for now. We can refactor this later, if the coupling becomes a problem.
|
||||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
private readonly Emoji _emoji = new(Id, Name, IsAnimated);
|
||||||
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
|
||||||
|
|
||||||
public EmojiNode(string name)
|
public EmojiNode(string name)
|
||||||
: this(null, name, false) { }
|
: this(null, name, false) { }
|
||||||
|
|
||||||
|
public bool IsCustomEmoji => _emoji.IsCustomEmoji;
|
||||||
|
|
||||||
|
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||||
|
public string Code => _emoji.Code;
|
||||||
|
|
||||||
|
public string ImageUrl => _emoji.ImageUrl;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ internal enum FormattingKind
|
|||||||
Underline,
|
Underline,
|
||||||
Strikethrough,
|
Strikethrough,
|
||||||
Spoiler,
|
Spoiler,
|
||||||
Quote
|
Quote,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ internal enum MentionKind
|
|||||||
Here,
|
Here,
|
||||||
User,
|
User,
|
||||||
Channel,
|
Channel,
|
||||||
Role
|
Role,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
|
|
||||||
internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatcher<T>
|
internal class AggregateMatcher<TContext, TValue>(
|
||||||
|
params IReadOnlyList<IMatcher<TContext, TValue>> matchers
|
||||||
|
) : IMatcher<TContext, TValue>
|
||||||
{
|
{
|
||||||
public AggregateMatcher(params IMatcher<T>[] matchers)
|
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
|
||||||
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
|
|
||||||
|
|
||||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
|
||||||
{
|
{
|
||||||
ParsedMatch<T>? earliestMatch = null;
|
ParsedMatch<TValue>? earliestMatch = null;
|
||||||
|
|
||||||
// Try to match the input with each matcher and get the match with the lowest start index
|
// Try to match the input with each matcher and get the match with the lowest start index
|
||||||
foreach (var matcher in matchers)
|
foreach (var matcher in matchers)
|
||||||
{
|
{
|
||||||
// Try to match
|
// Try to match
|
||||||
var match = matcher.TryMatch(segment);
|
var match = matcher.TryMatch(context, segment);
|
||||||
|
|
||||||
// If there's no match - continue
|
// If there's no match - continue
|
||||||
if (match is null)
|
if (match is null)
|
||||||
@@ -26,7 +25,9 @@ internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatch
|
|||||||
earliestMatch is null
|
earliestMatch is null
|
||||||
|| match.Segment.StartIndex < earliestMatch.Segment.StartIndex
|
|| match.Segment.StartIndex < earliestMatch.Segment.StartIndex
|
||||||
)
|
)
|
||||||
|
{
|
||||||
earliestMatch = match;
|
earliestMatch = match;
|
||||||
|
}
|
||||||
|
|
||||||
// If the earliest match starts at the very beginning - break,
|
// If the earliest match starts at the very beginning - break,
|
||||||
// because it's impossible to find a match earlier than that
|
// because it's impossible to find a match earlier than that
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ using System.Collections.Generic;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
|
|
||||||
internal interface IMatcher<T>
|
internal interface IMatcher<in TContext, TValue>
|
||||||
{
|
{
|
||||||
ParsedMatch<T>? TryMatch(StringSegment segment);
|
ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class MatcherExtensions
|
internal static class MatcherExtensions
|
||||||
{
|
{
|
||||||
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
|
public static IEnumerable<ParsedMatch<TValue>> MatchAll<TContext, TValue>(
|
||||||
this IMatcher<T> matcher,
|
this IMatcher<TContext, TValue> matcher,
|
||||||
|
TContext context,
|
||||||
StringSegment segment,
|
StringSegment segment,
|
||||||
Func<StringSegment, T> transformFallback
|
Func<TContext, StringSegment, TValue> transformFallback
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Loop through segments divided by individual matches
|
// Loop through segments divided by individual matches
|
||||||
@@ -22,6 +23,7 @@ internal static class MatcherExtensions
|
|||||||
{
|
{
|
||||||
// Find a match within this segment
|
// Find a match within this segment
|
||||||
var match = matcher.TryMatch(
|
var match = matcher.TryMatch(
|
||||||
|
context,
|
||||||
segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
|
segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -36,9 +38,9 @@ internal static class MatcherExtensions
|
|||||||
match.Segment.StartIndex - currentIndex
|
match.Segment.StartIndex - currentIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
yield return new ParsedMatch<T>(
|
yield return new ParsedMatch<TValue>(
|
||||||
fallbackSegment,
|
fallbackSegment,
|
||||||
transformFallback(fallbackSegment)
|
transformFallback(context, fallbackSegment)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +55,10 @@ internal static class MatcherExtensions
|
|||||||
{
|
{
|
||||||
var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
|
var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
|
||||||
|
|
||||||
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
|
yield return new ParsedMatch<TValue>(
|
||||||
|
fallbackSegment,
|
||||||
|
transformFallback(context, fallbackSegment)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
|
|
||||||
|
internal readonly record struct MarkdownContext(int Depth = 0);
|
||||||
@@ -23,15 +23,15 @@ internal static partial class MarkdownParser
|
|||||||
|
|
||||||
/* Formatting */
|
/* Formatting */
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> BoldFormattingNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// There must be exactly two closing asterisks.
|
// There must be exactly two closing asterisks.
|
||||||
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
|
(c, s, m) => new FormattingNode(FormattingKind.Bold, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicFormattingNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// There must be exactly one closing asterisk.
|
// There must be exactly one closing asterisk.
|
||||||
// Opening asterisk must not be followed by whitespace.
|
// Opening asterisk must not be followed by whitespace.
|
||||||
// Closing asterisk must not be preceded by whitespace.
|
// Closing asterisk must not be preceded by whitespace.
|
||||||
@@ -39,156 +39,174 @@ internal static partial class MarkdownParser
|
|||||||
@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
|
@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
|
||||||
DefaultRegexOptions | RegexOptions.Singleline
|
DefaultRegexOptions | RegexOptions.Singleline
|
||||||
),
|
),
|
||||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
(c, s, m) =>
|
||||||
|
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher =
|
private static readonly IMatcher<
|
||||||
new RegexMatcher<MarkdownNode>(
|
MarkdownContext,
|
||||||
// There must be exactly three closing asterisks.
|
MarkdownNode
|
||||||
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
(s, m) =>
|
// There must be exactly three closing asterisks.
|
||||||
new FormattingNode(
|
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
FormattingKind.Italic,
|
(c, s, m) =>
|
||||||
Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
|
new FormattingNode(
|
||||||
)
|
FormattingKind.Italic,
|
||||||
);
|
Parse(c, s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
// Closing underscore must not be followed by a word character.
|
|
||||||
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
|
||||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
// There must be exactly two closing underscores.
|
|
||||||
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
|
||||||
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
// There must be exactly three closing underscores.
|
|
||||||
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
|
||||||
(s, m) =>
|
|
||||||
new FormattingNode(
|
|
||||||
FormattingKind.Italic,
|
|
||||||
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
|
||||||
(s, m) =>
|
|
||||||
new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
|
|
||||||
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
// Include the linebreak in the content so that the lines are preserved in quotes.
|
|
||||||
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
|
|
||||||
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
// Include the linebreaks in the content, so that the lines are preserved in quotes.
|
|
||||||
// Empty content is allowed within quotes.
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
|
|
||||||
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
|
|
||||||
(s, m) =>
|
|
||||||
new FormattingNode(
|
|
||||||
FormattingKind.Quote,
|
|
||||||
m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
|
||||||
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher =
|
|
||||||
new RegexMatcher<MarkdownNode>(
|
|
||||||
// Consume the linebreak so that it's not attached to following nodes.
|
|
||||||
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
|
|
||||||
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>(
|
|
||||||
// Can be preceded by whitespace, which specifies the list's nesting level.
|
|
||||||
// Following lines that start with (level+1) whitespace are considered part of the list item.
|
|
||||||
// Consume the linebreak so that it's not attached to following nodes.
|
|
||||||
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
|
|
||||||
(s, m) =>
|
|
||||||
new ListNode(
|
|
||||||
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Code blocks */
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicAltFormattingNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher =
|
// Closing underscore must not be followed by a word character.
|
||||||
new RegexMatcher<MarkdownNode>(
|
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
// One or two backticks are allowed, but they must match on both sides.
|
(c, s, m) =>
|
||||||
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
|
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> UnderlineFormattingNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
// There must be exactly two closing underscores.
|
||||||
|
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
(c, s, m) =>
|
||||||
|
new FormattingNode(FormattingKind.Underline, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<
|
||||||
|
MarkdownContext,
|
||||||
|
MarkdownNode
|
||||||
|
> ItalicUnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
// There must be exactly three closing underscores.
|
||||||
|
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
(c, s, m) =>
|
||||||
|
new FormattingNode(
|
||||||
|
FormattingKind.Italic,
|
||||||
|
Parse(c, s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<
|
||||||
|
MarkdownContext,
|
||||||
|
MarkdownNode
|
||||||
|
> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
(c, s, m) =>
|
||||||
|
new FormattingNode(FormattingKind.Strikethrough, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> SpoilerFormattingNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
(c, s, m) =>
|
||||||
|
new FormattingNode(FormattingKind.Spoiler, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> SingleLineQuoteNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
// Include the linebreak in the content so that the lines are preserved in quotes.
|
||||||
|
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
|
||||||
|
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<
|
||||||
|
MarkdownContext,
|
||||||
|
MarkdownNode
|
||||||
|
> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
// Include the linebreaks in the content, so that the lines are preserved in quotes.
|
||||||
|
// Empty content is allowed within quotes.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
|
||||||
|
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
|
||||||
|
(c, s, m) =>
|
||||||
|
new FormattingNode(
|
||||||
|
FormattingKind.Quote,
|
||||||
|
m.Groups[1].Captures.SelectMany(r => Parse(c, s.Relocate(r))).ToArray()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineQuoteNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> HeadingNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
// Consume the linebreak so that it's not attached to following nodes.
|
||||||
|
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
|
||||||
|
(c, s, m) => new HeadingNode(m.Groups[1].Length, Parse(c, s.Relocate(m.Groups[2])))
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> ListNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
// Can be preceded by whitespace, which specifies the list's nesting level.
|
||||||
|
// Following lines that start with (level+1) whitespace are considered part of the list item.
|
||||||
|
// Consume the linebreak so that it's not attached to following nodes.
|
||||||
|
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
|
||||||
|
(c, s, m) =>
|
||||||
|
new ListNode(
|
||||||
|
m.Groups[2]
|
||||||
|
.Captures.Select(x => new ListItemNode(Parse(c, s.Relocate(x))))
|
||||||
|
.ToArray()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
|
||||||
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> InlineCodeBlockNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
// One or two backticks are allowed, but they must match on both sides.
|
||||||
|
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
(_, _, m) => new InlineCodeBlockNode(m.Groups[2].Value)
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineCodeBlockNodeMatcher =
|
||||||
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
|
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
|
||||||
// Blank lines at the beginning and at the end of content are trimmed.
|
// Blank lines at the beginning and at the end of content are trimmed.
|
||||||
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(_, m) =>
|
(_, _, m) =>
|
||||||
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Mentions */
|
/* Mentions */
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> EveryoneMentionNodeMatcher =
|
||||||
new StringMatcher<MarkdownNode>(
|
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||||
"@everyone",
|
"@everyone",
|
||||||
_ => new MentionNode(null, MentionKind.Everyone)
|
(_, _) => new MentionNode(null, MentionKind.Everyone)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> HereMentionNodeMatcher =
|
||||||
new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here));
|
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||||
|
"@here",
|
||||||
|
(_, _) => new MentionNode(null, MentionKind.Here)
|
||||||
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> UserMentionNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture <@123456> or <@!123456>
|
// Capture <@123456> or <@!123456>
|
||||||
new Regex(@"<@!?(\d+)>", DefaultRegexOptions),
|
new Regex(@"<@!?(\d+)>", DefaultRegexOptions),
|
||||||
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
|
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> ChannelMentionNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture <#123456>
|
// Capture <#123456>
|
||||||
new Regex(@"<\#!?(\d+)>", DefaultRegexOptions),
|
new Regex(@"<\#!?(\d+)>", DefaultRegexOptions),
|
||||||
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
|
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> RoleMentionNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture <@&123456>
|
// Capture <@&123456>
|
||||||
new Regex(@"<@&(\d+)>", DefaultRegexOptions),
|
new Regex(@"<@&(\d+)>", DefaultRegexOptions),
|
||||||
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
|
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Emoji */
|
/* Emoji */
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
new Regex(
|
new Regex(
|
||||||
@"("
|
@"("
|
||||||
+
|
+
|
||||||
@@ -239,21 +257,21 @@ internal static partial class MarkdownParser
|
|||||||
+ @")",
|
+ @")",
|
||||||
DefaultRegexOptions
|
DefaultRegexOptions
|
||||||
),
|
),
|
||||||
(_, m) => new EmojiNode(m.Groups[1].Value)
|
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> CodedStandardEmojiNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture :thinking:
|
// Capture :thinking:
|
||||||
new Regex(@":([\w_]+):", DefaultRegexOptions),
|
new Regex(@":([\w_]+):", DefaultRegexOptions),
|
||||||
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
|
(_, _, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> CustomEmojiNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture <:lul:123456> or <a:lul:123456>
|
// Capture <:lul:123456> or <a:lul:123456>
|
||||||
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
|
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
|
||||||
(_, m) =>
|
(_, _, m) =>
|
||||||
new EmojiNode(
|
new EmojiNode(
|
||||||
Snowflake.TryParse(m.Groups[3].Value),
|
Snowflake.TryParse(m.Groups[3].Value),
|
||||||
m.Groups[2].Value,
|
m.Groups[2].Value,
|
||||||
@@ -263,70 +281,72 @@ internal static partial class MarkdownParser
|
|||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> AutoLinkNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Any non-whitespace character after http:// or https://
|
// Any non-whitespace character after http:// or https://
|
||||||
// until the last punctuation character or whitespace.
|
// until the last punctuation character or whitespace.
|
||||||
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
|
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
|
||||||
(_, m) => new LinkNode(m.Groups[1].Value)
|
(_, _, m) => new LinkNode(m.Groups[1].Value)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> HiddenLinkNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Same as auto link but also surrounded by angular brackets
|
// Same as auto link but also surrounded by angular brackets
|
||||||
new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions),
|
new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions),
|
||||||
(_, m) => new LinkNode(m.Groups[1].Value)
|
(_, _, m) => new LinkNode(m.Groups[1].Value)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture [title](link)
|
// Capture [title](link)
|
||||||
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
|
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
|
||||||
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
|
(c, s, m) => new LinkNode(m.Groups[2].Value, Parse(c, s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> ShrugTextNodeMatcher =
|
||||||
new StringMatcher<MarkdownNode>(
|
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture the shrug kaomoji.
|
// Capture the shrug kaomoji.
|
||||||
// This escapes it from matching for formatting.
|
// This escapes it from matching for formatting.
|
||||||
@"¯\_(ツ)_/¯",
|
@"¯\_(ツ)_/¯",
|
||||||
s => new TextNode(s.ToString())
|
(s, _) => new TextNode(s.ToString())
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture some specific emoji that don't get rendered.
|
// Capture some specific emoji that don't get rendered.
|
||||||
// This escapes them from matching for emoji.
|
// This escapes them from matching for emoji.
|
||||||
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
|
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
|
||||||
(_, m) => new TextNode(m.Groups[1].Value)
|
(_, _, m) => new TextNode(m.Groups[1].Value)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> EscapedSymbolTextNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash.
|
// Capture any "symbol/other" character or surrogate pair preceded by a backslash.
|
||||||
// This escapes them from matching for emoji.
|
// This escapes them from matching for emoji.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230
|
||||||
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
|
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
|
||||||
(_, m) => new TextNode(m.Groups[1].Value)
|
(_, _, m) => new TextNode(m.Groups[1].Value)
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher =
|
private static readonly IMatcher<
|
||||||
new RegexMatcher<MarkdownNode>(
|
MarkdownContext,
|
||||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
MarkdownNode
|
||||||
// This escapes them from matching for formatting or other tokens.
|
> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
|
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
||||||
(_, m) => new TextNode(m.Groups[1].Value)
|
// This escapes them from matching for formatting or other tokens.
|
||||||
);
|
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
|
||||||
|
(_, _, m) => new TextNode(m.Groups[1].Value)
|
||||||
|
);
|
||||||
|
|
||||||
/* Misc */
|
/* Misc */
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> TimestampNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Capture <t:12345678> or <t:12345678:R>
|
// Capture <t:12345678> or <t:12345678:R>
|
||||||
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
|
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
|
||||||
(_, m) =>
|
(_, _, m) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -357,10 +377,9 @@ internal static partial class MarkdownParser
|
|||||||
null => null,
|
null => null,
|
||||||
// Unknown format: throw an exception to consider this timestamp invalid
|
// Unknown format: throw an exception to consider this timestamp invalid
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1156
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1156
|
||||||
var f
|
var f => throw new InvalidOperationException(
|
||||||
=> throw new InvalidOperationException(
|
$"Unknown timestamp format '{f}'."
|
||||||
$"Unknown timestamp format '{f}'."
|
),
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return new TimestampNode(instant, format);
|
return new TimestampNode(instant, format);
|
||||||
@@ -382,50 +401,51 @@ internal static partial class MarkdownParser
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Matchers that have similar patterns are ordered from most specific to least specific
|
// Matchers that have similar patterns are ordered from most specific to least specific
|
||||||
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> NodeMatcher =
|
||||||
// Escaped text
|
new AggregateMatcher<MarkdownContext, MarkdownNode>(
|
||||||
ShrugTextNodeMatcher,
|
// Escaped text
|
||||||
IgnoredEmojiTextNodeMatcher,
|
ShrugTextNodeMatcher,
|
||||||
EscapedSymbolTextNodeMatcher,
|
IgnoredEmojiTextNodeMatcher,
|
||||||
EscapedCharacterTextNodeMatcher,
|
EscapedSymbolTextNodeMatcher,
|
||||||
// Formatting
|
EscapedCharacterTextNodeMatcher,
|
||||||
ItalicBoldFormattingNodeMatcher,
|
// Formatting
|
||||||
ItalicUnderlineFormattingNodeMatcher,
|
ItalicBoldFormattingNodeMatcher,
|
||||||
BoldFormattingNodeMatcher,
|
ItalicUnderlineFormattingNodeMatcher,
|
||||||
ItalicFormattingNodeMatcher,
|
BoldFormattingNodeMatcher,
|
||||||
UnderlineFormattingNodeMatcher,
|
ItalicFormattingNodeMatcher,
|
||||||
ItalicAltFormattingNodeMatcher,
|
UnderlineFormattingNodeMatcher,
|
||||||
StrikethroughFormattingNodeMatcher,
|
ItalicAltFormattingNodeMatcher,
|
||||||
SpoilerFormattingNodeMatcher,
|
StrikethroughFormattingNodeMatcher,
|
||||||
MultiLineQuoteNodeMatcher,
|
SpoilerFormattingNodeMatcher,
|
||||||
RepeatedSingleLineQuoteNodeMatcher,
|
MultiLineQuoteNodeMatcher,
|
||||||
SingleLineQuoteNodeMatcher,
|
RepeatedSingleLineQuoteNodeMatcher,
|
||||||
HeadingNodeMatcher,
|
SingleLineQuoteNodeMatcher,
|
||||||
ListNodeMatcher,
|
HeadingNodeMatcher,
|
||||||
// Code blocks
|
ListNodeMatcher,
|
||||||
MultiLineCodeBlockNodeMatcher,
|
// Code blocks
|
||||||
InlineCodeBlockNodeMatcher,
|
MultiLineCodeBlockNodeMatcher,
|
||||||
// Mentions
|
InlineCodeBlockNodeMatcher,
|
||||||
EveryoneMentionNodeMatcher,
|
// Mentions
|
||||||
HereMentionNodeMatcher,
|
EveryoneMentionNodeMatcher,
|
||||||
UserMentionNodeMatcher,
|
HereMentionNodeMatcher,
|
||||||
ChannelMentionNodeMatcher,
|
UserMentionNodeMatcher,
|
||||||
RoleMentionNodeMatcher,
|
ChannelMentionNodeMatcher,
|
||||||
// Links
|
RoleMentionNodeMatcher,
|
||||||
MaskedLinkNodeMatcher,
|
// Links
|
||||||
AutoLinkNodeMatcher,
|
MaskedLinkNodeMatcher,
|
||||||
HiddenLinkNodeMatcher,
|
AutoLinkNodeMatcher,
|
||||||
// Emoji
|
HiddenLinkNodeMatcher,
|
||||||
StandardEmojiNodeMatcher,
|
// Emoji
|
||||||
CustomEmojiNodeMatcher,
|
StandardEmojiNodeMatcher,
|
||||||
CodedStandardEmojiNodeMatcher,
|
CustomEmojiNodeMatcher,
|
||||||
// Misc
|
CodedStandardEmojiNodeMatcher,
|
||||||
TimestampNodeMatcher
|
// Misc
|
||||||
);
|
TimestampNodeMatcher
|
||||||
|
);
|
||||||
|
|
||||||
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
||||||
private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
|
||||||
new AggregateMatcher<MarkdownNode>(
|
new AggregateMatcher<MarkdownContext, MarkdownNode>(
|
||||||
// Mentions
|
// Mentions
|
||||||
EveryoneMentionNodeMatcher,
|
EveryoneMentionNodeMatcher,
|
||||||
HereMentionNodeMatcher,
|
HereMentionNodeMatcher,
|
||||||
@@ -439,42 +459,75 @@ internal static partial class MarkdownParser
|
|||||||
);
|
);
|
||||||
|
|
||||||
private static IReadOnlyList<MarkdownNode> Parse(
|
private static IReadOnlyList<MarkdownNode> Parse(
|
||||||
|
MarkdownContext context,
|
||||||
StringSegment segment,
|
StringSegment segment,
|
||||||
IMatcher<MarkdownNode> matcher
|
IMatcher<MarkdownContext, MarkdownNode> matcher
|
||||||
) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray();
|
)
|
||||||
|
{
|
||||||
|
// Limit recursion depth to a reasonable number to prevent
|
||||||
|
// stack overflow on messages with inadvertently deep nesting.
|
||||||
|
// Example: ********************************* (repeat ad nauseam)
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1214
|
||||||
|
if (context.Depth >= 32)
|
||||||
|
return [new TextNode(segment.ToString())];
|
||||||
|
|
||||||
|
return matcher
|
||||||
|
.MatchAll(
|
||||||
|
new MarkdownContext(context.Depth + 1),
|
||||||
|
segment,
|
||||||
|
(_, s) => new TextNode(s.ToString())
|
||||||
|
)
|
||||||
|
.Select(r => r.Value)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static partial class MarkdownParser
|
internal static partial class MarkdownParser
|
||||||
{
|
{
|
||||||
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
|
private static void Extract<TNode>(
|
||||||
Parse(segment, NodeMatcher);
|
IEnumerable<MarkdownNode> nodes,
|
||||||
|
ICollection<TNode> extractedNodes
|
||||||
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
|
)
|
||||||
Parse(new StringSegment(markdown));
|
where TNode : MarkdownNode
|
||||||
|
|
||||||
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
|
|
||||||
Parse(segment, MinimalNodeMatcher);
|
|
||||||
|
|
||||||
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
|
|
||||||
ParseMinimal(new StringSegment(markdown));
|
|
||||||
|
|
||||||
private static void ExtractLinks(IEnumerable<MarkdownNode> nodes, ICollection<LinkNode> links)
|
|
||||||
{
|
{
|
||||||
foreach (var node in nodes)
|
foreach (var node in nodes)
|
||||||
{
|
{
|
||||||
if (node is LinkNode linkNode)
|
if (node is TNode extractedNode)
|
||||||
links.Add(linkNode);
|
extractedNodes.Add(extractedNode);
|
||||||
|
|
||||||
if (node is IContainerNode containerNode)
|
if (node is IContainerNode containerNode)
|
||||||
ExtractLinks(containerNode.Children, links);
|
Extract(containerNode.Children, extractedNodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown)
|
public static IReadOnlyList<TNode> Extract<TNode>(string markdown)
|
||||||
|
where TNode : MarkdownNode
|
||||||
{
|
{
|
||||||
var links = new List<LinkNode>();
|
var extractedNodes = new List<TNode>();
|
||||||
ExtractLinks(Parse(markdown), links);
|
Extract(Parse(markdown), extractedNodes);
|
||||||
|
|
||||||
return links;
|
return extractedNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) =>
|
||||||
|
Extract<LinkNode>(markdown);
|
||||||
|
|
||||||
|
public static IReadOnlyList<EmojiNode> ExtractEmojis(string markdown) =>
|
||||||
|
Extract<EmojiNode>(markdown);
|
||||||
|
|
||||||
|
private static IReadOnlyList<MarkdownNode> Parse(
|
||||||
|
MarkdownContext context,
|
||||||
|
StringSegment segment
|
||||||
|
) => Parse(context, segment, NodeMatcher);
|
||||||
|
|
||||||
|
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
|
||||||
|
Parse(new MarkdownContext(), new StringSegment(markdown));
|
||||||
|
|
||||||
|
private static IReadOnlyList<MarkdownNode> ParseMinimal(
|
||||||
|
MarkdownContext context,
|
||||||
|
StringSegment segment
|
||||||
|
) => Parse(context, segment, MinimalNodeMatcher);
|
||||||
|
|
||||||
|
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
|
||||||
|
ParseMinimal(new MarkdownContext(), new StringSegment(markdown));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ using System.Text.RegularExpressions;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
|
|
||||||
internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> transform) : IMatcher<T>
|
internal class RegexMatcher<TContext, TValue>(
|
||||||
|
Regex regex,
|
||||||
|
Func<TContext, StringSegment, Match, TValue?> transform
|
||||||
|
) : IMatcher<TContext, TValue>
|
||||||
{
|
{
|
||||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
|
||||||
{
|
{
|
||||||
var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
|
var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
|
||||||
if (!match.Success)
|
if (!match.Success)
|
||||||
@@ -20,8 +23,8 @@ internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> trans
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var segmentMatch = segment.Relocate(match);
|
var segmentMatch = segment.Relocate(match);
|
||||||
var value = transform(segmentMatch, match);
|
var value = transform(context, segmentMatch, match);
|
||||||
|
|
||||||
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
|
return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
|
|
||||||
internal class StringMatcher<T>(
|
internal class StringMatcher<TContext, TValue>(
|
||||||
string needle,
|
string needle,
|
||||||
StringComparison comparison,
|
StringComparison comparison,
|
||||||
Func<StringSegment, T?> transform
|
Func<TContext, StringSegment, TValue?> transform
|
||||||
) : IMatcher<T>
|
) : IMatcher<TContext, TValue>
|
||||||
{
|
{
|
||||||
public StringMatcher(string needle, Func<StringSegment, T> transform)
|
public StringMatcher(string needle, Func<TContext, StringSegment, TValue> transform)
|
||||||
: this(needle, StringComparison.Ordinal, transform) { }
|
: this(needle, StringComparison.Ordinal, transform) { }
|
||||||
|
|
||||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
|
||||||
{
|
{
|
||||||
var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
|
var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
|
||||||
|
|
||||||
@@ -19,8 +19,8 @@ internal class StringMatcher<T>(
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var segmentMatch = segment.Relocate(index, needle.Length);
|
var segmentMatch = segment.Relocate(index, needle.Length);
|
||||||
var value = transform(segmentMatch);
|
var value = transform(context, segmentMatch);
|
||||||
|
|
||||||
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
|
return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,10 @@ public static class Http
|
|||||||
private static bool IsRetryableException(Exception exception) =>
|
private static bool IsRetryableException(Exception exception) =>
|
||||||
exception
|
exception
|
||||||
.GetSelfAndChildren()
|
.GetSelfAndChildren()
|
||||||
.Any(
|
.Any(ex =>
|
||||||
ex =>
|
ex is TimeoutException or SocketException or AuthenticationException
|
||||||
ex is TimeoutException or SocketException or AuthenticationException
|
|| ex is HttpRequestException hrex
|
||||||
|| ex is HttpRequestException hrex
|
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
|
||||||
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
public static ResiliencePipeline ResiliencePipeline { get; } =
|
public static ResiliencePipeline ResiliencePipeline { get; } =
|
||||||
@@ -40,7 +39,7 @@ public static class Http
|
|||||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsRetryableException),
|
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsRetryableException),
|
||||||
MaxRetryAttempts = 4,
|
MaxRetryAttempts = 4,
|
||||||
BackoffType = DelayBackoffType.Exponential,
|
BackoffType = DelayBackoffType.Exponential,
|
||||||
Delay = TimeSpan.FromSeconds(1)
|
Delay = TimeSpan.FromSeconds(1),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.Build();
|
.Build();
|
||||||
@@ -69,7 +68,7 @@ public static class Http
|
|||||||
return ValueTask.FromResult<TimeSpan?>(
|
return ValueTask.FromResult<TimeSpan?>(
|
||||||
TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1)
|
TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1)
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.Build();
|
.Build();
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Frozen;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
namespace DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
public static class PathEx
|
public static class PathEx
|
||||||
{
|
{
|
||||||
private static readonly HashSet<char> InvalidFileNameChars =
|
private static readonly FrozenSet<char> InvalidFileNameChars =
|
||||||
new(Path.GetInvalidFileNameChars());
|
[
|
||||||
|
.. Path.GetInvalidFileNameChars(),
|
||||||
|
];
|
||||||
|
|
||||||
public static string EscapeFileName(string path)
|
public static string EscapeFileName(string path)
|
||||||
{
|
{
|
||||||
@@ -19,7 +21,7 @@ public static class PathEx
|
|||||||
|
|
||||||
// File names cannot end with a dot on Windows
|
// File names cannot end with a dot on Windows
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
if (OperatingSystem.IsWindows())
|
||||||
{
|
{
|
||||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||||
buffer.Remove(buffer.Length - 1, 1);
|
buffer.Remove(buffer.Length - 1, 1);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user