mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 15:14:37 +02:00
Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3be5bc6a21 | |||
| c9f152773a | |||
| 5e39611395 | |||
| 40491bdc0f | |||
| 03dd272eaf | |||
| 98e294f7e4 | |||
| b22e786449 | |||
| 951c1e4613 | |||
| 675d910ea3 | |||
| 28f26e45fb | |||
| 4674c517e3 | |||
| b660edfe78 | |||
| 532470e3db | |||
| b533e08d74 | |||
| d490fd0a38 | |||
| 21c2398e2a | |||
| 7ff2347df1 | |||
| 3391e755fc | |||
| f0e9d3be1b | |||
| eef0fc742d | |||
| 2e47c73388 | |||
| 2d49146c74 | |||
| c59374598a | |||
| 01d0203445 | |||
| c4bfb3424e | |||
| 522caba420 | |||
| ad1170b42e | |||
| d2c0e2169b | |||
| e6562de1f5 | |||
| 5f8054e2f0 | |||
| 12d98e9ab0 | |||
| 18086aa209 | |||
| dd7196b6a5 | |||
| 72f9e981de | |||
| 81e286751f | |||
| 225230a20f | |||
| dea412ff25 | |||
| 95953f8cf0 | |||
| 6191855e33 | |||
| 33a975b907 | |||
| 3d353bc1e6 | |||
| f555ab3f00 | |||
| a14a17db83 | |||
| 1f63d4fa26 | |||
| d3d078735a | |||
| 58dd40bf3e | |||
| 7c79e0639b | |||
| a74ba4da0c | |||
| 8bbb8fa5ee | |||
| a91337ccac | |||
| 303f5667bf | |||
| 934cb526e9 | |||
| c33b742dce | |||
| f0a74fbc6e | |||
| 768e124370 | |||
| 8dc7eb2937 | |||
| 8fff0f4445 | |||
| 0fc8124aa0 | |||
| 64ed6acf34 | |||
| e958600073 | |||
| 714d2e6806 | |||
| fbbac2afaa | |||
| 380dd6d511 | |||
| 607ec7442f | |||
| e6f675bcaa | |||
| 42ebb9928e | |||
| 7e64aa5a8e | |||
| a383722469 | |||
| 8f3fe7e501 | |||
| 27a434a663 | |||
| a966bee140 | |||
| fb17e86146 | |||
| 8fcf5e5377 | |||
| 845e2a52fe | |||
| 187befcf11 | |||
| 9e685a0375 | |||
| 0d98e1084d | |||
| f149fad406 | |||
| 5405e619a9 | |||
| adafaaa49d | |||
| d071459a5b | |||
| 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 |
@@ -33,7 +33,7 @@ The HTML format replicates Discord's interface, making it the most user-friendly
|
|||||||
It's the best format for attachment preview and sharing.
|
It's the best format for attachment preview and sharing.
|
||||||
You can open `.html` files with a web browser, such as Google Chrome.
|
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 assets" (`--media`) option to avoid this.
|
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||||
|
|
||||||
|
|||||||
+56
-11
@@ -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.
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
3. Type
|
3. Type
|
||||||
|
|
||||||
```js
|
```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()
|
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||||
@@ -119,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
1. Type
|
1. Type
|
||||||
|
|
||||||
```js
|
```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()
|
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||||
@@ -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
|
||||||
|
|
||||||
> **Warning**:
|
<img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />
|
||||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
|
||||||
|
|
||||||

|
### 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]
|
||||||
|
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
@@ -146,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).
|
||||||
|
|||||||
+44
-30
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
@@ -18,15 +18,18 @@ 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 command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and 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
|
||||||
$ ./DiscordChatExporter.Cli
|
./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
|
||||||
@@ -47,13 +50,13 @@ To use the commands, you'll need a token. For the instructions on how to get a t
|
|||||||
To get help with a specific command, run:
|
To get help with a specific command, run:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli export --help
|
./DiscordChatExporter.Cli export --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Export a specific channel
|
## Export a specific channel
|
||||||
@@ -61,7 +64,7 @@ $ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Changing the format
|
#### Changing the format
|
||||||
@@ -70,7 +73,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
|
|||||||
format is `HtmlDark`.
|
format is `HtmlDark`.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -78,7 +81,7 @@ $ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -88,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -98,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -106,7 +109,7 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports
|
|||||||
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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -125,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
|
||||||
@@ -133,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Downloading assets
|
#### Downloading assets
|
||||||
@@ -150,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
|
||||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Reusing assets
|
#### Reusing assets
|
||||||
@@ -159,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -168,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -177,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
|
||||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Date ranges
|
#### Date ranges
|
||||||
@@ -186,14 +190,14 @@ $ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli 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**
|
||||||
@@ -201,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -210,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
|
||||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Including threads
|
#### Including threads
|
||||||
@@ -225,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -235,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
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -243,7 +257,7 @@ $ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc fals
|
|||||||
To export all accessible channels, use the `exportall` command:
|
To export all accessible channels, use the `exportall` command:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Excluding DMs
|
#### Excluding DMs
|
||||||
@@ -251,7 +265,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
|||||||
To exclude DMs, add the `--include-dm false` option.
|
To exclude DMs, add the `--include-dm false` option.
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ ./DiscordChatExporter.Cli 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
|
||||||
@@ -259,7 +273,7 @@ $ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||||
```
|
```
|
||||||
|
|
||||||
### List direct message channels
|
### List direct message channels
|
||||||
@@ -267,7 +281,7 @@ $ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||||
```
|
```
|
||||||
|
|
||||||
### List servers
|
### List servers
|
||||||
@@ -275,5 +289,5 @@ $ ./DiscordChatExporter.Cli 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
|
||||||
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||||
```
|
```
|
||||||
|
|||||||
+17
-2
@@ -10,13 +10,28 @@
|
|||||||
|
|
||||||
### Step 1
|
### Step 1
|
||||||
|
|
||||||
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
|
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
|
||||||
|
|
||||||
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> **Never share your token!**
|
> **Never share your token!**
|
||||||
> A token gives full access to an account, treat it like a password.
|
> A token gives full access to an account, treat it like a password.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
- "*"
|
||||||
@@ -23,10 +23,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: >
|
run: >
|
||||||
@@ -36,8 +36,8 @@ jobs:
|
|||||||
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload image
|
||||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter.Cli.Docker
|
name: DiscordChatExporter.Cli.Docker
|
||||||
path: DiscordChatExporter.Cli.Docker.tar
|
path: DiscordChatExporter.Cli.Docker.tar
|
||||||
@@ -55,10 +55,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
run: >
|
run: >
|
||||||
|
|||||||
+34
-21
@@ -26,18 +26,29 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 8.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
|
||||||
@@ -51,12 +62,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 8.0.x
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
@@ -72,7 +81,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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -99,7 +108,7 @@ jobs:
|
|||||||
# GUI assets aren't suffixed, unlike the CLI assets
|
# GUI assets aren't suffixed, unlike the CLI assets
|
||||||
asset: DiscordChatExporter
|
asset: DiscordChatExporter
|
||||||
|
|
||||||
runs-on: ubuntu-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:
|
||||||
@@ -108,25 +117,25 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 8.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:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||||
|
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||||
--output ${{ matrix.app }}/bin/publish/
|
--output ${{ matrix.app }}/bin/publish/
|
||||||
--configuration Release
|
--configuration Release
|
||||||
--runtime ${{ matrix.rid }}
|
--runtime ${{ matrix.rid }}
|
||||||
--self-contained
|
--self-contained
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload app binaries
|
||||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/bin/publish/
|
path: ${{ matrix.app }}/bin/publish/
|
||||||
@@ -190,15 +199,19 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download app binaries
|
||||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/
|
path: ${{ matrix.app }}/
|
||||||
|
|
||||||
- name: Set permissions
|
- name: Set permissions
|
||||||
if: ${{ !startsWith(matrix.rid, 'win') }}
|
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
||||||
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
run: |
|
||||||
|
[ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true
|
||||||
|
|
||||||
|
# macOS bundle
|
||||||
|
[ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true
|
||||||
|
|
||||||
- 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
|
||||||
@@ -223,7 +236,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
-1
@@ -9,4 +9,4 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
|
|
||||||
# Test results
|
# Test results
|
||||||
TestResults/
|
TestResults/
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net10.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>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="AngleSharp" Version="1.4.0" />
|
||||||
|
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
||||||
|
<PackageVersion Include="AsyncKeyedLock" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Avalonia" Version="11.3.11" />
|
||||||
|
<PackageVersion Include="Avalonia.Desktop" Version="11.3.11" />
|
||||||
|
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.11" />
|
||||||
|
<PackageVersion Include="CliFx" Version="2.3.6" />
|
||||||
|
<PackageVersion Include="Cogwheel" Version="2.1.0" />
|
||||||
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.5" />
|
||||||
|
<PackageVersion Include="Deorcify" Version="1.1.0" />
|
||||||
|
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
|
||||||
|
<PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
|
||||||
|
<PackageVersion Include="Gress" Version="2.1.1" />
|
||||||
|
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
|
||||||
|
<PackageVersion Include="Markdig" Version="1.0.0" />
|
||||||
|
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
|
||||||
|
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
|
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||||
|
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||||
|
<PackageVersion Include="RazorBlade" Version="0.11.0" />
|
||||||
|
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||||
|
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||||
|
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
<PackageVersion Include="YoutubeExplode" Version="6.5.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
@@ -11,22 +10,21 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.1.2" />
|
<PackageReference Include="AngleSharp" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
<PackageReference Include="FluentAssertions" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="xunit" Version="2.8.0" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" 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,10 +10,14 @@ 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");
|
||||||
|
|
||||||
|
public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");
|
||||||
|
|
||||||
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
||||||
|
|
||||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ In order to run these tests locally, you need to join the test server and config
|
|||||||
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
||||||
4. Run the tests: `dotnet test`
|
4. Run the tests: `dotnet test`
|
||||||
|
|
||||||
> **Note**:
|
> [!NOTE]
|
||||||
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
||||||
@@ -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,7 +50,7 @@ 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>(ctx =>
|
o.Using<DateTimeOffset>(ctx =>
|
||||||
@@ -74,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
|
||||||
@@ -92,7 +92,7 @@ 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>(ctx =>
|
o.Using<DateTimeOffset>(ctx =>
|
||||||
@@ -118,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
|
||||||
@@ -137,7 +137,7 @@ 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>(ctx =>
|
o.Using<DateTimeOffset>(ctx =>
|
||||||
@@ -146,4 +146,31 @@ public class DateRangeSpecs
|
|||||||
.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
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
using System.Linq;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using DiscordChatExporter.Cli.Commands;
|
||||||
using DiscordChatExporter.Cli.Tests.Infra;
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -44,4 +49,41 @@ public class HtmlContentSpecs
|
|||||||
"Yeet"
|
"Yeet"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var file = TempFile.Create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
|
OutputPath = file.Path,
|
||||||
|
Locale = "en-US",
|
||||||
|
IsUtcNormalizationEnabled = true,
|
||||||
|
IsReverseMessageOrder = true,
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
|
||||||
|
var messages = document.QuerySelectorAll("[data-message-id]").ToArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
messages
|
||||||
|
.Select(e => e.GetAttribute("data-message-id"))
|
||||||
|
.Should()
|
||||||
|
.Equal(
|
||||||
|
"885169254029213696",
|
||||||
|
"868505973294268457",
|
||||||
|
"868505969821364245",
|
||||||
|
"868505966528835604",
|
||||||
|
"868490009366396958",
|
||||||
|
"866732113319428096",
|
||||||
|
"866710679758045195",
|
||||||
|
"866674314627121232"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
// Check that the YouTube video thumbnail image exists with the correct video ID
|
||||||
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE");
|
var youtubeThumbnailSrc = message
|
||||||
|
.QuerySelectorAll("img")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.WhereNotNull()
|
||||||
|
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
youtubeThumbnailSrc.Should().NotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using AngleSharp.Dom;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class HtmlForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.ForwardTestCases,
|
||||||
|
Snowflake.Parse("1455202427115536514")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -61,4 +61,17 @@ public class HtmlMentionSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Role mention: @Role 1");
|
message.Text().Should().Contain("Role mention: @Role 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.MentionTestCases,
|
||||||
|
Snowflake.Parse("1474874276828938290")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message.Text().Should().Contain("Thread mention: #Thread starting message");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
using System.Linq;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using DiscordChatExporter.Cli.Commands;
|
||||||
using DiscordChatExporter.Cli.Tests.Infra;
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using JsonExtensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
@@ -43,4 +49,43 @@ public class JsonContentSpecs
|
|||||||
"Yeet"
|
"Yeet"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var file = TempFile.Create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = file.Path,
|
||||||
|
Locale = "en-US",
|
||||||
|
IsUtcNormalizationEnabled = true,
|
||||||
|
IsReverseMessageOrder = true,
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
messages
|
||||||
|
.Select(j => j.GetProperty("id").GetString())
|
||||||
|
.Should()
|
||||||
|
.Equal(
|
||||||
|
"885169254029213696",
|
||||||
|
"868505973294268457",
|
||||||
|
"868505969821364245",
|
||||||
|
"868505966528835604",
|
||||||
|
"868490009366396958",
|
||||||
|
"866732113319428096",
|
||||||
|
"866710679758045195",
|
||||||
|
"866674314627121232"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class JsonForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.ForwardTestCases,
|
||||||
|
Snowflake.Parse("1455202427115536514")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var reference = message.GetProperty("reference");
|
||||||
|
reference.GetProperty("type").GetString().Should().Be("Forward");
|
||||||
|
reference.GetProperty("guildId").GetString().Should().Be("869237470565392384");
|
||||||
|
|
||||||
|
var forwardedMessage = message.GetProperty("forwardedMessage");
|
||||||
|
forwardedMessage.GetProperty("content").GetString().Should().Contain(@"¯\_(ツ)_/¯");
|
||||||
|
forwardedMessage
|
||||||
|
.GetProperty("timestamp")
|
||||||
|
.GetString()
|
||||||
|
.Should()
|
||||||
|
.StartWith("2025-12-28T22:52:42.175+00:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,4 +75,21 @@ public class JsonMentionSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.MentionTestCases,
|
||||||
|
Snowflake.Parse("1474874276828938290")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetString()
|
||||||
|
.Should()
|
||||||
|
.Be("Thread mention: #Thread starting message");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class PlainTextForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.ForwardTestCases);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
document
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class StringExtensions
|
||||||
|
{
|
||||||
|
extension(string str)
|
||||||
|
{
|
||||||
|
public string ReplaceWhiteSpace(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,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using PathEx = System.IO.Path;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
|
||||||
@@ -23,8 +22,8 @@ internal partial class TempDir
|
|||||||
{
|
{
|
||||||
public static TempDir Create()
|
public static TempDir Create()
|
||||||
{
|
{
|
||||||
var dirPath = PathEx.Combine(
|
var dirPath = System.IO.Path.Combine(
|
||||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||||
?? Directory.GetCurrentDirectory(),
|
?? Directory.GetCurrentDirectory(),
|
||||||
"Temp",
|
"Temp",
|
||||||
Guid.NewGuid().ToString()
|
Guid.NewGuid().ToString()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using PathEx = System.IO.Path;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
|
||||||
@@ -23,15 +22,15 @@ internal partial class TempFile
|
|||||||
{
|
{
|
||||||
public static TempFile Create()
|
public static TempFile Create()
|
||||||
{
|
{
|
||||||
var dirPath = PathEx.Combine(
|
var dirPath = System.IO.Path.Combine(
|
||||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||||
?? Directory.GetCurrentDirectory(),
|
?? Directory.GetCurrentDirectory(),
|
||||||
"Temp"
|
"Temp"
|
||||||
);
|
);
|
||||||
|
|
||||||
Directory.CreateDirectory(dirPath);
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||||
|
|
||||||
return new TempFile(filePath);
|
return new TempFile(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:10.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.
|
||||||
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
|
|||||||
COPY favicon.ico .
|
COPY favicon.ico .
|
||||||
COPY NuGet.config .
|
COPY NuGet.config .
|
||||||
COPY Directory.Build.props .
|
COPY Directory.Build.props .
|
||||||
|
COPY Directory.Packages.props .
|
||||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||||
|
|
||||||
@@ -30,7 +31,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:10.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 +46,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. "
|
||||||
@@ -77,6 +83,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public int ParallelLimit { get; init; } = 1;
|
public int ParallelLimit { get; init; } = 1;
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"reverse",
|
||||||
|
Description = "Export messages in reverse chronological order (newest first)."
|
||||||
|
)]
|
||||||
|
public bool IsReverseMessageOrder { get; init; }
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"markdown",
|
"markdown",
|
||||||
Description = "Process markdown, mentions, and other special tokens."
|
Description = "Process markdown, mentions, and other special tokens."
|
||||||
@@ -95,8 +107,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 +114,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 +146,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 +166,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 +229,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 +244,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) =>
|
||||||
{
|
{
|
||||||
@@ -221,6 +273,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
MessageFilter,
|
MessageFilter,
|
||||||
|
IsReverseMessageOrder,
|
||||||
ShouldFormatMarkdown,
|
ShouldFormatMarkdown,
|
||||||
ShouldDownloadAssets,
|
ShouldDownloadAssets,
|
||||||
ShouldReuseAssets,
|
ShouldReuseAssets,
|
||||||
@@ -236,6 +289,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 +305,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 +338,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 +353,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,20 +1,26 @@
|
|||||||
<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>
|
||||||
|
|
||||||
|
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||||
|
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
<PackageReference Include="CliFx" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
<PackageReference Include="Spectre.Console" />
|
||||||
</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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class ConsoleExtensions
|
internal static class ConsoleExtensions
|
||||||
{
|
{
|
||||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
extension(IConsole console)
|
||||||
AnsiConsole.Create(
|
{
|
||||||
new AnsiConsoleSettings
|
public IAnsiConsole CreateAnsiConsole() =>
|
||||||
{
|
AnsiConsole.Create(
|
||||||
Ansi = AnsiSupport.Detect,
|
new AnsiConsoleSettings
|
||||||
ColorSystem = ColorSystemSupport.Detect,
|
{
|
||||||
Out = new AnsiConsoleOutput(console.Output)
|
Ansi = AnsiSupport.Detect,
|
||||||
}
|
ColorSystem = ColorSystemSupport.Detect,
|
||||||
);
|
Out = new AnsiConsoleOutput(console.Output),
|
||||||
|
}
|
||||||
public static Status CreateStatusTicker(this IConsole console) =>
|
|
||||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
|
||||||
|
|
||||||
public static Progress CreateProgressTicker(this IConsole console) =>
|
|
||||||
console
|
|
||||||
.CreateAnsiConsole()
|
|
||||||
.Progress()
|
|
||||||
.AutoClear(false)
|
|
||||||
.AutoRefresh(true)
|
|
||||||
.HideCompleted(false)
|
|
||||||
.Columns(
|
|
||||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
|
||||||
new ProgressBarColumn(),
|
|
||||||
new PercentageColumn()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public Status CreateStatusTicker() =>
|
||||||
|
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||||
|
|
||||||
|
public Progress CreateProgressTicker() =>
|
||||||
|
console
|
||||||
|
.CreateAnsiConsole()
|
||||||
|
.Progress()
|
||||||
|
.AutoClear(false)
|
||||||
|
.AutoRefresh(true)
|
||||||
|
.HideCompleted(false)
|
||||||
|
.Columns(
|
||||||
|
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||||
|
new ProgressBarColumn(),
|
||||||
|
new PercentageColumn()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static async ValueTask StartTaskAsync(
|
public static async ValueTask StartTaskAsync(
|
||||||
this ProgressContext context,
|
this ProgressContext context,
|
||||||
string description,
|
string description,
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -67,7 +69,8 @@ public partial record Channel
|
|||||||
var guildId =
|
var guildId =
|
||||||
json.GetPropertyOrNull("guild_id")
|
json.GetPropertyOrNull("guild_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
?.Pipe(Snowflake.Parse)
|
||||||
|
?? Guild.DirectMessages.Id;
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
// Guild channel
|
// Guild channel
|
||||||
@@ -76,6 +79,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
|
||||||
@@ -93,7 +97,8 @@ public partial record Channel
|
|||||||
var isArchived =
|
var isArchived =
|
||||||
json.GetPropertyOrNull("thread_metadata")
|
json.GetPropertyOrNull("thread_metadata")
|
||||||
?.GetPropertyOrNull("archived")
|
?.GetPropertyOrNull("archived")
|
||||||
?.GetBooleanOrNull() ?? false;
|
?.GetBooleanOrNull()
|
||||||
|
?? false;
|
||||||
|
|
||||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data;
|
|
||||||
|
|
||||||
public record ChannelNode(Channel Channel, IReadOnlyList<ChannelNode> Children)
|
|
||||||
{
|
|
||||||
public static IReadOnlyList<ChannelNode> BuildTree(IReadOnlyList<Channel> channels)
|
|
||||||
{
|
|
||||||
IReadOnlyList<ChannelNode> GetChildren(Channel parent) =>
|
|
||||||
channels
|
|
||||||
.Where(c => c.Parent?.Id == parent.Id)
|
|
||||||
.Select(c => new ChannelNode(c, GetChildren(c)))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
return channels
|
|
||||||
.Where(c => c.Parent is null)
|
|
||||||
.Select(c => new ChannelNode(c, GetChildren(c)))
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,11 @@ public partial record Embed
|
|||||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||||
|
|
||||||
var kind =
|
var kind =
|
||||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
json.GetPropertyOrNull("type")
|
||||||
|
?.GetStringOrNull()
|
||||||
|
?.Pipe(s =>
|
||||||
|
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
|
||||||
|
)
|
||||||
?? EmbedKind.Rich;
|
?? EmbedKind.Rich;
|
||||||
|
|
||||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
@@ -63,7 +67,8 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("fields")
|
json.GetPropertyOrNull("fields")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(EmbedField.Parse)
|
?.Select(EmbedField.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||||
|
|
||||||
@@ -78,7 +83,8 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("image")
|
json.GetPropertyOrNull("image")
|
||||||
?.Pipe(EmbedImage.Parse)
|
?.Pipe(EmbedImage.Parse)
|
||||||
.ToSingletonEnumerable()
|
.ToSingletonEnumerable()
|
||||||
.ToArray() ?? [];
|
.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,10 @@
|
|||||||
|
|
||||||
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/watch?v={VideoId}";
|
||||||
|
|
||||||
|
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
|
||||||
|
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -25,7 +25,8 @@ public partial record Guild
|
|||||||
var iconUrl =
|
var iconUrl =
|
||||||
json.GetPropertyOrNull("icon")
|
json.GetPropertyOrNull("icon")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
|
||||||
|
?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||||
|
|
||||||
return new Guild(id, name, iconUrl);
|
return new Guild(id, name, iconUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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
|
||||||
@@ -32,7 +32,8 @@ public partial record Member
|
|||||||
?.EnumerateArray()
|
?.EnumerateArray()
|
||||||
.Select(j => j.GetNonWhiteSpaceString())
|
.Select(j => j.GetNonWhiteSpaceString())
|
||||||
.Select(Snowflake.Parse)
|
.Select(Snowflake.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var avatarUrl = guildId is not null
|
var avatarUrl = guildId is not null
|
||||||
? json.GetPropertyOrNull("avatar")
|
? json.GetPropertyOrNull("avatar")
|
||||||
|
|||||||
@@ -27,23 +27,26 @@ public partial record Message(
|
|||||||
IReadOnlyList<User> MentionedUsers,
|
IReadOnlyList<User> MentionedUsers,
|
||||||
MessageReference? Reference,
|
MessageReference? Reference,
|
||||||
Message? ReferencedMessage,
|
Message? ReferencedMessage,
|
||||||
|
MessageSnapshot? ForwardedMessage,
|
||||||
Interaction? Interaction
|
Interaction? Interaction
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsSystemNotification =>
|
public bool IsEmpty { get; } =
|
||||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
|
||||||
|
|
||||||
public bool IsReply => Kind == MessageKind.Reply;
|
|
||||||
|
|
||||||
// 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 IsEmpty =>
|
|
||||||
string.IsNullOrWhiteSpace(Content)
|
string.IsNullOrWhiteSpace(Content)
|
||||||
&& !Attachments.Any()
|
&& !Attachments.Any()
|
||||||
&& !Embeds.Any()
|
&& !Embeds.Any()
|
||||||
&& !Stickers.Any();
|
&& !Stickers.Any();
|
||||||
|
|
||||||
|
public bool IsSystemNotification { get; } =
|
||||||
|
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||||
|
|
||||||
|
public bool IsReply { get; } = Kind == MessageKind.Reply;
|
||||||
|
|
||||||
|
// 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 IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
|
||||||
|
|
||||||
public IEnumerable<User> GetReferencedUsers()
|
public IEnumerable<User> GetReferencedUsers()
|
||||||
{
|
{
|
||||||
yield return Author;
|
yield return Author;
|
||||||
@@ -143,7 +146,8 @@ public partial record Message
|
|||||||
json.GetPropertyOrNull("attachments")
|
json.GetPropertyOrNull("attachments")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Attachment.Parse)
|
?.Select(Attachment.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var embeds = NormalizeEmbeds(
|
var embeds = NormalizeEmbeds(
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
@@ -154,13 +158,15 @@ public partial record Message
|
|||||||
json.GetPropertyOrNull("sticker_items")
|
json.GetPropertyOrNull("sticker_items")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Sticker.Parse)
|
?.Select(Sticker.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var reactions =
|
var reactions =
|
||||||
json.GetPropertyOrNull("reactions")
|
json.GetPropertyOrNull("reactions")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Reaction.Parse)
|
?.Select(Reaction.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var mentionedUsers =
|
var mentionedUsers =
|
||||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||||
@@ -168,7 +174,17 @@ public partial record Message
|
|||||||
|
|
||||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||||
?.Pipe(MessageReference.Parse);
|
?.Pipe(MessageReference.Parse);
|
||||||
|
|
||||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||||
|
|
||||||
|
// Currently Discord only supports 1 snapshot per forward
|
||||||
|
var forwardedMessage = json.GetPropertyOrNull("message_snapshots")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(j => j.GetPropertyOrNull("message"))
|
||||||
|
.WhereNotNull()
|
||||||
|
.Select(MessageSnapshot.Parse)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
||||||
|
|
||||||
return new Message(
|
return new Message(
|
||||||
@@ -188,6 +204,7 @@ public partial record Message
|
|||||||
mentionedUsers,
|
mentionedUsers,
|
||||||
messageReference,
|
messageReference,
|
||||||
referencedMessage,
|
referencedMessage,
|
||||||
|
forwardedMessage,
|
||||||
interaction
|
interaction
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,19 @@ using JsonExtensions.Reading;
|
|||||||
namespace DiscordChatExporter.Core.Discord.Data;
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||||
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
|
public record MessageReference(
|
||||||
|
MessageReferenceKind Kind,
|
||||||
|
Snowflake? MessageId,
|
||||||
|
Snowflake? ChannelId,
|
||||||
|
Snowflake? GuildId
|
||||||
|
)
|
||||||
{
|
{
|
||||||
public static MessageReference Parse(JsonElement json)
|
public static MessageReference Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
|
var kind =
|
||||||
|
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
|
||||||
|
?? MessageReferenceKind.Default;
|
||||||
|
|
||||||
var messageId = json.GetPropertyOrNull("message_id")
|
var messageId = json.GetPropertyOrNull("message_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
|||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
return new MessageReference(messageId, channelId, guildId);
|
return new MessageReference(kind, messageId, channelId, guildId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-reference-types
|
||||||
|
public enum MessageReferenceKind
|
||||||
|
{
|
||||||
|
Default = 0,
|
||||||
|
Forward = 1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://docs.discord.com/developers/resources/message#message-snapshot-object
|
||||||
|
public record MessageSnapshot(
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
DateTimeOffset? EditedTimestamp,
|
||||||
|
string Content,
|
||||||
|
IReadOnlyList<Attachment> Attachments,
|
||||||
|
IReadOnlyList<Embed> Embeds,
|
||||||
|
IReadOnlyList<Sticker> Stickers
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static MessageSnapshot Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var timestamp =
|
||||||
|
json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull()
|
||||||
|
?? DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
|
||||||
|
|
||||||
|
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
|
||||||
|
|
||||||
|
var attachments =
|
||||||
|
json.GetPropertyOrNull("attachments")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(Attachment.Parse)
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
var embeds =
|
||||||
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
var stickers =
|
||||||
|
json.GetPropertyOrNull("sticker_items")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(Sticker.Parse)
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
return new MessageSnapshot(
|
||||||
|
timestamp,
|
||||||
|
editedTimestamp,
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
embeds,
|
||||||
|
stickers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,35 +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()
|
||||||
.StatusCode.ToString()
|
.ToSpaceSeparatedWords()
|
||||||
.ToSpaceSeparatedWords()
|
.ToLowerInvariant()}.
|
||||||
.ToLowerInvariant()}.
|
Response content: {await response.Content.ReadAsStringAsync(
|
||||||
Response content: {await response.Content.ReadAsStringAsync(
|
cancellationToken
|
||||||
cancellationToken
|
)}
|
||||||
)}
|
""",
|
||||||
""",
|
true
|
||||||
true
|
),
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +194,23 @@ public class DiscordClient(string token)
|
|||||||
return Application.Parse(response);
|
return Application.Parse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask EnsureMessageContentIntentAsync(
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var application = await GetApplicationAsync(cancellationToken);
|
||||||
|
if (application.IsMessageContentIntentEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
throw new DiscordChatExporterException(
|
||||||
|
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask<User?> TryGetUserAsync(
|
public async ValueTask<User?> TryGetUserAsync(
|
||||||
Snowflake userId,
|
Snowflake userId,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -298,184 +322,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,21 +388,229 @@ public class DiscordClient(string token)
|
|||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var parent = parentId is not null
|
|
||||||
? await GetChannelAsync(parentId.Value, cancellationToken)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Channel.Parse(response, parent);
|
|
||||||
}
|
|
||||||
// It's possible for the parent channel to be inaccessible, despite the
|
// It's possible for the parent channel to be inaccessible, despite the
|
||||||
// child channel being accessible.
|
// child channel being accessible.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||||
catch (DiscordChatExporterException)
|
var parent = parentId is not null
|
||||||
|
? await TryGetChannelAsync(parentId.Value, cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Channel.Parse(response, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Channel?> TryGetChannelAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var response = await TryGetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var parentId = response
|
||||||
|
.Value.GetPropertyOrNull("parent_id")
|
||||||
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
Channel? parent = null;
|
||||||
|
if (parentId is not null)
|
||||||
{
|
{
|
||||||
return Channel.Parse(response);
|
// It's possible for the parent channel to be inaccessible, despite the
|
||||||
|
// child channel being accessible.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||||
|
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Channel.Parse(response.Value, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Track yielded thread IDs to avoid duplicates that can occur when a thread transitions
|
||||||
|
// from active to archived between the two separate API calls used to fetch threads.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1433
|
||||||
|
var seenThreadIds = new HashSet<Snowflake>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenThreadIds.Add(thread.Id))
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, parent);
|
||||||
|
|
||||||
|
if (seenThreadIds.Add(thread.Id))
|
||||||
|
yield return thread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
currentBefore = threadJson
|
||||||
|
.GetProperty("thread_metadata")
|
||||||
|
.GetProperty("archive_timestamp")
|
||||||
|
.GetString();
|
||||||
|
|
||||||
|
if (seenThreadIds.Add(thread.Id))
|
||||||
|
yield return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake? after = null,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channelId}/messages")
|
||||||
|
.SetQueryParameter("limit", "1")
|
||||||
|
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
|
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||||
|
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
@@ -604,22 +671,10 @@ public class DiscordClient(string token)
|
|||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||||
// have the Message Content Intent enabled.
|
// have the MESSAGE_CONTENT intent enabled.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||||
if (
|
if (messages.All(m => m.IsEmpty))
|
||||||
messages.All(m => m.IsEmpty)
|
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||||
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var application = await GetApplicationAsync(cancellationToken);
|
|
||||||
if (!application.IsMessageContentIntentEnabled)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
"Provided bot account does not have the Message Content Intent enabled.",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
@@ -652,6 +707,75 @@ public class DiscordClient(string token)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake? after = null,
|
||||||
|
Snowflake? before = null,
|
||||||
|
IProgress<Percentage>? progress = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Get the first message in the specified range, so we can later calculate the
|
||||||
|
// progress based on the difference between message timestamps.
|
||||||
|
// Snapshotting is not necessary here because new messages can't appear in the past.
|
||||||
|
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||||
|
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// Keep track of the last message in range in order to calculate the progress
|
||||||
|
var lastMessage = default(Message);
|
||||||
|
|
||||||
|
var currentBefore = before;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channelId}/messages")
|
||||||
|
.SetQueryParameter("limit", "100")
|
||||||
|
.SetQueryParameter("before", currentBefore?.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
|
||||||
|
|
||||||
|
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||||
|
if (!messages.Any())
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||||
|
// have the MESSAGE_CONTENT intent enabled.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||||
|
if (messages.All(m => m.IsEmpty))
|
||||||
|
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
lastMessage ??= message;
|
||||||
|
|
||||||
|
// Report progress based on timestamps
|
||||||
|
if (progress is not null)
|
||||||
|
{
|
||||||
|
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
|
||||||
|
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
|
|
||||||
|
progress.Report(
|
||||||
|
Percentage.FromFraction(
|
||||||
|
// Avoid division by zero if all messages have the exact same timestamp
|
||||||
|
// (which happens when there's only one message in the channel)
|
||||||
|
totalDuration > TimeSpan.Zero
|
||||||
|
? exportedDuration / totalDuration
|
||||||
|
: 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBefore = messages.Last().Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
|
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake messageId,
|
Snowflake messageId,
|
||||||
@@ -692,9 +816,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -40,17 +41,19 @@ public partial class DataDump
|
|||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(zipFilePath);
|
await using var archive = await ZipFile.OpenReadAsync(zipFilePath, cancellationToken);
|
||||||
|
|
||||||
var entry = archive.GetEntry("messages/index.json");
|
// Use case-insensitive search to accommodate for different data dump versions
|
||||||
if (entry is null)
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
|
||||||
{
|
var entry =
|
||||||
throw new InvalidOperationException(
|
archive.Entries.FirstOrDefault(e =>
|
||||||
|
e.FullName.Equals("messages/index.json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
"Failed to locate the channel index inside the data package."
|
"Failed to locate the channel index inside the data package."
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
await using var stream = entry.Open();
|
await using var stream = await entry.OpenAsync(cancellationToken);
|
||||||
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
||||||
|
|
||||||
return Parse(document.RootElement);
|
return Parse(document.RootElement);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum RateLimitPreference
|
||||||
|
{
|
||||||
|
IgnoreAll = 0,
|
||||||
|
RespectForUserTokens = 0b1,
|
||||||
|
RespectForBotTokens = 0b10,
|
||||||
|
RespectAll = RespectForUserTokens | RespectForBotTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RateLimitPreferenceExtensions
|
||||||
|
{
|
||||||
|
extension(RateLimitPreference rateLimitPreference)
|
||||||
|
{
|
||||||
|
internal bool IsRespectedFor(TokenKind tokenKind) =>
|
||||||
|
tokenKind switch
|
||||||
|
{
|
||||||
|
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens)
|
||||||
|
!= 0,
|
||||||
|
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens)
|
||||||
|
!= 0,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||||
|
};
|
||||||
|
|
||||||
|
public string GetDisplayName() =>
|
||||||
|
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,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncKeyedLock" Version="6.4.2" />
|
<PackageReference Include="AngleSharp" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
<PackageReference Include="AsyncKeyedLock" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="Polly" Version="8.4.0" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="RazorBlade" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
<PackageReference Include="Superpower" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.3.14" />
|
<PackageReference Include="WebMarkupMin.Core" />
|
||||||
|
<PackageReference Include="YoutubeExplode" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Exceptions;
|
||||||
|
|
||||||
|
public class ChannelEmptyException(string message) : DiscordChatExporterException(message);
|
||||||
@@ -27,54 +27,60 @@ 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);
|
||||||
await foreach (
|
|
||||||
var message in discord.GetMessagesAsync(
|
// 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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = !request.IsReverseMessageOrder
|
||||||
|
? discord.GetMessagesAsync(
|
||||||
request.Channel.Id,
|
request.Channel.Id,
|
||||||
request.After,
|
request.After,
|
||||||
request.Before,
|
request.Before,
|
||||||
progress,
|
progress,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
)
|
: discord.GetMessagesInReverseAsync(
|
||||||
|
request.Channel.Id,
|
||||||
|
request.After,
|
||||||
|
request.Before,
|
||||||
|
progress,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
await foreach (var message in messages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -98,15 +104,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);
|
||||||
@@ -43,6 +37,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
|||||||
if (reuse && File.Exists(filePath))
|
if (reuse && File.Exists(filePath))
|
||||||
return _previousPathsByUrl[url] = filePath;
|
return _previousPathsByUrl[url] = filePath;
|
||||||
|
|
||||||
|
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
||||||
|
// to the new naming scheme to preserve backwards compatibility with existing exports
|
||||||
|
if (reuse)
|
||||||
|
{
|
||||||
|
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
|
||||||
|
if (File.Exists(legacyFilePath))
|
||||||
|
{
|
||||||
|
// Overwrite in case the destination file was created concurrently between our
|
||||||
|
// earlier existence check and this move operation
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Move(legacyFilePath, filePath, overwrite: true);
|
||||||
|
return _previousPathsByUrl[url] = filePath;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// The legacy file was moved or deleted concurrently or something else happened.
|
||||||
|
// Upgrading old files is not crucial, so we can just move on.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(workingDirPath);
|
Directory.CreateDirectory(workingDirPath);
|
||||||
|
|
||||||
await Http.ResiliencePipeline.ExecuteAsync(
|
await Http.ResiliencePipeline.ExecuteAsync(
|
||||||
@@ -50,38 +66,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
|
||||||
);
|
);
|
||||||
@@ -92,34 +78,23 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
|||||||
|
|
||||||
internal partial class ExportAssetDownloader
|
internal partial class ExportAssetDownloader
|
||||||
{
|
{
|
||||||
private static string GetUrlHash(string url)
|
private static string NormalizeUrl(string url)
|
||||||
{
|
{
|
||||||
// Remove signature parameters from Discord CDN URLs to normalize them
|
// Remove signature parameters from Discord CDN URLs to normalize them
|
||||||
static string NormalizeUrl(string url)
|
var uri = new Uri(url);
|
||||||
{
|
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||||
var uri = new Uri(url);
|
return url;
|
||||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return url;
|
|
||||||
|
|
||||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||||
query.Remove("ex");
|
query.Remove("ex");
|
||||||
query.Remove("is");
|
query.Remove("is");
|
||||||
query.Remove("hm");
|
query.Remove("hm");
|
||||||
|
|
||||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||||
}
|
|
||||||
|
|
||||||
return SHA256
|
|
||||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
|
||||||
.ToHex()
|
|
||||||
// 5 chars ought to be enough for anybody
|
|
||||||
.Truncate(5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetFileNameFromUrl(string url)
|
private static string GetFileNameFromUrl(string url, string urlHash)
|
||||||
{
|
{
|
||||||
var urlHash = GetUrlHash(url);
|
|
||||||
|
|
||||||
// Try to extract the file name from URL
|
// Try to extract the file name from URL
|
||||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||||
|
|
||||||
@@ -139,8 +114,29 @@ internal partial class ExportAssetDownloader
|
|||||||
fileExtension = "";
|
fileExtension = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return PathEx.EscapeFileName(
|
return Path.EscapeFileName(
|
||||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetFileNameFromUrl(string url) =>
|
||||||
|
GetFileNameFromUrl(
|
||||||
|
url,
|
||||||
|
// 16 chars = 64 bits, reaches 1% collision probability at ~609 million files
|
||||||
|
SHA256
|
||||||
|
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||||
|
.Pipe(Convert.ToHexStringLower)
|
||||||
|
.Truncate(16)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
||||||
|
private static string GetLegacyFileNameFromUrl(string url) =>
|
||||||
|
GetFileNameFromUrl(
|
||||||
|
url,
|
||||||
|
SHA256
|
||||||
|
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||||
|
.Pipe(Convert.ToHexStringLower)
|
||||||
|
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
|
||||||
|
.Truncate(5)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -15,11 +16,13 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
||||||
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;
|
||||||
|
|
||||||
@@ -48,6 +51,21 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Threads are not preloaded, so we resolve them on demand
|
||||||
|
public async ValueTask PopulateChannelAsync(
|
||||||
|
Snowflake id,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (_channelsById.ContainsKey(id))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var channel = await Discord.TryGetChannelAsync(id, cancellationToken);
|
||||||
|
|
||||||
|
// Store the result even if it's null, to avoid re-fetching non-existing channels
|
||||||
|
_channelsById[id] = channel;
|
||||||
|
}
|
||||||
|
|
||||||
// Because members cannot be pulled in bulk, we need to populate them on demand
|
// Because members cannot be pulled in bulk, we need to populate them on demand
|
||||||
private async ValueTask PopulateMemberAsync(
|
private async ValueTask PopulateMemberAsync(
|
||||||
Snowflake id,
|
Snowflake id,
|
||||||
@@ -96,7 +114,8 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
?.RoleIds.Select(TryGetRole)
|
?.RoleIds.Select(TryGetRole)
|
||||||
.WhereNotNull()
|
.WhereNotNull()
|
||||||
.OrderByDescending(r => r.Position)
|
.OrderByDescending(r => r.Position)
|
||||||
.ToArray() ?? [];
|
.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();
|
||||||
@@ -115,7 +134,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,
|
||||||
@@ -130,14 +149,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,30 +8,33 @@ public enum ExportFormat
|
|||||||
HtmlDark,
|
HtmlDark,
|
||||||
HtmlLight,
|
HtmlLight,
|
||||||
Csv,
|
Csv,
|
||||||
Json
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ExportFormatExtensions
|
public static class ExportFormatExtensions
|
||||||
{
|
{
|
||||||
public static string GetFileExtension(this ExportFormat format) =>
|
extension(ExportFormat format)
|
||||||
format switch
|
{
|
||||||
{
|
public string GetFileExtension() =>
|
||||||
ExportFormat.PlainText => "txt",
|
format switch
|
||||||
ExportFormat.HtmlDark => "html",
|
{
|
||||||
ExportFormat.HtmlLight => "html",
|
ExportFormat.PlainText => "txt",
|
||||||
ExportFormat.Csv => "csv",
|
ExportFormat.HtmlDark => "html",
|
||||||
ExportFormat.Json => "json",
|
ExportFormat.HtmlLight => "html",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
ExportFormat.Csv => "csv",
|
||||||
};
|
ExportFormat.Json => "json",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||||
|
};
|
||||||
|
|
||||||
public static string GetDisplayName(this ExportFormat format) =>
|
public string GetDisplayName() =>
|
||||||
format switch
|
format switch
|
||||||
{
|
{
|
||||||
ExportFormat.PlainText => "TXT",
|
ExportFormat.PlainText => "TXT",
|
||||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||||
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)),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using DiscordChatExporter.Core.Discord;
|
|||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
@@ -34,6 +33,8 @@ public partial class ExportRequest
|
|||||||
|
|
||||||
public MessageFilter MessageFilter { get; }
|
public MessageFilter MessageFilter { get; }
|
||||||
|
|
||||||
|
public bool IsReverseMessageOrder { get; }
|
||||||
|
|
||||||
public bool ShouldFormatMarkdown { get; }
|
public bool ShouldFormatMarkdown { get; }
|
||||||
|
|
||||||
public bool ShouldDownloadAssets { get; }
|
public bool ShouldDownloadAssets { get; }
|
||||||
@@ -56,6 +57,7 @@ public partial class ExportRequest
|
|||||||
Snowflake? before,
|
Snowflake? before,
|
||||||
PartitionLimit partitionLimit,
|
PartitionLimit partitionLimit,
|
||||||
MessageFilter messageFilter,
|
MessageFilter messageFilter,
|
||||||
|
bool isReverseMessageOrder,
|
||||||
bool shouldFormatMarkdown,
|
bool shouldFormatMarkdown,
|
||||||
bool shouldDownloadAssets,
|
bool shouldDownloadAssets,
|
||||||
bool shouldReuseAssets,
|
bool shouldReuseAssets,
|
||||||
@@ -70,6 +72,7 @@ public partial class ExportRequest
|
|||||||
Before = before;
|
Before = before;
|
||||||
PartitionLimit = partitionLimit;
|
PartitionLimit = partitionLimit;
|
||||||
MessageFilter = messageFilter;
|
MessageFilter = messageFilter;
|
||||||
|
IsReverseMessageOrder = isReverseMessageOrder;
|
||||||
ShouldFormatMarkdown = shouldFormatMarkdown;
|
ShouldFormatMarkdown = shouldFormatMarkdown;
|
||||||
ShouldDownloadAssets = shouldDownloadAssets;
|
ShouldDownloadAssets = shouldDownloadAssets;
|
||||||
ShouldReuseAssets = shouldReuseAssets;
|
ShouldReuseAssets = shouldReuseAssets;
|
||||||
@@ -145,7 +148,7 @@ public partial class ExportRequest
|
|||||||
// File extension
|
// File extension
|
||||||
buffer.Append('.').Append(format.GetFileExtension());
|
buffer.Append('.').Append(format.GetFileExtension());
|
||||||
|
|
||||||
return PathEx.EscapeFileName(buffer.ToString());
|
return Path.EscapeFileName(buffer.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatPath(
|
private static string FormatPath(
|
||||||
@@ -159,7 +162,7 @@ public partial class ExportRequest
|
|||||||
path,
|
path,
|
||||||
"%.",
|
"%.",
|
||||||
m =>
|
m =>
|
||||||
PathEx.EscapeFileName(
|
Path.EscapeFileName(
|
||||||
m.Value switch
|
m.Value switch
|
||||||
{
|
{
|
||||||
"%g" => guild.Id.ToString(),
|
"%g" => guild.Id.ToString(),
|
||||||
@@ -172,24 +175,22 @@ 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"
|
"%d" => DateTimeOffset.Now.ToString(
|
||||||
=> DateTimeOffset.Now.ToString(
|
"yyyy-MM-dd",
|
||||||
"yyyy-MM-dd",
|
CultureInfo.InvariantCulture
|
||||||
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}'."),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}'."
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ internal enum MessageContentMatchKind
|
|||||||
Image,
|
Image,
|
||||||
Sound,
|
Sound,
|
||||||
Pin,
|
Pin,
|
||||||
Invite
|
Invite,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -217,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(
|
||||||
@@ -228,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)}">
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,6 +270,12 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
|
// Channel/thread mentions may reference threads that are not preloaded,
|
||||||
|
// so we resolve them on demand.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||||
|
if (mention.TargetId is not null)
|
||||||
|
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||||
|
|
||||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||||
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ internal static class HtmlMessageExtensions
|
|||||||
{
|
{
|
||||||
// Message content is hidden if it's a link to an embedded media
|
// Message content is hidden if it's a link to an embedded media
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||||
public static bool IsContentHidden(this Message message)
|
extension(Message message)
|
||||||
{
|
{
|
||||||
if (message.Embeds.Count != 1)
|
public bool IsContentHidden()
|
||||||
return false;
|
{
|
||||||
|
if (message.Embeds.Count != 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
var embed = message.Embeds[0];
|
var embed = message.Embeds[0];
|
||||||
|
|
||||||
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
|
return string.Equals(
|
||||||
&& embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
message.Content.Trim(),
|
||||||
|
embed.Url,
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
) && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ 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 = [];
|
||||||
|
|
||||||
|
// Note: in reverse order, last message appears earlier than the first message
|
||||||
private bool CanJoinGroup(Message message)
|
private bool CanJoinGroup(Message message)
|
||||||
{
|
{
|
||||||
// If the group is empty, any message can join it
|
// If the group is empty, any message can join it
|
||||||
@@ -90,7 +91,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 +132,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
|
||||||
@@ -89,6 +119,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteAttachmentAsync(
|
||||||
|
Attachment attachment,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", attachment.Id.ToString());
|
||||||
|
_writer.WriteString(
|
||||||
|
"url",
|
||||||
|
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||||
|
);
|
||||||
|
_writer.WriteString("fileName", attachment.FileName);
|
||||||
|
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteEmbedAuthorAsync(
|
private async ValueTask WriteEmbedAuthorAsync(
|
||||||
EmbedAuthor embedAuthor,
|
EmbedAuthor embedAuthor,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -108,6 +156,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
@@ -130,6 +180,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("canonicalUrl", embedImage.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedImage.Width);
|
_writer.WriteNumber("width", embedImage.Width);
|
||||||
@@ -155,6 +207,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("canonicalUrl", embedVideo.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedVideo.Width);
|
_writer.WriteNumber("width", embedVideo.Width);
|
||||||
@@ -182,6 +236,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
@@ -273,10 +329,48 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteStickerAsync(
|
||||||
|
Sticker sticker,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", sticker.Id.ToString());
|
||||||
|
_writer.WriteString("name", sticker.Name);
|
||||||
|
_writer.WriteString("format", sticker.Format.ToString());
|
||||||
|
_writer.WriteString(
|
||||||
|
"sourceUrl",
|
||||||
|
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(
|
public override async ValueTask WritePreambleAsync(
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
@@ -373,25 +467,13 @@ 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");
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
{
|
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("id", attachment.Id.ToString());
|
|
||||||
_writer.WriteString(
|
|
||||||
"url",
|
|
||||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
|
||||||
);
|
|
||||||
_writer.WriteString("fileName", attachment.FileName);
|
|
||||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -407,19 +489,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
_writer.WriteStartArray("stickers");
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
foreach (var sticker in message.Stickers)
|
foreach (var sticker in message.Stickers)
|
||||||
{
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("id", sticker.Id.ToString());
|
|
||||||
_writer.WriteString("name", sticker.Name);
|
|
||||||
_writer.WriteString("format", sticker.Format.ToString());
|
|
||||||
_writer.WriteString(
|
|
||||||
"sourceUrl",
|
|
||||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
|
||||||
);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -431,20 +501,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 +518,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 +530,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();
|
||||||
|
|
||||||
@@ -497,12 +539,58 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
if (message.Reference is not null)
|
if (message.Reference is not null)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("reference");
|
_writer.WriteStartObject("reference");
|
||||||
|
_writer.WriteString("type", message.Reference.Kind.ToString());
|
||||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forwarded message
|
||||||
|
if (message.ForwardedMessage is not null)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("forwardedMessage");
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"timestamp",
|
||||||
|
Context.NormalizeDate(message.ForwardedMessage.Timestamp)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"timestampEdited",
|
||||||
|
message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"content",
|
||||||
|
await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forwarded attachments
|
||||||
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
|
foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||||
|
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Forwarded embeds
|
||||||
|
_writer.WriteStartArray("embeds");
|
||||||
|
foreach (var embed in message.ForwardedMessage.Embeds)
|
||||||
|
await WriteEmbedAsync(embed, cancellationToken);
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Forwarded stickers
|
||||||
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
|
foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||||
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
// Interaction
|
// Interaction
|
||||||
if (message.Interaction is not null)
|
if (message.Interaction is not null)
|
||||||
{
|
{
|
||||||
@@ -512,11 +600,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}'."
|
),
|
||||||
)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
@foreach (var (message, i) in Messages.WithIndex())
|
@foreach (var (i, message) in Messages.Index())
|
||||||
{
|
{
|
||||||
var isFirst = i == 0;
|
var isFirst = i == 0;
|
||||||
|
|
||||||
@@ -262,6 +262,95 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Forwarded message *@
|
||||||
|
@if (message is { IsForwarded: true, ForwardedMessage: not null })
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded">
|
||||||
|
<div class="chatlog__forwarded-header">
|
||||||
|
<svg class="chatlog__forwarded-icon">
|
||||||
|
<use href="#forward-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<em>Forwarded</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Forwarded content *@
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded-content chatlog__markdown">
|
||||||
|
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded attachments *@
|
||||||
|
@if (message.ForwardedMessage.Attachments.Any())
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded-attachments">
|
||||||
|
@foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||||
|
{
|
||||||
|
@if (attachment.IsImage)
|
||||||
|
{
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
<img class="chatlog__forwarded-attachment" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (attachment.IsVideo)
|
||||||
|
{
|
||||||
|
<video class="chatlog__forwarded-attachment" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else if (attachment.IsAudio)
|
||||||
|
{
|
||||||
|
<audio class="chatlog__forwarded-attachment" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</audio>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__attachment-generic">
|
||||||
|
<svg class="chatlog__attachment-generic-icon">
|
||||||
|
<use href="#attachment-icon"/>
|
||||||
|
</svg>
|
||||||
|
<div class="chatlog__attachment-generic-name">
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
@attachment.FileName
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="chatlog__attachment-generic-size">
|
||||||
|
@attachment.FileSize
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded stickers *@
|
||||||
|
@foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker" title="@sticker.Name">
|
||||||
|
@if (sticker.IsImage)
|
||||||
|
{
|
||||||
|
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||||
|
}
|
||||||
|
else if (sticker.Format == StickerFormat.Lottie)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded timestamp *@
|
||||||
|
<div class="chatlog__forwarded-timestamp">
|
||||||
|
<span title="@FormatDate(message.ForwardedMessage.Timestamp, "f")">Originally sent: @FormatDate(message.ForwardedMessage.Timestamp)</span>
|
||||||
|
@if (message.ForwardedMessage.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<span title="@FormatDate(message.ForwardedMessage.EditedTimestamp.Value, "f")"> (edited)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Attachments *@
|
@* Attachments *@
|
||||||
@foreach (var attachment in message.Attachments)
|
@foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
@@ -388,7 +477,7 @@
|
|||||||
<div class="chatlog__embed-author-container">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
@@ -424,9 +513,11 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Video player *@
|
@* Video thumbnail *@
|
||||||
<div class="chatlog__embed-youtube-container">
|
<div class="chatlog__embed-youtube-container">
|
||||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
<a href="@youTubeVideoEmbed.Url">
|
||||||
|
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,9 +532,12 @@
|
|||||||
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedImageCanonicalUrl =
|
||||||
|
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
||||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy">
|
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -457,8 +551,10 @@
|
|||||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
|
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
|
||||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,8 +566,10 @@
|
|||||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
|
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
|
||||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +597,7 @@
|
|||||||
<div class="chatlog__embed-author-container">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
@@ -574,7 +672,7 @@
|
|||||||
{
|
{
|
||||||
<div class="chatlog__embed-thumbnail-container">
|
<div class="chatlog__embed-thumbnail-container">
|
||||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -590,7 +688,7 @@
|
|||||||
{
|
{
|
||||||
<div class="chatlog__embed-image-container">
|
<div class="chatlog__embed-image-container">
|
||||||
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -605,7 +703,7 @@
|
|||||||
@* Footer icon *@
|
@* Footer icon *@
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
<span class="chatlog__embed-footer-text">
|
<span class="chatlog__embed-footer-text">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
|
// Channel/thread mentions may reference threads that are not preloaded,
|
||||||
|
// so we resolve them on demand.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||||
|
if (mention.TargetId is not null)
|
||||||
|
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||||
|
|
||||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
|
|||||||
@@ -7,40 +7,38 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
|
|
||||||
internal static class PlainTextMessageExtensions
|
internal static class PlainTextMessageExtensions
|
||||||
{
|
{
|
||||||
public static string GetFallbackContent(this Message message) =>
|
extension(Message message)
|
||||||
message.Kind switch
|
{
|
||||||
{
|
public string GetFallbackContent() =>
|
||||||
MessageKind.RecipientAdd
|
message.Kind switch
|
||||||
=> message.MentionedUsers.Any()
|
{
|
||||||
|
MessageKind.RecipientAdd => 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)
|
.Pipe(t => t.TotalMinutes)
|
||||||
.Pipe(t => t.TotalMinutes)
|
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"} minutes.",
|
||||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
|
||||||
} 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,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -173,7 +174,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
await _writer.WriteLineAsync("{Reactions}");
|
await _writer.WriteLineAsync("{Reactions}");
|
||||||
|
|
||||||
foreach (var (reaction, i) in reactions.WithIndex())
|
foreach (var (i, reaction) in reactions.Index())
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -224,6 +225,31 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteForwardedMessageAsync(
|
||||||
|
MessageSnapshot forwardedMessage,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync("{Forwarded Message}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(forwardedMessage.Content))
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
$"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}"
|
||||||
|
);
|
||||||
|
|
||||||
|
await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken);
|
||||||
|
await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken);
|
||||||
|
await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken);
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WriteMessageAsync(
|
public override async ValueTask WriteMessageAsync(
|
||||||
Message message,
|
Message message,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -248,6 +274,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
|
// Forwarded message content
|
||||||
|
if (message.ForwardedMessage is not null)
|
||||||
|
{
|
||||||
|
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Attachments, embeds, reactions, etc.
|
// Attachments, embeds, reactions, etc.
|
||||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||||
|
|||||||
@@ -304,6 +304,52 @@
|
|||||||
unicode-bidi: bidi-override;
|
unicode-bidi: bidi-override;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: @Themed("#b5b6b8", "#5f5f60");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-content {
|
||||||
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-attachments {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-attachment {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-timestamp {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__system-notification-icon {
|
.chatlog__system-notification-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -711,9 +757,11 @@
|
|||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-youtube {
|
.chatlog__embed-youtube-thumbnail {
|
||||||
border: 0;
|
max-width: 400px;
|
||||||
|
max-height: 225px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__sticker {
|
.chatlog__sticker {
|
||||||
@@ -997,6 +1045,9 @@
|
|||||||
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
||||||
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="forward-icon" viewBox="0 0 24 24">
|
||||||
|
<path fill="#b9bbbe" d="M13 4L21 12L13 20V15C8 15 4 17 1 22C2 16 6 10 13 9V4Z" />
|
||||||
|
</symbol>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user