mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
179 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5632d6c386 | |||
| f4d1e630f7 | |||
| 799dd1ad81 | |||
| 64a4ba296e | |||
| f0e99765f7 | |||
| 45629c9d7f | |||
| 2aec84884b | |||
| 6258394fc0 | |||
| e6472543e0 | |||
| ee163d6de7 | |||
| 5838c0546e | |||
| 07a02773be | |||
| 0a623b24d1 | |||
| da4f5ea74a | |||
| 34b5ccc75b | |||
| 7456f0fe3a | |||
| 757daa7a60 | |||
| 9590d4461a | |||
| a27fc3e72e | |||
| f10bba63e6 | |||
| b76b42549b | |||
| ae70fe9741 | |||
| af3ec8c299 | |||
| f1673cde77 | |||
| ed33d19bc0 | |||
| 80e9ad75ba | |||
| 295f4cf9a2 | |||
| 1f189e5206 | |||
| df683b3b41 | |||
| d467948d0b | |||
| f6166764e9 | |||
| 7ee2763d4b | |||
| eb99ab29ed | |||
| 5c61306231 | |||
| 2e4601d378 | |||
| cc703cc860 | |||
| 661f82c478 | |||
| b9f19f7003 | |||
| e794d7c833 | |||
| 5a26ba0b5a | |||
| 1181a35f08 | |||
| 4a62f9c34f | |||
| b6e1f92989 | |||
| 0d3d2ddcf0 | |||
| 9afecd47bd | |||
| 5cec162b4e | |||
| 6647f90ced | |||
| b9a3001c11 | |||
| dadfe5b8ef | |||
| 4f29fa63d0 | |||
| 54b0174bde | |||
| 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 |
+2
-2
@@ -17,7 +17,7 @@ Note the `:stable` tag. DiscordChatExporter images are tagged according to the f
|
||||
|
||||
- `stable` — latest stable version release. This tag is updated with each release of a new project version. Recommended for personal use.
|
||||
- `x.y.z` (e.g. `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
|
||||
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `master` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
|
||||
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `prime` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
|
||||
|
||||
You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordchatexporter/tags?ordering=name).
|
||||
|
||||
@@ -57,7 +57,7 @@ You can also use the current working directory as the output directory by specif
|
||||
- `-v $PWD:/out` in Bash
|
||||
- `-v $pwd.Path:/out` in PowerShell
|
||||
|
||||
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
|
||||
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
|
||||
|
||||
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
|
||||
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
TOKEN=tokenhere
|
||||
CHANNELID=channelhere
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
TOKEN=tokenhere
|
||||
CHANNELID=channelhere
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
1. Open a text editor such as Notepad and paste:
|
||||
|
||||
```console
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
$TOKEN = "tokenhere"
|
||||
$CHANNEL = "channelhere"
|
||||
|
||||
+56
-11
@@ -1,10 +1,11 @@
|
||||
# Obtaining Token and Channel IDs
|
||||
|
||||
> **Warning**:
|
||||
> **Do not share your token!**
|
||||
> 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.
|
||||
> [!WARNING]
|
||||
> **Do not share your token!** A token gives full access to an account.
|
||||
> 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.
|
||||
|
||||
@@ -26,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
3. Type
|
||||
|
||||
```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?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```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?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
|
||||
```
|
||||
|
||||
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.
|
||||
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)
|
||||
2. Open your Application's settings
|
||||
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**:
|
||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
||||
<img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />
|
||||
|
||||

|
||||
### Step 4 - Copy the bot token
|
||||
|
||||
If you don't have a bot token yet or if you've lost it, follow these steps to reset it:
|
||||
|
||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||
2. Open your Application's settings
|
||||
3. Navigate to the **Bot** section on the left
|
||||
4. Under **Token** click **Reset Token**
|
||||
5. Click **Yes, do it!** and authenticate to confirm
|
||||
|
||||
> **Tip**:
|
||||
> As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.
|
||||
|
||||
> [!WARNING]
|
||||
> 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?
|
||||
|
||||
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?
|
||||
|
||||
@@ -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`
|
||||
|
||||
## 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).
|
||||
|
||||
+11
-12
@@ -29,21 +29,20 @@ Type the following command in your terminal of choice, then press ENTER to run i
|
||||
> **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).
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Description |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| export | Exports a channel |
|
||||
| exportdm | Exports all direct message channels |
|
||||
| exportguild | Exports all channels within the specified server |
|
||||
| exportall | Exports all accessible channels |
|
||||
| channels | Outputs the list of channels in the given server |
|
||||
| dm | Outputs the list of direct message channels |
|
||||
| guilds | Outputs the list of accessible servers |
|
||||
| guide | Explains how to obtain token, server, and channel ID |
|
||||
| Command | Description |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| export | Exports a channel |
|
||||
| exportdm | Exports all direct message channels |
|
||||
| exportguild | Exports all channels within the specified server |
|
||||
| exportall | Exports all accessible channels |
|
||||
| channels | Outputs the list of channels in the given server |
|
||||
| dm | Outputs the list of direct message channels |
|
||||
| guilds | Outputs the list of accessible servers |
|
||||
| guide | Explains how to obtain token, server, and channel ID |
|
||||
|
||||
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
|
||||
|
||||
@@ -222,7 +221,7 @@ Use `--filter` to filter what messages are included in the export.
|
||||
./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).
|
||||
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs/Message-filters.md).
|
||||
|
||||
### Export channels from a specific server
|
||||
|
||||
|
||||
+17
-2
@@ -10,13 +10,28 @@
|
||||
|
||||
### 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
|
||||
|
||||
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!**
|
||||
> A token gives full access to an account, treat it like a password.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠ Feature request
|
||||
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
|
||||
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
|
||||
url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
|
||||
about: Sorry, but this project is in maintenance mode and currently doesn't accept new feature requests.
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
about: Find usage guides and frequently asked questions.
|
||||
- name: 🗨 Discussions
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
changelog:
|
||||
exclude:
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
|
||||
categories:
|
||||
- title: Enhancements
|
||||
labels:
|
||||
- enhancement
|
||||
|
||||
- title: Bugs
|
||||
labels:
|
||||
- bug
|
||||
|
||||
- title: Other
|
||||
labels:
|
||||
- "*"
|
||||
@@ -4,12 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
|
||||
jobs:
|
||||
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build image
|
||||
run: >
|
||||
@@ -36,16 +36,16 @@ jobs:
|
||||
--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
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
- name: Upload image
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: DiscordChatExporter.Cli.Docker
|
||||
path: DiscordChatExporter.Cli.Docker.tar
|
||||
if-no-files-found: error
|
||||
|
||||
deploy:
|
||||
# Deploy to DockerHub only on tag push or master branch push
|
||||
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'master' }}
|
||||
# Deploy to DockerHub only on tag push or prime branch push
|
||||
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'prime' }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -55,10 +55,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
run: >
|
||||
|
||||
+34
-21
@@ -4,12 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||
@@ -26,18 +26,29 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
|
||||
# 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
|
||||
id: verify
|
||||
run: >
|
||||
dotnet build
|
||||
-t:CSharpierFormat
|
||||
--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:
|
||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||
@@ -51,12 +62,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -72,7 +81,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -108,25 +117,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
|
||||
- name: Publish app
|
||||
run: >
|
||||
dotnet publish ${{ matrix.app }}
|
||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
-p:CSharpier_Bypass=true
|
||||
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||
--output ${{ matrix.app }}/bin/publish/
|
||||
--configuration Release
|
||||
--runtime ${{ matrix.rid }}
|
||||
--self-contained
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
@@ -190,15 +199,19 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
||||
- name: Download app binaries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/
|
||||
|
||||
- name: Set permissions
|
||||
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
|
||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||
@@ -223,7 +236,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Notify Discord
|
||||
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
|
||||
uses: tyrrrz/action-http-request@110f1a0f0f7e91c3b2de349539249f4573c243fa # 1.1.5
|
||||
with:
|
||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
method: POST
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ bin/
|
||||
obj/
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
TestResults/
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>999.9.9-dev</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.8.0" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||
<PackageVersion Include="Avalonia" Version="12.0.4" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageVersion Include="CliFx" Version="3.0.0" />
|
||||
<PackageVersion Include="Cogwheel" Version="2.1.1" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
|
||||
<PackageVersion Include="Deorcify" Version="1.1.0" />
|
||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" />
|
||||
<PackageVersion Include="Gress" Version="2.2.0" />
|
||||
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.2.0" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
||||
<PackageVersion
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="10.0.8"
|
||||
/>
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
||||
<PackageVersion Include="PowerKit" Version="1.2.0" />
|
||||
<PackageVersion Include="RazorBlade" Version="1.0.0" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
|
||||
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
|
||||
<PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="YoutubeExplode" Version="6.6.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
@@ -11,22 +10,22 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||
<ProjectReference Include="../DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Infra;
|
||||
|
||||
@@ -16,6 +16,8 @@ public static class ChannelIds
|
||||
|
||||
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 MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# DiscordChatExporter Tests
|
||||
# DiscordChatExporter Tests
|
||||
|
||||
This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios.
|
||||
In order to run these tests locally, you need to join the test server and configure your authentication token.
|
||||
@@ -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>`
|
||||
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.
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using PowerKit;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -146,4 +146,31 @@ public class DateRangeSpecs
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using PowerKit;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using PowerKit;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -44,4 +50,41 @@ public class HtmlContentSpecs
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using FluentAssertions;
|
||||
using PowerKit.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||
// Check that the YouTube video thumbnail image exists with the correct video ID
|
||||
var youtubeThumbnailSrc = message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.WhereNotNull()
|
||||
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
|
||||
|
||||
youtubeThumbnailSrc.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using PowerKit.Extensions;
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
@@ -8,6 +8,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using PowerKit;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using PowerKit.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -19,8 +20,16 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.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]
|
||||
@@ -33,8 +42,11 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Short time timestamp: 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Short time timestamp: 1:36 PM");
|
||||
message
|
||||
.InnerHtml.ReplaceWhiteSpace(' ')
|
||||
.Should()
|
||||
.Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,8 +59,11 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||
message
|
||||
.InnerHtml.ReplaceWhiteSpace(' ')
|
||||
.Should()
|
||||
.Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,8 +76,11 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Short date timestamp: 2/12/2023");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Short date timestamp: 2/12/2023");
|
||||
message
|
||||
.InnerHtml.ReplaceWhiteSpace(' ')
|
||||
.Should()
|
||||
.Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,8 +93,16 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace(' ')
|
||||
.Should()
|
||||
.Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||
|
||||
message
|
||||
.InnerHtml.ReplaceWhiteSpace(' ')
|
||||
.Should()
|
||||
.Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,8 +115,16 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.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]
|
||||
@@ -105,9 +139,14 @@ public class HtmlMarkdownSpecs
|
||||
// Assert
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace(' ')
|
||||
.Should()
|
||||
.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]
|
||||
@@ -120,8 +159,16 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.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]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
@@ -61,4 +61,17 @@ public class HtmlMentionSpecs
|
||||
// Assert
|
||||
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,4 +1,4 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using PowerKit;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -43,4 +49,43 @@ public class JsonContentSpecs
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
@@ -75,4 +75,21 @@ public class JsonMentionSpecs
|
||||
// Assert
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using FluentAssertions;
|
||||
using PowerKit;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -17,7 +17,7 @@ public class PartitioningSpecs
|
||||
public async Task I_can_export_a_channel_with_partitioning_based_on_message_count()
|
||||
{
|
||||
// Arrange
|
||||
using var dir = TempDir.Create();
|
||||
using var dir = TempDirectory.Create();
|
||||
var filePath = Path.Combine(dir.Path, "output.html");
|
||||
|
||||
// Act
|
||||
@@ -38,7 +38,7 @@ public class PartitioningSpecs
|
||||
public async Task I_can_export_a_channel_with_partitioning_based_on_file_size()
|
||||
{
|
||||
// Arrange
|
||||
using var dir = TempDir.Create();
|
||||
using var dir = TempDirectory.Create();
|
||||
var filePath = Path.Combine(dir.Path, "output.html");
|
||||
|
||||
// Act
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using FluentAssertions;
|
||||
using PowerKit.Extensions;
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
@@ -7,6 +7,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using PowerKit;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -17,7 +18,7 @@ public class SelfContainedSpecs
|
||||
public async Task I_can_export_a_channel_and_download_all_referenced_assets()
|
||||
{
|
||||
// Arrange
|
||||
using var dir = TempDir.Create();
|
||||
using var dir = TempDirectory.Create();
|
||||
var filePath = Path.Combine(dir.Path, "output.html");
|
||||
|
||||
// Act
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
internal partial class TempDir(string path) : IDisposable
|
||||
{
|
||||
public string Path { get; } = path;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class TempDir
|
||||
{
|
||||
public static TempDir Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp",
|
||||
Guid.NewGuid().ToString()
|
||||
);
|
||||
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
return new TempDir(dirPath);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
internal partial class TempFile(string path) : IDisposable
|
||||
{
|
||||
public string Path { get; } = path;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(Path);
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class TempFile
|
||||
{
|
||||
public static TempFile Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp"
|
||||
);
|
||||
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
|
||||
return new TempFile(filePath);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"methodDisplayOptions": "all",
|
||||
"methodDisplay": "method"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -- Build
|
||||
# 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.
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.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
|
||||
# we can build the assembly for the correct platform.
|
||||
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
|
||||
COPY favicon.ico .
|
||||
COPY NuGet.config .
|
||||
COPY Directory.Build.props .
|
||||
COPY Directory.Packages.props .
|
||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||
|
||||
@@ -30,7 +31,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
|
||||
|
||||
# -- Run
|
||||
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.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.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
@@ -16,19 +17,29 @@ public abstract class DiscordCommandBase : ICommand
|
||||
EnvironmentVariable = "DISCORD_TOKEN",
|
||||
Description = "Authentication token."
|
||||
)]
|
||||
public required string Token { get; init; }
|
||||
public required string Token { get; set; }
|
||||
|
||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||
[CommandOption(
|
||||
"bot",
|
||||
'b',
|
||||
EnvironmentVariable = "DISCORD_TOKEN_BOT",
|
||||
Description = "This option doesn't do anything. Kept for backwards compatibility."
|
||||
)]
|
||||
public bool IsBotToken { get; init; } = false;
|
||||
public bool IsBotToken { get; set; } = false;
|
||||
|
||||
private DiscordClient? _discordClient;
|
||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
|
||||
[CommandOption(
|
||||
"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; set; } = true;
|
||||
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected DiscordClient Discord =>
|
||||
field ??= new DiscordClient(
|
||||
Token,
|
||||
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
||||
);
|
||||
|
||||
public virtual ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Gress;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
public abstract class ExportCommandBase : DiscordCommandBase
|
||||
{
|
||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption(
|
||||
"output",
|
||||
'o',
|
||||
@@ -35,26 +34,26 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public string OutputPath
|
||||
{
|
||||
get => _outputPath;
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => _outputPath = Path.GetFullPath(value);
|
||||
}
|
||||
set => field = Path.GetFullPath(value);
|
||||
} = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption("format", 'f', Description = "Export format.")]
|
||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
[CommandOption(
|
||||
"after",
|
||||
Description = "Only include messages sent after this date or message ID."
|
||||
)]
|
||||
public Snowflake? After { get; init; }
|
||||
public Snowflake? After { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"before",
|
||||
Description = "Only include messages sent before this date or message ID."
|
||||
)]
|
||||
public Snowflake? Before { get; init; }
|
||||
public Snowflake? Before { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"partition",
|
||||
@@ -62,40 +61,51 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
Description = "Split the output into partitions, each limited to the specified "
|
||||
+ "number of messages (e.g. '100') or file size (e.g. '10mb')."
|
||||
)]
|
||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||
public PartitionLimit PartitionLimit { get; set; } = PartitionLimit.Null;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeInputConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
|
||||
|
||||
[CommandOption(
|
||||
"filter",
|
||||
Description = "Only include messages that satisfy this filter. "
|
||||
+ "See the documentation for more info."
|
||||
)]
|
||||
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
||||
public MessageFilter MessageFilter { get; set; } = MessageFilter.Null;
|
||||
|
||||
[CommandOption(
|
||||
"parallel",
|
||||
Description = "Limits how many channels can be exported in parallel."
|
||||
)]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
public int ParallelLimit { get; set; } = 1;
|
||||
|
||||
[CommandOption(
|
||||
"reverse",
|
||||
Description = "Export messages in reverse chronological order (newest first)."
|
||||
)]
|
||||
public bool IsReverseMessageOrder { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"markdown",
|
||||
Description = "Process markdown, mentions, and other special tokens."
|
||||
)]
|
||||
public bool ShouldFormatMarkdown { get; init; } = true;
|
||||
public bool ShouldFormatMarkdown { get; set; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"media",
|
||||
Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
|
||||
)]
|
||||
public bool ShouldDownloadAssets { get; init; }
|
||||
public bool ShouldDownloadAssets { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"reuse-media",
|
||||
Description = "Reuse previously downloaded assets to avoid redundant requests."
|
||||
)]
|
||||
public bool ShouldReuseAssets { get; init; } = false;
|
||||
|
||||
private readonly string? _assetsDirPath;
|
||||
public bool ShouldReuseAssets { get; set; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"media-dir",
|
||||
@@ -104,43 +114,44 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public string? AssetsDirPath
|
||||
{
|
||||
get => _assetsDirPath;
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
|
||||
set => field = value is not null ? Path.GetFullPath(value) : null;
|
||||
}
|
||||
|
||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||
[CommandOption(
|
||||
"dateformat",
|
||||
Description = "This option doesn't do anything. Kept for backwards compatibility."
|
||||
)]
|
||||
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
|
||||
public string DateFormat { get; set; } = "MM/dd/yyyy h:mm tt";
|
||||
|
||||
[CommandOption(
|
||||
"locale",
|
||||
Description = "Locale to use when formatting dates and numbers. "
|
||||
+ "If not specified, the default system locale will be used."
|
||||
)]
|
||||
public string? Locale { get; init; }
|
||||
public string? Locale { get; set; }
|
||||
|
||||
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
|
||||
public bool IsUtcNormalizationEnabled { get; init; } = false;
|
||||
public bool IsUtcNormalizationEnabled { get; set; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"fuck-russia",
|
||||
EnvironmentVariable = "FUCK_RUSSIA",
|
||||
Description = "Don't print the Support Ukraine message to the console.",
|
||||
// Use a converter to accept '1' as 'true' to reuse the existing environment variable
|
||||
Converter = typeof(TruthyBooleanBindingConverter)
|
||||
Converter = typeof(TruthyBooleanInputConverter)
|
||||
)]
|
||||
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
||||
public bool IsUkraineSupportMessageDisabled { get; set; } = false;
|
||||
|
||||
private ChannelExporter? _channelExporter;
|
||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
|
||||
|
||||
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
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||
@@ -154,13 +165,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
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.
|
||||
// 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/917
|
||||
var isValidOutputPath =
|
||||
// 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
|
||||
|| OutputPath.Contains('%')
|
||||
// Otherwise, require an existing directory or an unambiguous directory path
|
||||
@@ -177,10 +228,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
}
|
||||
|
||||
// Export
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
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
|
||||
.CreateProgressTicker()
|
||||
.HideCompleted(
|
||||
@@ -192,7 +243,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
await Parallel.ForEachAsync(
|
||||
channels,
|
||||
unwrappedChannels,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
||||
@@ -221,6 +272,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
Before,
|
||||
PartitionLimit,
|
||||
MessageFilter,
|
||||
IsReverseMessageOrder,
|
||||
ShouldFormatMarkdown,
|
||||
ShouldDownloadAssets,
|
||||
ShouldReuseAssets,
|
||||
@@ -236,6 +288,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
}
|
||||
);
|
||||
}
|
||||
catch (ChannelEmptyException ex)
|
||||
{
|
||||
warningsByChannel[channel] = ex.Message;
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||
{
|
||||
errorsByChannel[channel] = ex.Message;
|
||||
@@ -248,10 +304,32 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
{
|
||||
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
|
||||
if (errorsByChannel.Any())
|
||||
{
|
||||
@@ -259,16 +337,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
{
|
||||
await console.Error.WriteLineAsync(
|
||||
$"Failed to export {errorsByChannel.Count} the following channel(s):"
|
||||
);
|
||||
await console.Error.WriteLineAsync("Failed to export the following channel(s):");
|
||||
}
|
||||
|
||||
foreach (var (channel, error) in errorsByChannel)
|
||||
foreach (var (channel, message) in errorsByChannel)
|
||||
{
|
||||
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
await console.Error.WriteLineAsync(error);
|
||||
await console.Error.WriteLineAsync(message);
|
||||
}
|
||||
|
||||
await console.Error.WriteLineAsync();
|
||||
@@ -276,48 +352,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
|
||||
// Fail the command only if ALL channels failed to export.
|
||||
// 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.");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
// Support Ukraine callout
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using CliFx.Extensibility;
|
||||
using System;
|
||||
using CliFx.Activation;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Converters;
|
||||
|
||||
internal class ThreadInclusionModeBindingConverter : BindingConverter<ThreadInclusionMode>
|
||||
internal class ThreadInclusionModeInputConverter : ScalarInputConverter<ThreadInclusionMode>
|
||||
{
|
||||
public override ThreadInclusionMode Convert(string? rawValue)
|
||||
{
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
using System.Globalization;
|
||||
using CliFx.Extensibility;
|
||||
using System.Globalization;
|
||||
using CliFx.Activation;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Converters;
|
||||
|
||||
internal class TruthyBooleanBindingConverter : BindingConverter<bool>
|
||||
internal class TruthyBooleanInputConverter : ScalarInputConverter<bool>
|
||||
{
|
||||
public override bool Convert(string? rawValue)
|
||||
{
|
||||
@@ -2,11 +2,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Dump;
|
||||
@@ -16,30 +14,23 @@ using Spectre.Console;
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("exportall", Description = "Exports all accessible channels.")]
|
||||
public class ExportAllCommand : ExportCommandBase
|
||||
public partial class ExportAllCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
||||
public bool IncludeDirectChannels { get; init; } = true;
|
||||
public bool IncludeDirectChannels { get; set; } = true;
|
||||
|
||||
[CommandOption("include-guilds", Description = "Include server channels.")]
|
||||
public bool IncludeGuildChannels { get; init; } = true;
|
||||
public bool IncludeGuildChannels { get; set; } = true;
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
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 bool IncludeVoiceChannels { get; set; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"data-package",
|
||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||
+ "If provided, only channels referenced in the dump will be exported."
|
||||
)]
|
||||
public string? DataPackageFilePath { get; init; }
|
||||
public string? DataPackageFilePath { get; set; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
);
|
||||
|
||||
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
|
||||
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
channels.RemoveAll(c => c.IsGuild);
|
||||
if (!IncludeVoiceChannels)
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("export", Description = "Exports one or multiple channels.")]
|
||||
public class ExportChannelsCommand : ExportCommandBase
|
||||
public partial class ExportChannelsCommand : ExportCommandBase
|
||||
{
|
||||
// TODO: change this to plural (breaking change)
|
||||
[CommandOption(
|
||||
@@ -17,11 +19,45 @@ public class ExportChannelsCommand : ExportCommandBase
|
||||
Description = "Channel ID(s). "
|
||||
+ "If provided with category ID(s), all channels inside those categories will be exported."
|
||||
)]
|
||||
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
|
||||
public required IReadOnlyList<Snowflake> ChannelIds { get; set; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("exportdm", Description = "Exports all direct message channels.")]
|
||||
public class ExportDirectMessagesCommand : ExportCommandBase
|
||||
public partial class ExportDirectMessagesCommand : ExportCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
@@ -13,20 +11,13 @@ using Spectre.Console;
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("exportguild", Description = "Exports all channels within the specified server.")]
|
||||
public class ExportGuildCommand : ExportCommandBase
|
||||
public partial class ExportGuildCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', Description = "Server ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
public required Snowflake GuildId { get; set; }
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
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 bool IncludeVoiceChannels { get; set; } = true;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var channels = new List<Channel>();
|
||||
|
||||
// Regular channels
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
|
||||
var fetchedChannelsCount = 0;
|
||||
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("channels", Description = "Get the list of channels in a server.")]
|
||||
public class GetChannelsCommand : DiscordCommandBase
|
||||
public partial class GetChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', Description = "Server ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
public required Snowflake GuildId { get; set; }
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
public bool IncludeVoiceChannels { get; set; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
Converter = typeof(ThreadInclusionModeInputConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("dm", Description = "Gets the list of all direct message channels.")]
|
||||
public class GetDirectChannelsCommand : DiscordCommandBase
|
||||
public partial class GetDirectChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("guilds", Description = "Gets the list of accessible servers.")]
|
||||
public class GetGuildsCommand : DiscordCommandBase
|
||||
public partial class GetGuildsCommand : DiscordCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("guide", Description = "Explains how to obtain the token, server or channel ID.")]
|
||||
public class GuideCommand : ICommand
|
||||
public partial class GuideCommand : ICommand
|
||||
{
|
||||
public ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -34,10 +34,17 @@ public class GuideCommand : ICommand
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
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(" 2. Open your application's settings");
|
||||
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(
|
||||
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
||||
);
|
||||
@@ -67,7 +74,7 @@ public class GuideCommand : ICommand
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||
{
|
||||
console.Output.WriteLine(
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Cli.Commands.Shared;
|
||||
namespace DiscordChatExporter.Cli.Commands.Shared;
|
||||
|
||||
public enum ThreadInclusionMode
|
||||
{
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
<ApplicationIcon>../favicon.ico</ApplicationIcon>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
<PackageReference Include="CliFx" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
<ProjectReference Include="../DiscordChatExporter.Core/DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
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;
|
||||
|
||||
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>()
|
||||
await new CommandLineApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using Spectre.Console;
|
||||
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
||||
|
||||
internal static class ConsoleExtensions
|
||||
{
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
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()
|
||||
extension(IConsole console)
|
||||
{
|
||||
public IAnsiConsole CreateAnsiConsole() =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
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(
|
||||
this ProgressContext context,
|
||||
string description,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
@@ -33,9 +33,9 @@ public partial record Channel(
|
||||
|
||||
public bool IsThread { get; } =
|
||||
Kind
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
|
||||
public bool IsEmpty { get; } = LastMessageId is null;
|
||||
|
||||
@@ -69,7 +69,8 @@ public partial record Channel
|
||||
var guildId =
|
||||
json.GetPropertyOrNull("guild_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
||||
?.Pipe(Snowflake.Parse)
|
||||
?? Guild.DirectMessages.Id;
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
@@ -78,6 +79,7 @@ public partial record Channel
|
||||
?? json.GetPropertyOrNull("recipients")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(User.Parse)
|
||||
.OrderBy(u => u.Id)
|
||||
.Select(u => u.DisplayName)
|
||||
.Pipe(s => string.Join(", ", s))
|
||||
// Fallback
|
||||
@@ -95,7 +97,8 @@ public partial record Channel
|
||||
var isArchived =
|
||||
json.GetPropertyOrNull("thread_metadata")
|
||||
?.GetPropertyOrNull("archived")
|
||||
?.GetBooleanOrNull() ?? false;
|
||||
?.GetBooleanOrNull()
|
||||
?? false;
|
||||
|
||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
public enum ChannelKind
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||
|
||||
public interface IHasId
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
@@ -45,7 +45,9 @@ public partial record Embed
|
||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
||||
json.GetPropertyOrNull("type")
|
||||
?.GetStringOrNull()
|
||||
.Pipe(s => Enum.ParseOrNull<EmbedKind>(s, true))
|
||||
?? EmbedKind.Rich;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
@@ -54,7 +56,7 @@ public partial record Embed
|
||||
var color = json.GetPropertyOrNull("color")
|
||||
?.GetInt32OrNull()
|
||||
?.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha();
|
||||
.WithFullAlpha();
|
||||
|
||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
||||
@@ -63,7 +65,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("fields")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(EmbedField.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||
|
||||
@@ -78,7 +81,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("image")
|
||||
?.Pipe(EmbedImage.Parse)
|
||||
.ToSingletonEnumerable()
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||
{
|
||||
public string Url { get; } = $"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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
@@ -25,7 +25,8 @@ public partial record Guild
|
||||
var iconUrl =
|
||||
json.GetPropertyOrNull("icon")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
|
||||
?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
@@ -32,7 +32,8 @@ public partial record Member
|
||||
?.EnumerateArray()
|
||||
.Select(j => j.GetNonWhiteSpaceString())
|
||||
.Select(Snowflake.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var avatarUrl = guildId is not null
|
||||
? json.GetPropertyOrNull("avatar")
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
@@ -27,9 +27,16 @@ public partial record Message(
|
||||
IReadOnlyList<User> MentionedUsers,
|
||||
MessageReference? Reference,
|
||||
Message? ReferencedMessage,
|
||||
MessageSnapshot? ForwardedMessage,
|
||||
Interaction? Interaction
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
|
||||
public bool IsSystemNotification { get; } =
|
||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||
|
||||
@@ -38,11 +45,7 @@ public partial record Message(
|
||||
// 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 { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
|
||||
|
||||
public IEnumerable<User> GetReferencedUsers()
|
||||
{
|
||||
@@ -143,7 +146,8 @@ public partial record Message
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var embeds = NormalizeEmbeds(
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
@@ -154,13 +158,15 @@ public partial record Message
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var reactions =
|
||||
json.GetPropertyOrNull("reactions")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Reaction.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var mentionedUsers =
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||
@@ -168,7 +174,17 @@ public partial record Message
|
||||
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||
?.Pipe(MessageReference.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);
|
||||
|
||||
return new Message(
|
||||
@@ -188,6 +204,7 @@ public partial record Message
|
||||
mentionedUsers,
|
||||
messageReference,
|
||||
referencedMessage,
|
||||
forwardedMessage,
|
||||
interaction
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
public enum MessageKind
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// 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)
|
||||
{
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
|
||||
?? MessageReferenceKind.Default;
|
||||
|
||||
var messageId = json.GetPropertyOrNull("message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using System.Text.Json;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Drawing;
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
@@ -18,7 +18,7 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
|
||||
var color = json.GetPropertyOrNull("color")
|
||||
?.GetInt32OrNull()
|
||||
?.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha()
|
||||
.WithFullAlpha()
|
||||
.NullIf(c => c.ToRgb() <= 0);
|
||||
|
||||
return new Role(id, name, position, color);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
public enum StickerFormat
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@@ -11,14 +11,17 @@ using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Gress;
|
||||
using JsonExtensions.Http;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
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 TokenKind? _resolvedTokenKind;
|
||||
@@ -47,33 +50,41 @@ public class DiscordClient(string token)
|
||||
innerCancellationToken
|
||||
);
|
||||
|
||||
// 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
|
||||
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)
|
||||
// Discord has advisory rate limits (communicated via response headers), but they are typically
|
||||
// way stricter than the actual rate limits enforced by the server.
|
||||
// The user may choose to ignore the advisory rate limits and only retry on hard rate limits,
|
||||
// if they want to prioritize speed over compliance (and safety of their account/bot).
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1021
|
||||
if (rateLimitPreference.IsRespectedFor(tokenKind))
|
||||
{
|
||||
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));
|
||||
var remainingRequestCount = response
|
||||
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||
?.Pipe(s => int.ParseOrNull(s, CultureInfo.InvariantCulture));
|
||||
|
||||
await Task.Delay(delay, innerCancellationToken);
|
||||
var resetAfterDelay = response
|
||||
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||
?.Pipe(s => double.ParseOrNull(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;
|
||||
@@ -148,14 +159,14 @@ public class DiscordClient(string token)
|
||||
|
||||
_ => throw new DiscordChatExporterException(
|
||||
$"""
|
||||
Request to '{url}' failed: {response
|
||||
.StatusCode.ToString()
|
||||
.ToSpaceSeparatedWords()
|
||||
.ToLowerInvariant()}.
|
||||
Response content: {await response.Content.ReadAsStringAsync(
|
||||
cancellationToken
|
||||
)}
|
||||
""",
|
||||
Request to '{url}' failed: {response
|
||||
.StatusCode.ToString()
|
||||
.SeparateWords(' ')
|
||||
.ToLowerInvariant()}.
|
||||
Response content: {await response.Content.ReadAsStringAsync(
|
||||
cancellationToken
|
||||
)}
|
||||
""",
|
||||
true
|
||||
),
|
||||
};
|
||||
@@ -183,6 +194,23 @@ public class DiscordClient(string token)
|
||||
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(
|
||||
Snowflake userId,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -294,184 +322,19 @@ public class DiscordClient(string token)
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
yield break;
|
||||
|
||||
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();
|
||||
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
foreach (
|
||||
var channel in await GetChannelThreadsAsync(
|
||||
channels,
|
||||
includeArchived,
|
||||
before,
|
||||
after,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
// Active threads
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield return channel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,21 +388,229 @@ public class DiscordClient(string token)
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.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
|
||||
// child channel being accessible.
|
||||
// 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(
|
||||
@@ -600,22 +671,10 @@ public class DiscordClient(string token)
|
||||
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.
|
||||
// have the MESSAGE_CONTENT intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (
|
||||
messages.All(m => m.IsEmpty)
|
||||
&& 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
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
@@ -648,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(
|
||||
Snowflake channelId,
|
||||
Snowflake messageId,
|
||||
@@ -688,9 +816,7 @@ public class DiscordClient(string token)
|
||||
count++;
|
||||
}
|
||||
|
||||
// Each batch can contain up to 100 users.
|
||||
// If we got fewer, then it's definitely the last batch.
|
||||
if (count < 100)
|
||||
if (count <= 0)
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -40,17 +41,19 @@ public partial class DataDump
|
||||
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");
|
||||
if (entry is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
// Use case-insensitive search to accommodate for different data dump versions
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
|
||||
var entry =
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
await using var stream = entry.Open();
|
||||
await using var stream = await entry.OpenAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
||||
|
||||
return Parse(document.RootElement);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Dump;
|
||||
namespace DiscordChatExporter.Core.Discord.Dump;
|
||||
|
||||
public record DataDumpChannel(Snowflake Id, string Name);
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user