mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 23:24:39 +02:00
Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d458cd3fd | |||
| 57eb32d9c0 | |||
| 506bc0176b | |||
| 67b31c5f68 | |||
| d9c06bacda | |||
| db45b49af7 | |||
| fc07f8da6c | |||
| b138908eb3 | |||
| 2c4f812d4f | |||
| 324d6bab20 | |||
| e5d5f1c85f | |||
| 49c4b12512 | |||
| 57e2dc9313 | |||
| 07b72b9023 | |||
| f3830247fe | |||
| a8f89ec292 | |||
| 0bdbd44492 | |||
| db23971b08 | |||
| 90bf4975b9 | |||
| c2f9868746 | |||
| ec840e76ae | |||
| b672c30071 | |||
| e752269467 | |||
| 5b1b720503 | |||
| 3dc3e3fa01 | |||
| 001ea7e495 | |||
| 9639b6c550 | |||
| 950658c737 | |||
| 560a069c35 | |||
| 1131f8659d | |||
| 38be44debb | |||
| 4b243f2252 | |||
| a31175b2e8 | |||
| 913255f04f | |||
| 873d055191 | |||
| dabed24c16 | |||
| fdf421df3c | |||
| 2259f19c44 | |||
| 8695942328 | |||
| 84e656ccfc | |||
| 2ba0c3b38e | |||
| 7467f0aeb6 | |||
| e38479d463 | |||
| 06a4b6a8e6 | |||
| cc1ad8b435 | |||
| f8dac2c9d0 | |||
| b8cea0d140 | |||
| 7996a38c5c | |||
| 9d052f9404 | |||
| 3078351670 | |||
| 4f41c4fff3 | |||
| a80ee2943f | |||
| 8612d2c84a | |||
| 57c849d0f8 | |||
| 9c2a26aa82 | |||
| f376028d76 | |||
| da87f3c774 | |||
| 61516feafa | |||
| 8d209cd67a | |||
| c58d7e752b | |||
| fcc1605455 | |||
| f28d662a71 | |||
| a405ab184e | |||
| b861a69799 | |||
| d53b4c825a | |||
| 30678e96e5 | |||
| 2024d3658b | |||
| 9453cac389 | |||
| 504c3c53cd | |||
| 4e358e8e6f | |||
| 5e852235ce | |||
| 612c6d37a4 | |||
| 92cf886eab | |||
| f48af53d72 | |||
| 99d721c63b | |||
| 11414b5937 | |||
| 6c039ebfa6 | |||
| 9cffbbc5ce | |||
| 6f87960513 | |||
| e8f8c47dbd | |||
| 9aba36fce5 | |||
| f29e1d2d9f | |||
| b000a72f81 | |||
| f5318e77b7 | |||
| e171b43c22 | |||
| 23e1850d15 | |||
| 057902f919 | |||
| 565fb53d3b | |||
| edba9f36a7 | |||
| 356b1a6afd | |||
| 70edc8989c | |||
| 2fd7e327a9 | |||
| 9182c3d934 | |||
| 01389b7e28 | |||
| bd0701cb82 | |||
| 4283ef22b1 | |||
| 94ef4b6981 | |||
| 7b72f473fd | |||
| 2463cb5087 | |||
| 2c7986c4e6 | |||
| 5755334492 | |||
| 26c523a3e9 | |||
| 371c7d2b75 | |||
| 62400f305a | |||
| 65a528e7fe | |||
| 47d8163a46 | |||
| 1d9224ebd8 | |||
| ddc7f921bd | |||
| aca94ddccb | |||
| 3661c42df4 | |||
| 005c1786ec | |||
| b6fbf2393e | |||
| 0e49936e38 | |||
| 8d4e6b77c4 | |||
| 6f47fb6a1c | |||
| 6150720b1e | |||
| dcdeea19ab | |||
| ca56d47110 | |||
| 17b548d679 | |||
| 20eeaa58e9 | |||
| 6621c10ae0 | |||
| 75214354ca | |||
| 84d99e8520 | |||
| 4b80eabb73 | |||
| e6830bbd0e | |||
| 97c751f171 | |||
| dc814e08e7 | |||
| 54bf7c204a | |||
| 36c9983c9e | |||
| 361e0489ed | |||
| 77436120a8 | |||
| 2d7ab5411f | |||
| 1ce523b252 | |||
| bb81cf06ae | |||
| f7f6ac9494 | |||
| 481958f4d3 | |||
| 5e3502cbc5 | |||
| 3b03c30865 | |||
| c9e16a7f74 | |||
| f9b6793d47 | |||
| cfbd267d3a | |||
| 2b6f01bf6f | |||
| a44d43eefc | |||
| 0e138bac7d | |||
| 71876febf8 | |||
| 489c786c1e | |||
| eb0c61e173 | |||
| 9341c21963 | |||
| 704c483074 | |||
| 1f0e667085 | |||
| 0b72b73d1d | |||
| 32dd1689d0 | |||
| 2e4d19671a | |||
| 5b4d88ca26 | |||
| 65cc3954b6 | |||
| ffe5544f9c | |||
| bf1ad07a9c | |||
| 338566c1d6 | |||
| 91ea7a443c | |||
| aea96d5eba | |||
| e29f08264c | |||
| ad84ecf6a4 | |||
| ae57a0ff7c | |||
| 1daff4178d | |||
| 3a2b119618 | |||
| 5f27426f44 | |||
| a893ebda68 | |||
| 9221966171 | |||
| 67325b610a | |||
| 3186ce90c8 | |||
| 168d7c8ec9 | |||
| daa1b3f6b3 | |||
| e2780d6666 | |||
| b996215e36 | |||
| 08d9af534b | |||
| bef110a193 | |||
| 12555e576f | |||
| afb4bc67ae | |||
| f80a5fe644 | |||
| b7dca87fb9 | |||
| 8ecc2940b0 | |||
| 407c2bd2ae | |||
| 5b563d4f00 | |||
| ecb615ecf1 | |||
| 7c212ef893 | |||
| 36b4a45f3a | |||
| 51cc132e5d | |||
| cf83cbd89c | |||
| d51d0d4872 | |||
| 2467caac9f | |||
| bd98f4cb6a | |||
| fc7191d74c |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -1,5 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
|
- name: ⚠ Feature request
|
||||||
|
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
|
||||||
|
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
|
||||||
- name: 💬 Discord server
|
- name: 💬 Discord server
|
||||||
url: https://discord.gg/2SUWKFnHSm
|
url: https://discord.gg/2SUWKFnHSm
|
||||||
about: Chat with the project community.
|
about: Chat with the project community.
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
name: ✨ Feature request
|
|
||||||
description: Request a new feature.
|
|
||||||
labels: [enhancement]
|
|
||||||
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
🧐 **Guidelines:**
|
|
||||||
|
|
||||||
- Search through [existing issues](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) first to ensure that this feature has not been requested before.
|
|
||||||
- Write a descriptive title for your issue. Avoid generic or vague titles such as "Some suggestions" or "Ideas for improvement".
|
|
||||||
- Keep your issue focused on one single problem. If you have multiple feature requests, please create separate issues for each of them.
|
|
||||||
- Provide as much context as possible in the details section. Include screenshots, screen recordings, links, references, or anything else you may consider relevant.
|
|
||||||
- If you want to ask a question instead of requesting a feature, please use [discussions](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: Flavor
|
|
||||||
description: Is this feature request related to specific flavor(s) of DiscordChatExporter? If not applicable, leave this field blank.
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- GUI (Graphical User Interface)
|
|
||||||
- CLI (Command Line Interface)
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
attributes:
|
|
||||||
label: Export format
|
|
||||||
description: Is this feature request related to specific export format(s)? If not applicable, leave this field blank.
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- HTML
|
|
||||||
- TXT
|
|
||||||
- JSON
|
|
||||||
- CSV
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
attributes:
|
|
||||||
label: Details
|
|
||||||
description: Clear and thorough explanation of the feature you have in mind.
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Docker CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Get release version
|
|
||||||
id: get_version
|
|
||||||
uses: dawidd6/action-get-tag@v1
|
|
||||||
|
|
||||||
- name: Login
|
|
||||||
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: docker build -t tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }} -t tyrrrz/discordchatexporter:stable .
|
|
||||||
|
|
||||||
- name: Deploy (version)
|
|
||||||
run: docker push tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Deploy (stable)
|
|
||||||
run: docker push tyrrrz/discordchatexporter:stable
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
name: CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Get release version
|
|
||||||
id: get_version
|
|
||||||
uses: dawidd6/action-get-tag@v1
|
|
||||||
|
|
||||||
- name: Install .NET
|
|
||||||
uses: actions/setup-dotnet@v1.7.2
|
|
||||||
with:
|
|
||||||
dotnet-version: 6.0.x
|
|
||||||
|
|
||||||
- name: Build & publish (CLI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Build & publish (GUI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Pack (CLI)
|
|
||||||
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Pack (GUI)
|
|
||||||
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1.0.1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref }}
|
|
||||||
release_name: ${{ github.ref }}
|
|
||||||
body: |
|
|
||||||
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
- name: Upload release asset (CLI)
|
|
||||||
uses: actions/upload-release-asset@v1.0.2
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
|
|
||||||
asset_name: DiscordChatExporter.CLI.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Upload release asset (GUI)
|
|
||||||
uses: actions/upload-release-asset@v1.0.2
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
|
|
||||||
asset_name: DiscordChatExporter.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Notify Discord
|
|
||||||
uses: satak/webrequest-action@v1.2.4
|
|
||||||
with:
|
|
||||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
method: POST
|
|
||||||
headers: |
|
|
||||||
{
|
|
||||||
"ContentType": "application/json; charset=UTF-8"
|
|
||||||
}
|
|
||||||
payload: |
|
|
||||||
{
|
|
||||||
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: Docker CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Login
|
|
||||||
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: docker build -t tyrrrz/discordchatexporter:latest .
|
|
||||||
|
|
||||||
- name: Deploy (latest)
|
|
||||||
run: docker push tyrrrz/discordchatexporter:latest
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Install .NET
|
|
||||||
uses: actions/setup-dotnet@v1.7.2
|
|
||||||
with:
|
|
||||||
dotnet-version: 6.0.x
|
|
||||||
|
|
||||||
- name: Build & test
|
|
||||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
run: dotnet test --configuration Release --logger GitHubActions
|
|
||||||
env:
|
|
||||||
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
|
||||||
DISCORD_TOKEN_BOT: true
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: codecov/codecov-action@v1.0.5
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build & publish (CLI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Build & publish (GUI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Upload build artifacts (CLI)
|
|
||||||
uses: actions/upload-artifact@v1
|
|
||||||
with:
|
|
||||||
name: DiscordChatExporter.CLI
|
|
||||||
path: DiscordChatExporter.Cli/bin/Publish/
|
|
||||||
|
|
||||||
- name: Upload build artifact (GUI)
|
|
||||||
uses: actions/upload-artifact@v1
|
|
||||||
with:
|
|
||||||
name: DiscordChatExporter
|
|
||||||
path: DiscordChatExporter.Gui/bin/Publish/
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: docker
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build container
|
||||||
|
run: docker build -f DiscordChatExporter.Cli.dockerfile .
|
||||||
|
|
||||||
|
deploy-latest:
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref_type == 'branch' && github.ref_name == 'master' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build container
|
||||||
|
run: docker build -f DiscordChatExporter.Cli.dockerfile -t tyrrrz/discordchatexporter:latest .
|
||||||
|
|
||||||
|
- name: Push container
|
||||||
|
run: docker push tyrrrz/discordchatexporter:latest
|
||||||
|
|
||||||
|
deploy-stable:
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Get release version
|
||||||
|
id: get_version
|
||||||
|
uses: dawidd6/action-get-tag@v1
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build container
|
||||||
|
run: docker build -f DiscordChatExporter.Cli.dockerfile -t tyrrrz/discordchatexporter:stable -t tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }} .
|
||||||
|
|
||||||
|
- name: Push container (stable)
|
||||||
|
run: docker push tyrrrz/discordchatexporter:stable
|
||||||
|
|
||||||
|
- name: Push container (${{ steps.get_version.outputs.tag }})
|
||||||
|
run: docker push tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
name: main
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||||
|
DOTNET_NOLOGO: true
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install .NET
|
||||||
|
uses: actions/setup-dotnet@v2
|
||||||
|
with:
|
||||||
|
dotnet-version: 7.0.x
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||||
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
|
run: dotnet test --configuration Release --logger GitHubActions --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||||
|
env:
|
||||||
|
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
||||||
|
DISCORD_TOKEN_BOT: true
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
package:
|
||||||
|
needs: test
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||||
|
DOTNET_NOLOGO: true
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install .NET
|
||||||
|
uses: actions/setup-dotnet@v2
|
||||||
|
with:
|
||||||
|
dotnet-version: 7.0.x
|
||||||
|
|
||||||
|
- name: Publish (CLI)
|
||||||
|
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
|
- name: Publish (GUI)
|
||||||
|
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
|
- name: Upload artifacts (CLI)
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter.Cli
|
||||||
|
path: DiscordChatExporter.Cli/bin/Publish/
|
||||||
|
|
||||||
|
- name: Upload artifacts (GUI)
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter
|
||||||
|
path: DiscordChatExporter.Gui/bin/Publish/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
|
||||||
|
needs: package
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts (CLI)
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter.Cli
|
||||||
|
path: DiscordChatExporter.Cli
|
||||||
|
|
||||||
|
- name: Download artifacts (GUI)
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter
|
||||||
|
path: DiscordChatExporter.Gui
|
||||||
|
|
||||||
|
- name: Create package (CLI)
|
||||||
|
run: Compress-Archive -Path DiscordChatExporter.Cli/* -DestinationPath DiscordChatExporter.Cli.zip -Force
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Create package (GUI)
|
||||||
|
run: Compress-Archive -Path DiscordChatExporter.Gui/* -DestinationPath DiscordChatExporter.Gui.zip -Force
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref }}
|
||||||
|
release_name: ${{ github.ref }}
|
||||||
|
body: |
|
||||||
|
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload release asset (CLI)
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_name: DiscordChatExporter.Cli.zip
|
||||||
|
asset_path: DiscordChatExporter.Cli.zip
|
||||||
|
asset_content_type: application/zip
|
||||||
|
|
||||||
|
- name: Upload release asset (GUI)
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_name: DiscordChatExporter.zip
|
||||||
|
asset_path: DiscordChatExporter.Gui.zip
|
||||||
|
asset_content_type: application/zip
|
||||||
|
|
||||||
|
notify:
|
||||||
|
needs: deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Get release version
|
||||||
|
id: get_version
|
||||||
|
uses: dawidd6/action-get-tag@v1
|
||||||
|
|
||||||
|
- name: Notify Discord
|
||||||
|
uses: satak/webrequest-action@v1.2.4
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
method: POST
|
||||||
|
headers: |
|
||||||
|
{
|
||||||
|
"ContentType": "application/json; charset=UTF-8"
|
||||||
|
}
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*.sln.docstates
|
*.sln.docstates
|
||||||
.idea/
|
.idea/
|
||||||
.vs/
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# Build results
|
# Build results
|
||||||
[Dd]ebug/
|
[Dd]ebug/
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
+108
-1
@@ -1,4 +1,111 @@
|
|||||||
### v2.32 (15-Dec-2021)
|
### v2.37 (08-Jan-2023)
|
||||||
|
|
||||||
|
- Switched from .NET 6.0 to .NET 7.0. If running on Windows, the application should update all required prerequisites automatically. Alternatively, you can download the latest version of the runtime for your system [here](https://dotnet.microsoft.com/download/dotnet/7.0).
|
||||||
|
- Updated rate limit handling to adjust automatically based on the response headers. This may result in exports taking longer than before on bot accounts, but it makes it less likely for Discord to throttle you. Exports with user accounts shouldn't be affected too much by this change.
|
||||||
|
- Optimized snowflake parsing by removing unnecessary checks. (Thanks [@Kuba_Z2](https://github.com/KubaZ2))
|
||||||
|
- [Docker] Optimized the Docker image size by using the Alpine base image and publishing a self-contained distribution. (Thanks [@Velithris](https://github.com/Zireael-N))
|
||||||
|
- Fixed an issue where exporting a channel whose name ends with a dot would fail on Windows with default output path. The dots are now trimmed in file and directory names to match the behavior of Windows Explorer.
|
||||||
|
|
||||||
|
### v2.36.4 (29-Oct-2022)
|
||||||
|
|
||||||
|
- Changed all mentions of "media" in the context of "download media" or "reuse media" to "assets". CLI options will retain their existing names for backwards compatibility.
|
||||||
|
- [HTML] Fixed an issue which prevented emoji used inside message content from being downloaded when "download assets" option is enabled. (Thanks [@Roberto Blázquez](https://github.com/xBaank))
|
||||||
|
|
||||||
|
### v2.36.3 (21-Oct-2022)
|
||||||
|
|
||||||
|
- [GUI] Fixed an issue where opening a dialog did not prevent user interactions with background UI elements using keyboard. This sometimes caused the application to crash in weird ways.
|
||||||
|
- [Docker] Fixed an issue where DiscordChatExporter was running as root inside the container, making the exported files inaccessible to the host. (Thanks [@Benjamin Just](https://github.com/BamButz))
|
||||||
|
|
||||||
|
### v2.36.2 (08-Oct-2022)
|
||||||
|
|
||||||
|
- Removed the message content intent check because the heuristics turned out to be not reliable enough.
|
||||||
|
- Fixed an issue where the runtime manifest included the wrong target runtime version. This resulted in the application failing to launch for some users when upgrading from earlier versions.
|
||||||
|
|
||||||
|
### v2.36.1 (24-Sep-2022)
|
||||||
|
|
||||||
|
- Added a check which will trigger an error if the provided bot account does not have the message content intent enabled. Note, however, that this check is based on heuristics which may result in false negatives.
|
||||||
|
- Fixed an issue where certain transient HTTP errors were not retried.
|
||||||
|
- Fixed an issue which caused the export process to fail with the `IndexOutOfRangeException` exception on certain automated messages.
|
||||||
|
- Fixed an issue which caused the export process to fail on unrecognized embed types.
|
||||||
|
|
||||||
|
### v2.36 (16-Sep-2022)
|
||||||
|
|
||||||
|
- [HTML] Added support for rendering GIFV embeds. They will now render as videos that automatically play when you hover your mouse over them. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
|
||||||
|
- [HTML] Added support for rendering system notification messages, such as when a user joins a server or when a messages gets pinned. Previously, such messages were rendered as regular text messages, but now they look similar to how they're presented in the Discord client. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
|
||||||
|
- [CLI] Improved the wording of the error message that's shown when exporting multiple channels and the provided output path is ambiguous. It now mentions that you can resolve the ambiguity by adding a slash at the end of the path.
|
||||||
|
|
||||||
|
### v2.35.2 (08-Sep-2022)
|
||||||
|
|
||||||
|
- Updated usage guide to mention the need to enable "Message Content Intent" to use a bot as a medium for exporting messages.
|
||||||
|
- [CLI] Fixed an issue where passing an existing directory as the output path failed when exporting multiple channels (for example, when running `exportdm`). An ambiguous output path will now always be treated as a directory path if that directory already exists. If it doesn't exist, it will be treated as a file path, matching the previous behavior.
|
||||||
|
|
||||||
|
### v2.35.1 (26-Aug-2022)
|
||||||
|
|
||||||
|
- Added `~` character as an alias for `-` when negating a message filter. This should make it easier to use negated filters from CLI where the dash character already has other meanings.
|
||||||
|
- Extended retry logic to encompass all server-side errors from Discord API, instead of only `500 Internal Server Error`.
|
||||||
|
- [CLI] Attempt to export multiple channels will now fail if provided with an output path that is neither a directory, nor a file name template. Previously, this resulted in all channels being exported to the same file, overwriting each other.
|
||||||
|
- Fixed an issue where a text message filter didn't work if it started with a character that's also the first character in one of the named filters (e.g. `from` or `mentions`).
|
||||||
|
- Fixed an issue where a text message filter didn't work correctly if it was enclosed in parentheses.
|
||||||
|
- Fixed an issue where categories were displayed alongside regular channels in the channel list, both in GUI and in CLI (when using the `guild` command).
|
||||||
|
- [CLI] Fixed an issue where an output path starting with a tilde on Linux was not expanded properly into a full path. (Thanks [@TSRBerry](https://github.com/TSRBerry))
|
||||||
|
|
||||||
|
### v2.35 (02-Jul-2022)
|
||||||
|
|
||||||
|
- Added support for exporting text chat in voice channels. These channels are now also displayed when browsing in the GUI and in the CLI when executing the `channels` command.
|
||||||
|
- [GUI] Direct message channels are now sorted by the timestamp of their last message, similarly to how they appear in the Discord client.
|
||||||
|
- [GUI] Removed artificial grouping of direct message channels into "Private" and "Group".
|
||||||
|
- [CLI] Added runtime bootstrapper capabilities to the CLI flavor of DiscordChatExporter on Windows. Now, if you attempt to run the application without the required prerequisites installed, you will be provided with an option to install them automatically.
|
||||||
|
- [CLI] Fixed outdated information in the usage guide. (Thanks [@Clint Herron](https://github.com/HanClinto))
|
||||||
|
- [CLI] Added whitespace padding to the output of `guilds`, `channels`, and `dm` to make the tables look more uniform.
|
||||||
|
- [HTML] Added hyperlinks to message timestamps, which allows you to quickly copy a link to a specific message in the export. (Thanks [@Philipp C. Heckel](https://github.com/binwiederhier))
|
||||||
|
- [HTML] Added minification. Chat exports in HTML format are now 30-35% smaller than before.
|
||||||
|
- [HTML] Added support for rendering embeds of Twitter posts that contain multiple images. Previously, this resulted in multiple separate embeds instead of one.
|
||||||
|
- [HTML] Added support for rendering embeds of YouTube Shorts videos.
|
||||||
|
- [HTML] Text content is now hidden if the message only contains a link to an image and nothing else. The link itself is resolved as an image embed.
|
||||||
|
- [HTML] Fixed an issue where some emoji that included a zero-width-joiner rune were not rendered correctly due to a wrong Twemoji URL being generated. (Thanks [@Ethan](https://github.com/ethanndickson))
|
||||||
|
- [HTML] Fixed an issue where replies to a message that contained a quote (i.e. `>`) were not rendered correctly.
|
||||||
|
- [HTML] Fixed an issue where code blocks with language highlighting did not have the correct background color.
|
||||||
|
- Added file name template token that resolves to the current date (`%d`). (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||||
|
- Updated the usage guide to replace the instructions for retrieving the user token with those that appear to work for more people.
|
||||||
|
- Updated the usage guide to feature the TOS warning more prominently.
|
||||||
|
- [Docker] Changed internal working directory from `/app/out` to `/out`. This was an unintended breaking change. Please update your volume binding configuration from `-v /path/on/machine:/app/out` to `-v /path/on/machine:/out`.
|
||||||
|
|
||||||
|
### v2.34.1 (01-Jun-2022)
|
||||||
|
|
||||||
|
- Improved retry policy to handle more transient errors. This should result in fewer errors exporting a channel due to timeout.
|
||||||
|
- Added `has:pin` message filter. You can use it to limit exported messages to those that have been pinned. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Fixed an issue which caused the export to fail when processing stickers that have no name.
|
||||||
|
|
||||||
|
### v2.34 (10-Apr-2022)
|
||||||
|
|
||||||
|
- [HTML] Reworked layout to be more consistent with Discord.
|
||||||
|
- [HTML] Changed user profile picture dimensions from `128x128` to `512x512`. (Thanks [@Chris Helder](https://github.com/TheDude53))
|
||||||
|
- [HTML] Improved image scaling for Chromium-based browsers. This should result in higher quality images across the entire export.
|
||||||
|
- [HTML] Hovering over messages will now show short timestamps on the left, indicating the time that the individual messages of the group were sent.
|
||||||
|
- [HTML] Using "download media" option will now also download scripts, styles, fonts, and other similar resources used by the export.
|
||||||
|
- [HTML] Image attachments will now use the provided description as alt text.
|
||||||
|
- [HTML] Fixed a few layout inconsistencies related to embeds.
|
||||||
|
- [GUI] Added a few clickable links to the usage guide to make the process of locating tokens easier.
|
||||||
|
|
||||||
|
### v2.33.2 (07-Mar-2022)
|
||||||
|
|
||||||
|
- Actually fixed it this time.
|
||||||
|
|
||||||
|
### v2.33.1 (07-Mar-2022)
|
||||||
|
|
||||||
|
- Fixed an issue where the application silently failed to run if the system didn't have .NET Runtime 6.0.2 installed. If you continue seeing this issue, please uninstall all existing .NET runtimes from your computer and then try running the application again.
|
||||||
|
|
||||||
|
### v2.33 (06-Mar-2022)
|
||||||
|
|
||||||
|
- Added messages informing about war in Ukraine and available ways to help.
|
||||||
|
- Added support for rendering stickers in HTML and JSON. Lottie-based stickers currently cannot be displayed in HTML exports (see [#803](https://github.com/Tyrrrz/DiscordChatExporter/issues/803)).
|
||||||
|
- Added a new `reaction:` message filter which can be used to check if someone reacted to a message with a specific emoji. You can either pass the emoji name (e.g. `reaction:LUL`) or its ID (e.g. `reaction:41771983429993937`).
|
||||||
|
- [GUI] Added auto-detection for dark mode. If your system is configured to prefer dark mode in applications, DiscordChatExporter will use it by default instead of light mode.
|
||||||
|
- Fixed an issue which caused the export process to crash when downloading media files with extremely long file extensions. (Thanks [@Tomlacko](https://github.com/Tomlacko))
|
||||||
|
- Fixed an issue which caused the export process to crash on invalid mentions.
|
||||||
|
- [GUI] Fixed an issue where the time pickers used to specify export ranges always displayed time in 12-hour format, instead of respecting the system locale.
|
||||||
|
|
||||||
|
### v2.32 (27-Jan-2022)
|
||||||
|
|
||||||
- Token kind (user or bot) is now detected automatically. Removed the button to switch token kind in GUI. Option `-b|--bot` in CLI is now deprecated and does nothing.
|
- Token kind (user or bot) is now detected automatically. Removed the button to switch token kind in GUI. Option `-b|--bot` in CLI is now deprecated and does nothing.
|
||||||
- Updated user token extraction guide to reflect the fact that devtools are no longer accessible in the desktop version of Discord client. The recommended workaround is to open Discord in browser. (Thanks [@Dhananjay-JSR](https://github.com/Dhananjay-JSR))
|
- Updated user token extraction guide to reflect the fact that devtools are no longer accessible in the desktop version of Discord client. The recommended workaround is to open Discord in browser. (Thanks [@Dhananjay-JSR](https://github.com/Dhananjay-JSR))
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<Version>2.32</Version>
|
<Version>2.37</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<CollectCoverage>true</CollectCoverage>
|
|
||||||
<CoverletOutputFormat>opencover</CoverletOutputFormat>
|
|
||||||
<NoWarn>$(NoWarn);xUnit1013</NoWarn>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -14,15 +11,15 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="0.16.1" />
|
<PackageReference Include="AngleSharp" Version="0.17.1" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.4.0" />
|
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />
|
||||||
<PackageReference Include="coverlet.msbuild" Version="3.1.0" PrivateAssets="all" />
|
|
||||||
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
||||||
<PackageReference Include="xunit" Version="2.4.1" />
|
<PackageReference Include="xunit" Version="2.4.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="coverlet.collector" Version="3.2.0" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -46,58 +46,46 @@ public class ExportWrapperFixture : IDisposable
|
|||||||
return await File.ReadAllTextAsync(filePath);
|
return await File.ReadAllTextAsync(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId)
|
public async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse(
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.HtmlDark)
|
||||||
var data = await ExportAsync(channelId, ExportFormat.HtmlDark);
|
);
|
||||||
return Html.Parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId)
|
public async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId) => Json.Parse(
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.Json)
|
||||||
var data = await ExportAsync(channelId, ExportFormat.Json);
|
);
|
||||||
return Json.Parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId)
|
public async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId) =>
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.PlainText);
|
||||||
var data = await ExportAsync(channelId, ExportFormat.PlainText);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ExportAsCsvAsync(Snowflake channelId)
|
public async ValueTask<string> ExportAsCsvAsync(Snowflake channelId) =>
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.Csv);
|
||||||
var data = await ExportAsync(channelId, ExportFormat.Csv);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId)
|
public async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId) =>
|
||||||
{
|
(await ExportAsHtmlAsync(channelId))
|
||||||
var document = await ExportAsHtmlAsync(channelId);
|
.QuerySelectorAll("[data-message-id]")
|
||||||
return document.QuerySelectorAll("[data-message-id]").ToArray();
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId)
|
public async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId) =>
|
||||||
{
|
(await ExportAsJsonAsync(channelId))
|
||||||
var document = await ExportAsJsonAsync(channelId);
|
.GetProperty("messages")
|
||||||
return document.GetProperty("messages").EnumerateArray().ToArray();
|
.EnumerateArray()
|
||||||
}
|
.ToArray();
|
||||||
|
|
||||||
public async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
|
public async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
|
||||||
{
|
{
|
||||||
var messages = await GetMessagesAsHtmlAsync(channelId);
|
var message = (await GetMessagesAsHtmlAsync(channelId))
|
||||||
|
.SingleOrDefault(e =>
|
||||||
var message = messages.SingleOrDefault(e =>
|
string.Equals(
|
||||||
string.Equals(
|
e.GetAttribute("data-message-id"),
|
||||||
e.GetAttribute("data-message-id"),
|
messageId.ToString(),
|
||||||
messageId.ToString(),
|
StringComparison.OrdinalIgnoreCase
|
||||||
StringComparison.OrdinalIgnoreCase
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (message is null)
|
if (message is null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
$"Message '{messageId}' does not exist in the export of channel '{channelId}'."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,20 +94,19 @@ public class ExportWrapperFixture : IDisposable
|
|||||||
|
|
||||||
public async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
|
public async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
|
||||||
{
|
{
|
||||||
var messages = await GetMessagesAsJsonAsync(channelId);
|
var message = (await GetMessagesAsJsonAsync(channelId))
|
||||||
|
.SingleOrDefault(j =>
|
||||||
var message = messages.FirstOrDefault(j =>
|
string.Equals(
|
||||||
string.Equals(
|
j.GetProperty("id").GetString(),
|
||||||
j.GetProperty("id").GetString(),
|
messageId.ToString(),
|
||||||
messageId.ToString(),
|
StringComparison.OrdinalIgnoreCase
|
||||||
StringComparison.OrdinalIgnoreCase
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (message.ValueKind == JsonValueKind.Undefined)
|
if (message.ValueKind == JsonValueKind.Undefined)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
$"Message '{messageId}' does not exist in the export of channel '{channelId}'."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios.
|
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.
|
In order to run these tests locally, you need to join the test server and configure your authentication token.
|
||||||
|
|
||||||
1. Join the server: https://discord.gg/eRV8Vap5bm
|
1. [Join the test server](https://discord.gg/eRV8Vap5bm)
|
||||||
2. Locate your Discord authentication token
|
2. Locate your Discord authentication token
|
||||||
3. Specify your token using a file or an environment variable:
|
3. Specify your token using a file or an environment variable:
|
||||||
- **Using a file**: put your token in a new `DiscordToken.secret` file created in this directory
|
- **Using a file**: put your token in a new `DiscordToken.secret` file created in this directory
|
||||||
- **Using an environment variable**: set `DISCORD_TOKEN` variable to your token
|
- **Using an environment variable**: set `DISCORD_TOKEN` variable to your token
|
||||||
4. Run the tests: `dotnet test`
|
4. Run the tests: `dotnet test`
|
||||||
|
|
||||||
> If you want to have a new test case or a scenario added, please let me know in your pull request.
|
> If you want to add a new test case, please let me know and I will give you the required permissions
|
||||||
Currently, it's not possible to add them by yourself.
|
|
||||||
@@ -6,13 +6,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.CsvWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.CsvWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
var document = await _exportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document.Should().ContainAll(
|
document.Should().ContainAll(
|
||||||
|
|||||||
@@ -15,14 +15,21 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class DateRangeSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public DateRangeSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date()
|
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -34,16 +41,14 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
After = Snowflake.FromDate(after)
|
After = Snowflake.FromDate(after)
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
var timestamps = Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
var timestamps = document
|
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// Assert
|
|
||||||
timestamps.All(t => t > after).Should().BeTrue();
|
timestamps.All(t => t > after).Should().BeTrue();
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
@@ -68,7 +73,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -80,16 +85,14 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
Before = Snowflake.FromDate(before)
|
Before = Snowflake.FromDate(before)
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
var timestamps = Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
var timestamps = document
|
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// Assert
|
|
||||||
timestamps.All(t => t < before).Should().BeTrue();
|
timestamps.All(t => t < before).Should().BeTrue();
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
@@ -113,7 +116,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
// Arrange
|
// Arrange
|
||||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
var before = new DateTimeOffset(2021, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
var before = new DateTimeOffset(2021, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -126,16 +129,14 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
After = Snowflake.FromDate(after)
|
After = Snowflake.FromDate(after)
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
var timestamps = Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
var timestamps = document
|
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// Assert
|
|
||||||
timestamps.All(t => t < before && t > after).Should().BeTrue();
|
timestamps.All(t => t < before && t > after).Should().BeTrue();
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
|
|||||||
@@ -14,13 +14,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class FilterSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public FilterSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text()
|
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -32,11 +39,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("some text")
|
MessageFilter = MessageFilter.Parse("some text")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
@@ -48,7 +53,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author()
|
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -60,11 +65,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
||||||
@@ -76,7 +79,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content()
|
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -88,11 +91,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("has:image")
|
MessageFilter = MessageFilter.Parse("has:image")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
@@ -100,11 +101,37 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
.ContainSingle("This has image");
|
.ContainSingle("This has image");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Messages_filtered_by_pin_only_include_messages_that_have_been_pinned()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
MessageFilter = MessageFilter.Parse("has:pin")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
|
.Should()
|
||||||
|
.ContainSingle("This is pinned");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
|
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -116,11 +143,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
using DiscordChatExporter.Cli.Tests.Fixtures;
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
using DiscordChatExporter.Cli.Tests.TestData;
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
@@ -8,19 +9,24 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class AttachmentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public AttachmentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885587844989612074")
|
Snowflake.Parse("885587844989612074")
|
||||||
);
|
);
|
||||||
|
|
||||||
var fileUrl = message.QuerySelector("a")?.GetAttribute("href");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().ContainAll(
|
message.Text().Should().ContainAll(
|
||||||
"Generic file attachment",
|
"Generic file attachment",
|
||||||
@@ -28,45 +34,52 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
"11 bytes"
|
"11 bytes"
|
||||||
);
|
);
|
||||||
|
|
||||||
fileUrl.Should().StartWithEquivalentOf(
|
message
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
.QuerySelectorAll("a")
|
||||||
);
|
.Select(e => e.GetAttribute("href"))
|
||||||
|
.Should()
|
||||||
|
.Contain(
|
||||||
|
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885654862656843786")
|
Snowflake.Parse("885654862656843786")
|
||||||
);
|
);
|
||||||
|
|
||||||
var imageUrl = message.QuerySelector("img")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Image attachment");
|
message.Text().Should().Contain("Image attachment");
|
||||||
|
|
||||||
imageUrl.Should().StartWithEquivalentOf(
|
message
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
.QuerySelectorAll("img")
|
||||||
);
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.Should()
|
||||||
|
.Contain(
|
||||||
|
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885655761919836171")
|
Snowflake.Parse("885655761919836171")
|
||||||
);
|
);
|
||||||
|
|
||||||
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Video attachment");
|
message.Text().Should().Contain("Video attachment");
|
||||||
|
|
||||||
videoUrl.Should().StartWithEquivalentOf(
|
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
|
||||||
|
videoUrl.Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,18 +87,19 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885656175620808734")
|
Snowflake.Parse("885656175620808734")
|
||||||
);
|
);
|
||||||
|
|
||||||
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Audio attachment");
|
message.Text().Should().Contain("Audio attachment");
|
||||||
|
|
||||||
audioUrl.Should().StartWithEquivalentOf(
|
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
|
||||||
|
audioUrl.Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
var messages = await _exportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal(
|
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal(
|
||||||
@@ -28,7 +35,7 @@ public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
"885169254029213696"
|
"885169254029213696"
|
||||||
);
|
);
|
||||||
|
|
||||||
messages.Select(e => e.QuerySelector(".chatlog__content")?.Text().Trim()).Should().Equal(
|
messages.SelectMany(e => e.Text()).Should().ContainInOrder(
|
||||||
"Hello world",
|
"Hello world",
|
||||||
"Goodbye world",
|
"Goodbye world",
|
||||||
"Foo bar",
|
"Foo bar",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
using DiscordChatExporter.Cli.Tests.Fixtures;
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
using DiscordChatExporter.Cli.Tests.TestData;
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
@@ -8,13 +9,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public EmbedSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_embed_is_rendered_correctly()
|
public async Task Message_with_an_embed_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("866769910729146400")
|
Snowflake.Parse("866769910729146400")
|
||||||
);
|
);
|
||||||
@@ -32,32 +40,128 @@ public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_Spotify_track_is_rendered_using_an_iframe()
|
public async Task Message_containing_an_image_link_is_rendered_with_an_image_embed()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/537
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("991768701126852638")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.QuerySelectorAll("img")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.Where(s => s?.EndsWith("i.redd.it/f8w05ja8s4e61.png") ?? false)
|
||||||
|
.Should()
|
||||||
|
.ContainSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_an_image_link_and_nothing_else_is_rendered_without_text_content()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("991768701126852638")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var content = message.QuerySelector(".chatlog__content")?.Text();
|
||||||
|
content.Should().BeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_a_gifv_link_is_rendered_with_a_video_embed()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("1019234520349814794")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.QuerySelectorAll("source")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.Where(s => s?.EndsWith("media.tenor.com/DDAJeW6BQKkAAAPo/tooncasm-test-copy.mp4") ?? false)
|
||||||
|
.Should()
|
||||||
|
.ContainSingle();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_a_gifv_link_and_nothing_else_is_rendered_without_text_content()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("1019234520349814794")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var content = message.QuerySelector(".chatlog__content")?.Text();
|
||||||
|
content.Should().BeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/657
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("867886632203976775")
|
Snowflake.Parse("867886632203976775")
|
||||||
);
|
);
|
||||||
|
|
||||||
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
iframeSrc.Should().StartWithEquivalentOf("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||||
|
iframeUrl.Should().Be("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_YouTube_video_is_rendered_using_an_iframe()
|
public async Task Message_containing_a_YouTube_video_link_is_rendered_with_a_video_embed()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/570
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("866472508588294165")
|
Snowflake.Parse("866472508588294165")
|
||||||
);
|
);
|
||||||
|
|
||||||
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
// Assert
|
||||||
|
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||||
|
iframeUrl.Should().Be("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_a_Twitter_post_link_with_multiple_images_is_rendered_as_a_single_embed()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("991757444017557665")
|
||||||
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
iframeSrc.Should().StartWithEquivalentOf("https://www.youtube.com/embed/qOWW4OlgbvE");
|
message
|
||||||
|
.QuerySelectorAll("img")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.Should()
|
||||||
|
.ContainInOrder(
|
||||||
|
"https://images-ext-1.discordapp.net/external/-n--xW3EHH_3jlrheVkMXHCM7T86b5Ty4-MzXCT4m1Q/https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
|
||||||
|
"https://images-ext-2.discordapp.net/external/z5nEmGeEldV-kswydGLhqUsFHbb5AWHtdvc9XT6N5rE/https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
|
||||||
|
"https://images-ext-2.discordapp.net/external/gnip03SawMB6uZLagN5sRDpA_1Ap1CcEhMbJfK1z6WQ/https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
|
||||||
|
"https://images-ext-2.discordapp.net/external/jl1v6cCbLaGmiwmKU-ZkXnF4cFsJ39f9A3-oEdqPdZs/https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png"
|
||||||
|
);
|
||||||
|
|
||||||
|
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AngleSharp.Dom;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using DiscordChatExporter.Cli.Commands;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public class GroupingSpecs : IClassFixture<TempOutputFixture>
|
||||||
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public GroupingSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Messages_are_grouped_correctly()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/152
|
||||||
|
|
||||||
|
// Arrange
|
||||||
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = new[] { ChannelIds.GroupingTestCases },
|
||||||
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
|
OutputPath = filePath
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var messageGroups = Html
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
|
.QuerySelectorAll(".chatlog__message-group");
|
||||||
|
|
||||||
|
messageGroups.Should().HaveCount(2);
|
||||||
|
|
||||||
|
messageGroups[0]
|
||||||
|
.QuerySelectorAll(".chatlog__content")
|
||||||
|
.Select(e => e.Text())
|
||||||
|
.Should()
|
||||||
|
.ContainInOrder(
|
||||||
|
"First",
|
||||||
|
"Second",
|
||||||
|
"Third",
|
||||||
|
"Fourth",
|
||||||
|
"Fifth",
|
||||||
|
"Sixth",
|
||||||
|
"Seventh",
|
||||||
|
"Eighth",
|
||||||
|
"Ninth",
|
||||||
|
"Tenth"
|
||||||
|
);
|
||||||
|
|
||||||
|
messageGroups[1]
|
||||||
|
.QuerySelectorAll(".chatlog__content")
|
||||||
|
.Select(e => e.Text())
|
||||||
|
.Should()
|
||||||
|
.ContainInOrder(
|
||||||
|
"Eleventh",
|
||||||
|
"Twelveth",
|
||||||
|
"Thirteenth",
|
||||||
|
"Fourteenth",
|
||||||
|
"Fifteenth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,19 +8,26 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class MentionSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public MentionSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task User_mention_is_rendered_correctly()
|
public async Task User_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866458840245076028")
|
Snowflake.Parse("866458840245076028")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("User mention: @Tyrrrz");
|
message.Text().Should().Contain("User mention: @Tyrrrz");
|
||||||
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,38 +35,38 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
public async Task Text_channel_mention_is_rendered_correctly()
|
public async Task Text_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459040480624680")
|
Snowflake.Parse("866459040480624680")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Text channel mention: #mention-tests");
|
message.Text().Should().Contain("Text channel mention: #mention-tests");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Voice_channel_mention_is_rendered_correctly()
|
public async Task Voice_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459175462633503")
|
Snowflake.Parse("866459175462633503")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc");
|
message.Text().Should().Contain("Voice channel mention: 🔊chaos-vc");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Role_mention_is_rendered_correctly()
|
public async Task Role_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459254693429258")
|
Snowflake.Parse("866459254693429258")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Role mention: @Role 1");
|
message.Text().Should().Contain("Role mention: @Role 1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,49 +8,60 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ReplySpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ReplySpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_normal_message_is_rendered_correctly()
|
public async Task Reply_to_a_normal_message_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866460738239725598")
|
Snowflake.Parse("866460738239725598")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to original");
|
message.Text().Should().Contain("reply to original");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original");
|
message.QuerySelector(".chatlog__reference-link")?.Text().Should().Contain("original");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_deleted_message_is_rendered_correctly()
|
public async Task Reply_to_a_deleted_message_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/645
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866460975388819486")
|
Snowflake.Parse("866460975388819486")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to deleted");
|
message.Text().Should().Contain("reply to deleted");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Should().Contain(
|
||||||
.Be("Original message was deleted or could not be loaded.");
|
"Original message was deleted or could not be loaded."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_empty_message_with_attachment_is_rendered_correctly()
|
public async Task Reply_to_an_empty_message_with_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/634
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866462470335627294")
|
Snowflake.Parse("866462470335627294")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to attachment");
|
message.Text().Should().Contain("reply to attachment");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Should().Contain("Click to see attachment");
|
||||||
.Be("Click to see attachment 🖼️");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public class StickerSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public StickerSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670623158943754")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var stickerUrl = message.QuerySelector("[title='rock'] img")?.GetAttribute("src");
|
||||||
|
stickerUrl.Should().Be("https://discord.com/stickers/904215665597120572.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670526517997590")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var stickerUrl = message.QuerySelector("[title='Yikes'] [data-source]")?.GetAttribute("data-source");
|
||||||
|
stickerUrl.Should().Be("https://discord.com/stickers/816087132447178774.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,24 +8,30 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class AttachmentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public AttachmentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885587844989612074")
|
Snowflake.Parse("885587844989612074")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Generic file attachment");
|
message.GetProperty("content").GetString().Should().Be("Generic file attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
|
||||||
@@ -36,18 +42,17 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885654862656843786")
|
Snowflake.Parse("885654862656843786")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Image attachment");
|
message.GetProperty("content").GetString().Should().Be("Image attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
||||||
@@ -58,18 +63,17 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885655761919836171")
|
Snowflake.Parse("885655761919836171")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Video attachment");
|
message.GetProperty("content").GetString().Should().Be("Video attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
||||||
@@ -80,18 +84,17 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885656175620808734")
|
Snowflake.Parse("885656175620808734")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Audio attachment");
|
message.GetProperty("content").GetString().Should().Be("Audio attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
var messages = await _exportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal(
|
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal(
|
||||||
|
|||||||
@@ -8,41 +8,47 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public EmbedSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_embed_is_rendered_correctly()
|
public async Task Message_with_an_embed_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("866769910729146400")
|
Snowflake.Parse("866769910729146400")
|
||||||
);
|
);
|
||||||
|
|
||||||
var embed = message
|
|
||||||
.GetProperty("embeds")
|
|
||||||
.EnumerateArray()
|
|
||||||
.Single();
|
|
||||||
|
|
||||||
var embedAuthor = embed.GetProperty("author");
|
|
||||||
var embedThumbnail = embed.GetProperty("thumbnail");
|
|
||||||
var embedFooter = embed.GetProperty("footer");
|
|
||||||
var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
var embed = message.GetProperty("embeds").EnumerateArray().Single();
|
||||||
embed.GetProperty("title").GetString().Should().Be("Embed title");
|
embed.GetProperty("title").GetString().Should().Be("Embed title");
|
||||||
embed.GetProperty("url").GetString().Should().Be("https://example.com");
|
embed.GetProperty("url").GetString().Should().Be("https://example.com");
|
||||||
embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
|
embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
|
||||||
embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
|
embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
|
||||||
embed.GetProperty("color").GetString().Should().Be("#58B9FF");
|
embed.GetProperty("color").GetString().Should().Be("#58B9FF");
|
||||||
|
|
||||||
|
var embedAuthor = embed.GetProperty("author");
|
||||||
embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
|
embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
|
||||||
embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
|
embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
|
||||||
embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
var embedThumbnail = embed.GetProperty("thumbnail");
|
||||||
embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
|
embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
|
embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
|
||||||
embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);
|
embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);
|
||||||
|
|
||||||
|
var embedFooter = embed.GetProperty("footer");
|
||||||
embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
|
embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
|
||||||
embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
|
||||||
embedFields.Should().HaveCount(3);
|
embedFields.Should().HaveCount(3);
|
||||||
embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
|
embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
|
||||||
embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
|
embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class MentionSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public MentionSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task User_mention_is_rendered_correctly()
|
public async Task User_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866458840245076028")
|
Snowflake.Parse("866458840245076028")
|
||||||
);
|
);
|
||||||
@@ -26,14 +33,15 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
.GetProperty("mentions")
|
.GetProperty("mentions")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("id").GetString())
|
.Select(j => j.GetProperty("id").GetString())
|
||||||
.Should().Contain("128178626683338752");
|
.Should()
|
||||||
|
.Contain("128178626683338752");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Text_channel_mention_is_rendered_correctly()
|
public async Task Text_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459040480624680")
|
Snowflake.Parse("866459040480624680")
|
||||||
);
|
);
|
||||||
@@ -46,7 +54,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
public async Task Voice_channel_mention_is_rendered_correctly()
|
public async Task Voice_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459175462633503")
|
Snowflake.Parse("866459175462633503")
|
||||||
);
|
);
|
||||||
@@ -59,7 +67,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
public async Task Role_mention_is_rendered_correctly()
|
public async Task Role_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459254693429258")
|
Snowflake.Parse("866459254693429258")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
|
public class StickerSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public StickerSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670623158943754")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var sticker = message
|
||||||
|
.GetProperty("stickers")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Single();
|
||||||
|
|
||||||
|
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
|
||||||
|
sticker.GetProperty("name").GetString().Should().Be("rock");
|
||||||
|
sticker.GetProperty("format").GetString().Should().Be("PngAnimated");
|
||||||
|
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/904215665597120572.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670526517997590")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var sticker = message
|
||||||
|
.GetProperty("stickers")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Single();
|
||||||
|
|
||||||
|
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
|
||||||
|
sticker.GetProperty("name").GetString().Should().Be("Yikes");
|
||||||
|
sticker.GetProperty("format").GetString().Should().Be("Lottie");
|
||||||
|
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/816087132447178774.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,13 +12,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class PartitioningSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public PartitioningSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_partitioned_by_count_are_split_into_multiple_files_correctly()
|
public async Task Messages_partitioned_by_count_are_split_into_a_corresponding_number_of_files()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
@@ -39,10 +46,10 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_partitioned_by_file_size_are_split_into_multiple_files_correctly()
|
public async Task Messages_partitioned_by_file_size_are_split_into_a_corresponding_number_of_files()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
@@ -53,12 +60,12 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
|
|||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
PartitionLimit = PartitionLimit.Parse("20kb")
|
PartitionLimit = PartitionLimit.Parse("1kb")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
||||||
.Should()
|
.Should()
|
||||||
.HaveCount(2);
|
.HaveCount(8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.PlainTextWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.PlainTextWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
var document = await _exportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document.Should().ContainAll(
|
document.Should().ContainAll(
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class SelfContainedSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public SelfContainedSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_in_self_contained_export_only_reference_local_file_resources()
|
public async Task Messages_in_self_contained_export_only_reference_local_file_resources()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -29,14 +36,12 @@ public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<T
|
|||||||
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
ShouldDownloadMedia = true
|
ShouldDownloadAssets = true
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Html.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Html
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.QuerySelectorAll("body [src]")
|
.QuerySelectorAll("body [src]")
|
||||||
.Select(e => e.GetAttribute("src")!)
|
.Select(e => e.GetAttribute("src")!)
|
||||||
.Select(f => Path.GetFullPath(f, dirPath))
|
.Select(f => Path.GetFullPath(f, dirPath))
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public static class ChannelIds
|
|||||||
|
|
||||||
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
||||||
|
|
||||||
|
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
|
||||||
|
|
||||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||||
|
|
||||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||||
@@ -17,4 +19,6 @@ public static class ChannelIds
|
|||||||
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");
|
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");
|
||||||
|
|
||||||
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
|
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
|
||||||
|
|
||||||
|
public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# -- Build
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
COPY favicon.ico ./
|
||||||
|
COPY NuGet.config ./
|
||||||
|
COPY Directory.Build.props ./
|
||||||
|
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core
|
||||||
|
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli
|
||||||
|
|
||||||
|
RUN dotnet publish DiscordChatExporter.Cli \
|
||||||
|
--self-contained \
|
||||||
|
--use-current-runtime \
|
||||||
|
--configuration Release \
|
||||||
|
--output ./publish
|
||||||
|
|
||||||
|
# -- Run
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime-deps:7.0-alpine
|
||||||
|
|
||||||
|
# Alpine dotnet image doesn't include timezone data, which is needed
|
||||||
|
# for certain date/time operations.
|
||||||
|
RUN apk add --no-cache tzdata
|
||||||
|
|
||||||
|
# Create a non-root user to run the app, so that the output files
|
||||||
|
# can be accessed by the host.
|
||||||
|
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
|
||||||
|
RUN adduser \
|
||||||
|
--disabled-password \
|
||||||
|
--no-create-home \
|
||||||
|
dce
|
||||||
|
|
||||||
|
USER dce
|
||||||
|
|
||||||
|
COPY --from=build /build/publish /opt/dce
|
||||||
|
|
||||||
|
# Need to keep this as /out for backwards compatibility with documentation.
|
||||||
|
# A lot of people have this directory mounted in their scripts files, so
|
||||||
|
# changing it would break existing workflows.
|
||||||
|
WORKDIR /out
|
||||||
|
|
||||||
|
# Add the app directory to PATH so that it's easier to debug using a shell
|
||||||
|
ENV PATH="$PATH:/opt/dce"
|
||||||
|
ENTRYPOINT ["DiscordChatExporter.Cli"]
|
||||||
@@ -14,39 +14,82 @@ using DiscordChatExporter.Core.Exceptions;
|
|||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
using Gress;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands.Base;
|
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
|
||||||
public abstract class ExportCommandBase : TokenCommandBase
|
public abstract class ExportCommandBase : TokenCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
||||||
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
|
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Export format.")]
|
[CommandOption(
|
||||||
|
"output",
|
||||||
|
'o',
|
||||||
|
Description = "Output file or directory path. Directory path should end in a slash."
|
||||||
|
)]
|
||||||
|
public string OutputPath
|
||||||
|
{
|
||||||
|
get => _outputPath;
|
||||||
|
// Handle ~/ in paths on Unix systems
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||||
|
init => _outputPath = Path.GetFullPath(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"format",
|
||||||
|
'f',
|
||||||
|
Description = "Export format."
|
||||||
|
)]
|
||||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
[CommandOption("after", Description = "Only include messages sent after this date or message ID.")]
|
[CommandOption(
|
||||||
|
"after",
|
||||||
|
Description = "Only include messages sent after this date or message ID."
|
||||||
|
)]
|
||||||
public Snowflake? After { get; init; }
|
public Snowflake? After { get; init; }
|
||||||
|
|
||||||
[CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
|
[CommandOption(
|
||||||
|
"before",
|
||||||
|
Description = "Only include messages sent before this date or message ID."
|
||||||
|
)]
|
||||||
public Snowflake? Before { get; init; }
|
public Snowflake? Before { get; init; }
|
||||||
|
|
||||||
[CommandOption("partition", 'p', Description = "Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb').")]
|
[CommandOption(
|
||||||
|
"partition",
|
||||||
|
'p',
|
||||||
|
Description = "Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')."
|
||||||
|
)]
|
||||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||||
|
|
||||||
[CommandOption("filter", Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image').")]
|
[CommandOption(
|
||||||
|
"filter",
|
||||||
|
Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')."
|
||||||
|
)]
|
||||||
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
||||||
|
|
||||||
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
|
[CommandOption(
|
||||||
|
"parallel",
|
||||||
|
Description = "Limits how many channels can be exported in parallel."
|
||||||
|
)]
|
||||||
public int ParallelLimit { get; init; } = 1;
|
public int ParallelLimit { get; init; } = 1;
|
||||||
|
|
||||||
[CommandOption("media", Description = "Download referenced media content.")]
|
[CommandOption(
|
||||||
public bool ShouldDownloadMedia { get; init; }
|
"media",
|
||||||
|
Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
|
||||||
|
)]
|
||||||
|
public bool ShouldDownloadAssets { get; init; }
|
||||||
|
|
||||||
[CommandOption("reuse-media", Description = "Reuse already existing media content to skip redundant downloads.")]
|
[CommandOption(
|
||||||
public bool ShouldReuseMedia { get; init; }
|
"reuse-media",
|
||||||
|
Description = "Reuse previously downloaded assets to avoid redundant requests."
|
||||||
|
)]
|
||||||
|
public bool ShouldReuseAssets { get; init; }
|
||||||
|
|
||||||
[CommandOption("dateformat", Description = "Format used when writing dates.")]
|
[CommandOption(
|
||||||
|
"dateformat",
|
||||||
|
Description = "Format used when writing dates."
|
||||||
|
)]
|
||||||
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
|
||||||
private ChannelExporter? _channelExporter;
|
private ChannelExporter? _channelExporter;
|
||||||
@@ -54,13 +97,36 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
|
|
||||||
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
|
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
// Reuse assets option should only be used when the download assets option is set.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||||
if (ShouldReuseMedia && !ShouldDownloadMedia)
|
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||||
{
|
{
|
||||||
throw new CommandException("Option --reuse-media cannot be used without --media.");
|
throw new CommandException(
|
||||||
|
"Option --reuse-media cannot be used without --media."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the user does not try to export all channels into a single file.
|
||||||
|
// Output path must either be a directory, or contain template tokens.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||||
|
var isValidOutputPath =
|
||||||
|
// Anything is valid when exporting a single channel
|
||||||
|
channels.Count <= 1 ||
|
||||||
|
// When using template tokens, assume the user knows what they're doing
|
||||||
|
OutputPath.Contains('%') ||
|
||||||
|
// Otherwise, require an existing directory or an unambiguous directory path
|
||||||
|
Directory.Exists(OutputPath) || PathEx.IsDirectoryPath(OutputPath);
|
||||||
|
|
||||||
|
if (!isValidOutputPath)
|
||||||
|
{
|
||||||
|
throw new CommandException(
|
||||||
|
"Attempted to export multiple channels, but the output path is neither a directory nor a template. " +
|
||||||
|
"If the provided output path is meant to be treated as a directory, make sure it ends with a slash."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
var errors = new ConcurrentDictionary<Channel, string>();
|
var errors = new ConcurrentDictionary<Channel, string>();
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
@@ -78,8 +144,7 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await progressContext.StartTaskAsync(
|
await progressContext.StartTaskAsync($"{channel.Category.Name} / {channel.Name}",
|
||||||
$"{channel.Category.Name} / {channel.Name}",
|
|
||||||
async progress =>
|
async progress =>
|
||||||
{
|
{
|
||||||
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
||||||
@@ -93,12 +158,16 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
MessageFilter,
|
MessageFilter,
|
||||||
ShouldDownloadMedia,
|
ShouldDownloadAssets,
|
||||||
ShouldReuseMedia,
|
ShouldReuseAssets,
|
||||||
DateFormat
|
DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
await Exporter.ExportChannelAsync(request, progress, innerCancellationToken);
|
await Exporter.ExportChannelAsync(
|
||||||
|
request,
|
||||||
|
progress.ToPercentageBased(),
|
||||||
|
innerCancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,4 +231,16 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
|
|
||||||
await ExecuteAsync(console, channels);
|
await ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
// War in Ukraine message
|
||||||
|
console.Output.WriteLine("========================================================================");
|
||||||
|
console.Output.WriteLine("|| Ukraine is at war! Support my country in its fight for freedom ||");
|
||||||
|
console.Output.WriteLine("|| Learn more: https://tyrrrz.me/ukraine ||");
|
||||||
|
console.Output.WriteLine("========================================================================");
|
||||||
|
console.Output.WriteLine("");
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,11 +9,21 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
|||||||
|
|
||||||
public abstract class TokenCommandBase : ICommand
|
public abstract class TokenCommandBase : ICommand
|
||||||
{
|
{
|
||||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
|
[CommandOption(
|
||||||
public string Token { get; init; } = "";
|
"token",
|
||||||
|
't',
|
||||||
|
EnvironmentVariable = "DISCORD_TOKEN",
|
||||||
|
Description = "Authentication token."
|
||||||
|
)]
|
||||||
|
public required string Token { get; init; }
|
||||||
|
|
||||||
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "This option doesn't do anything. Kept for backwards compatibility.")]
|
|
||||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
[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; }
|
public bool IsBotToken { get; init; }
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
private DiscordClient? _discordClient;
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
[Command("exportall", Description = "Export all accessible channels.")]
|
[Command("exportall", Description = "Export all accessible channels.")]
|
||||||
public class ExportAllCommand : ExportCommandBase
|
public class ExportAllCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
[CommandOption(
|
||||||
|
"include-dm",
|
||||||
|
Description = "Include direct message channels."
|
||||||
|
)]
|
||||||
public bool IncludeDirectMessages { get; init; } = true;
|
public bool IncludeDirectMessages { get; init; } = true;
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
var channels = new List<Channel>();
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
@@ -26,13 +31,7 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
|
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
|
||||||
{
|
|
||||||
// Skip non-text channels
|
|
||||||
if (!channel.IsTextChannel)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
channels.Add(channel);
|
channels.Add(channel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await base.ExecuteAsync(console, channels);
|
await base.ExecuteAsync(console, channels);
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
public class ExportChannelsCommand : ExportCommandBase
|
public class ExportChannelsCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
// TODO: change this to plural (breaking change)
|
// TODO: change this to plural (breaking change)
|
||||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
|
[CommandOption(
|
||||||
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
"channel",
|
||||||
|
'c',
|
||||||
|
Description = "Channel ID(s)."
|
||||||
|
)]
|
||||||
|
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console) =>
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
await base.ExecuteAsync(console, ChannelIds);
|
await base.ExecuteAsync(console, ChannelIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,12 +13,16 @@ public class ExportDirectMessagesCommand : ExportCommandBase
|
|||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
|
||||||
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
|
||||||
|
|
||||||
await base.ExecuteAsync(console, textChannels);
|
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
|
||||||
|
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
await base.ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands;
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
@@ -11,17 +12,25 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
||||||
public class ExportGuildCommand : ExportCommandBase
|
public class ExportGuildCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption(
|
||||||
public Snowflake GuildId { get; init; }
|
"guild",
|
||||||
|
'g',
|
||||||
|
Description = "Guild ID."
|
||||||
|
)]
|
||||||
|
public required Snowflake GuildId { get; init; }
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
|
||||||
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
|
||||||
|
|
||||||
await base.ExecuteAsync(console, textChannels);
|
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||||
|
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
await base.ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands;
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
@@ -12,25 +13,29 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
[Command("channels", Description = "Get the list of channels in a guild.")]
|
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||||
public class GetChannelsCommand : TokenCommandBase
|
public class GetChannelsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption(
|
||||||
public Snowflake GuildId { get; init; }
|
"guild",
|
||||||
|
'g',
|
||||||
|
Description = "Guild ID."
|
||||||
|
)]
|
||||||
|
public required Snowflake GuildId { get; init; }
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||||
|
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||||
var textChannels = channels
|
|
||||||
.Where(c => c.IsTextChannel)
|
|
||||||
.OrderBy(c => c.Category.Position)
|
.OrderBy(c => c.Category.Position)
|
||||||
.ThenBy(c => c.Name)
|
.ThenBy(c => c.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
foreach (var channel in textChannels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
// Channel ID
|
// Channel ID
|
||||||
await console.Output.WriteAsync(channel.Id.ToString());
|
await console.Output.WriteAsync(
|
||||||
|
channel.Id.ToString().PadRight(18, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
|
|||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
|
||||||
|
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||||
var textChannels = channels
|
.OrderByDescending(c => c.LastMessageId)
|
||||||
.Where(c => c.IsTextChannel)
|
|
||||||
.OrderBy(c => c.Category.Position)
|
|
||||||
.ThenBy(c => c.Name)
|
.ThenBy(c => c.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
foreach (var channel in textChannels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
// Channel ID
|
// Channel ID
|
||||||
await console.Output.WriteAsync(channel.Id.ToString());
|
await console.Output.WriteAsync(
|
||||||
|
channel.Id.ToString().PadRight(18, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands;
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
@@ -15,12 +16,17 @@ public class GetGuildsCommand : TokenCommandBase
|
|||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var guilds = await Discord.GetUserGuildsAsync(cancellationToken);
|
var guilds = (await Discord.GetUserGuildsAsync(cancellationToken))
|
||||||
|
// Show direct messages first
|
||||||
|
.OrderByDescending(g => g.Id == Guild.DirectMessages.Id)
|
||||||
|
.ThenBy(g => g.Name);
|
||||||
|
|
||||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
foreach (var guild in guilds)
|
||||||
{
|
{
|
||||||
// Guild ID
|
// Guild ID
|
||||||
await console.Output.WriteAsync(guild.Id.ToString());
|
await console.Output.WriteAsync(
|
||||||
|
guild.Id.ToString().PadRight(18, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
|||||||
@@ -15,15 +15,16 @@ public class GuideCommand : ICommand
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get user token:");
|
console.Output.WriteLine("To get user token:");
|
||||||
|
|
||||||
|
console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!");
|
||||||
console.Output.WriteLine(" 1. Open Discord in your web browser and login");
|
console.Output.WriteLine(" 1. Open Discord in your web browser and login");
|
||||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
console.Output.WriteLine(" 2. Open any server or direct message channel");
|
||||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
|
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||||
console.Output.WriteLine(" 4. Navigate to the Application tab");
|
console.Output.WriteLine(" 4. Navigate to the Network tab");
|
||||||
console.Output.WriteLine(" 5. On the left, expand Local Storage and select https://discord.com");
|
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
|
||||||
console.Output.WriteLine(" 6. Type \"token\" into the Filter box");
|
console.Output.WriteLine(" 6. Search for a request containing \"messages?limit=50\" or similar");
|
||||||
console.Output.WriteLine(" 7. If the token key does not appear, press Ctrl+R to reload");
|
console.Output.WriteLine(" 7. Select the Headers tab on the right");
|
||||||
console.Output.WriteLine(" 8. Copy the value of the token key");
|
console.Output.WriteLine(" 8. Scroll down to the Request Headers section");
|
||||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
console.Output.WriteLine(" 9. Copy the value of the \"authorization\" header");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
// Bot token
|
// Bot token
|
||||||
@@ -34,6 +35,7 @@ public class GuideCommand : ICommand
|
|||||||
console.Output.WriteLine(" 2. Open your application's settings");
|
console.Output.WriteLine(" 2. Open your application's settings");
|
||||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||||
|
console.Output.WriteLine(" * Your bot needs to have Message Content Intent enabled to read messages");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
// Guild or channel ID
|
// Guild or channel ID
|
||||||
@@ -42,7 +44,7 @@ public class GuideCommand : ICommand
|
|||||||
|
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Open Settings");
|
console.Output.WriteLine(" 2. Open Settings");
|
||||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
console.Output.WriteLine(" 3. Go to Advanced section");
|
||||||
console.Output.WriteLine(" 4. Enable Developer Mode");
|
console.Output.WriteLine(" 4. Enable Developer Mode");
|
||||||
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.2.1" />
|
<PackageReference Include="CliFx" Version="2.3.1" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.43.0" />
|
<PackageReference Include="Spectre.Console" Version="0.45.0" />
|
||||||
<PackageReference Include="Gress" Version="1.2.0" />
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
|
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.4.0" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli;
|
||||||
|
|
||||||
|
public static class Sanctions
|
||||||
|
{
|
||||||
|
[ModuleInitializer]
|
||||||
|
internal static void Verify()
|
||||||
|
{
|
||||||
|
var isSkipped = string.Equals(
|
||||||
|
Environment.GetEnvironmentVariable("RUSNI"),
|
||||||
|
"PYZDA",
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSkipped)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var locale = CultureInfo.CurrentCulture.Name;
|
||||||
|
|
||||||
|
var region =
|
||||||
|
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||||
|
? Registry.CurrentUser
|
||||||
|
.OpenSubKey(@"Control Panel\International\Geo", false)?
|
||||||
|
.GetValue("Name") as string
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var isSanctioned =
|
||||||
|
locale.EndsWith("-ru", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
locale.EndsWith("-by", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(region, "ru", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(region, "by", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (!isSanctioned)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Console.ForegroundColor = ConsoleColor.Red;
|
||||||
|
Console.Error.WriteLine(
|
||||||
|
"You cannot use this software on the territory of a terrorist state. " +
|
||||||
|
"Set the environment variable `RUSNI=PYZDA` if you wish to override this check."
|
||||||
|
);
|
||||||
|
|
||||||
|
Console.ResetColor();
|
||||||
|
|
||||||
|
Environment.Exit(0xFACC);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ public partial record Attachment(
|
|||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
string Url,
|
string Url,
|
||||||
string FileName,
|
string FileName,
|
||||||
|
string? Description,
|
||||||
int? Width,
|
int? Width,
|
||||||
int? Height,
|
int? Height,
|
||||||
FileSize FileSize) : IHasId
|
FileSize FileSize) : IHasId
|
||||||
@@ -34,11 +35,12 @@ public partial record Attachment
|
|||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
||||||
|
var fileName = json.GetProperty("filename").GetNonNullString();
|
||||||
|
var description = json.GetPropertyOrNull("description")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
||||||
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
||||||
var fileName = json.GetProperty("filename").GetNonNullString();
|
|
||||||
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
||||||
|
|
||||||
return new Attachment(id, url, fileName, width, height, fileSize);
|
return new Attachment(id, url, fileName, description, width, height, fileSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,16 +14,10 @@ public partial record Channel(
|
|||||||
ChannelCategory Category,
|
ChannelCategory Category,
|
||||||
string Name,
|
string Name,
|
||||||
int? Position,
|
int? Position,
|
||||||
string? Topic) : IHasId
|
string? Topic,
|
||||||
|
Snowflake? LastMessageId) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsTextChannel => Kind is
|
public bool SupportsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
||||||
ChannelKind.GuildTextChat or
|
|
||||||
ChannelKind.DirectTextChat or
|
|
||||||
ChannelKind.DirectGroupTextChat or
|
|
||||||
ChannelKind.GuildNews or
|
|
||||||
ChannelKind.GuildStore;
|
|
||||||
|
|
||||||
public bool IsVoiceChannel => !IsTextChannel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Channel
|
public partial record Channel
|
||||||
@@ -36,7 +30,6 @@ public partial record Channel
|
|||||||
ChannelKind.DirectTextChat => "Private",
|
ChannelKind.DirectTextChat => "Private",
|
||||||
ChannelKind.DirectGroupTextChat => "Group",
|
ChannelKind.DirectGroupTextChat => "Group",
|
||||||
ChannelKind.GuildNews => "News",
|
ChannelKind.GuildNews => "News",
|
||||||
ChannelKind.GuildStore => "Store",
|
|
||||||
_ => "Default"
|
_ => "Default"
|
||||||
},
|
},
|
||||||
null
|
null
|
||||||
@@ -45,9 +38,8 @@ public partial record Channel
|
|||||||
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null)
|
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
|
||||||
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
|
||||||
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
||||||
|
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
// Guild channel
|
// Guild channel
|
||||||
@@ -63,7 +55,16 @@ public partial record Channel
|
|||||||
// Fallback
|
// Fallback
|
||||||
id.ToString();
|
id.ToString();
|
||||||
|
|
||||||
var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
var position =
|
||||||
|
positionHint ??
|
||||||
|
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||||
|
|
||||||
|
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
||||||
|
|
||||||
|
var lastMessageId = json
|
||||||
|
.GetPropertyOrNull("last_message_id")?
|
||||||
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
return new Channel(
|
return new Channel(
|
||||||
id,
|
id,
|
||||||
@@ -72,7 +73,8 @@ public partial record Channel
|
|||||||
category ?? GetFallbackCategory(kind),
|
category ?? GetFallbackCategory(kind),
|
||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
topic
|
topic,
|
||||||
|
lastMessageId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,15 @@ public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
|
|||||||
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
|
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
||||||
id.ToString();
|
id.ToString();
|
||||||
|
|
||||||
|
var position =
|
||||||
|
positionHint ??
|
||||||
|
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||||
|
|
||||||
return new ChannelCategory(id, name, position);
|
return new ChannelCategory(id, name, position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,15 @@
|
|||||||
public enum ChannelKind
|
public enum ChannelKind
|
||||||
{
|
{
|
||||||
GuildTextChat = 0,
|
GuildTextChat = 0,
|
||||||
DirectTextChat,
|
DirectTextChat = 1,
|
||||||
GuildVoiceChat,
|
GuildVoiceChat = 2,
|
||||||
DirectGroupTextChat,
|
DirectGroupTextChat = 3,
|
||||||
GuildCategory,
|
GuildCategory = 4,
|
||||||
GuildNews,
|
GuildNews = 5,
|
||||||
GuildStore
|
GuildNewsThread = 10,
|
||||||
|
GuildPublicThread = 11,
|
||||||
|
GuildPrivateThread = 12,
|
||||||
|
GuildStageVoice = 13,
|
||||||
|
GuildDirectory = 14,
|
||||||
|
GuildForum = 15
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
|
|
||||||
@@ -39,7 +40,8 @@ public readonly partial record struct FileSize(long TotalBytes)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}";
|
public override string ToString() =>
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record struct FileSize
|
public partial record struct FileSize
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
|||||||
// https://discord.com/developers/docs/resources/channel#embed-object
|
// https://discord.com/developers/docs/resources/channel#embed-object
|
||||||
public partial record Embed(
|
public partial record Embed(
|
||||||
string? Title,
|
string? Title,
|
||||||
|
EmbedKind Kind,
|
||||||
string? Url,
|
string? Url,
|
||||||
DateTimeOffset? Timestamp,
|
DateTimeOffset? Timestamp,
|
||||||
Color? Color,
|
Color? Color,
|
||||||
@@ -18,12 +19,10 @@ public partial record Embed(
|
|||||||
string? Description,
|
string? Description,
|
||||||
IReadOnlyList<EmbedField> Fields,
|
IReadOnlyList<EmbedField> Fields,
|
||||||
EmbedImage? Thumbnail,
|
EmbedImage? Thumbnail,
|
||||||
EmbedImage? Image,
|
IReadOnlyList<EmbedImage> Images,
|
||||||
|
EmbedVideo? Video,
|
||||||
EmbedFooter? Footer)
|
EmbedFooter? Footer)
|
||||||
{
|
{
|
||||||
public PlainImageEmbedProjection? TryGetPlainImage() =>
|
|
||||||
PlainImageEmbedProjection.TryResolve(this);
|
|
||||||
|
|
||||||
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
|
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
|
||||||
SpotifyTrackEmbedProjection.TryResolve(this);
|
SpotifyTrackEmbedProjection.TryResolve(this);
|
||||||
|
|
||||||
@@ -36,22 +35,47 @@ public partial record Embed
|
|||||||
public static Embed Parse(JsonElement json)
|
public static Embed Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||||
|
|
||||||
|
var kind =
|
||||||
|
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
|
||||||
|
EmbedKind.Rich;
|
||||||
|
|
||||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
||||||
var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
|
||||||
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
var color = json
|
||||||
|
.GetPropertyOrNull("color")?
|
||||||
|
.GetInt32OrNull()?
|
||||||
|
.Pipe(System.Drawing.Color.FromArgb)
|
||||||
|
.ResetAlpha();
|
||||||
|
|
||||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
||||||
var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse);
|
|
||||||
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
|
||||||
|
|
||||||
var fields =
|
var fields =
|
||||||
json.GetPropertyOrNull("fields")?.EnumerateArrayOrNull()?.Select(EmbedField.Parse).ToArray() ??
|
json.GetPropertyOrNull("fields")?.EnumerateArrayOrNull()?.Select(EmbedField.Parse).ToArray() ??
|
||||||
Array.Empty<EmbedField>();
|
Array.Empty<EmbedField>();
|
||||||
|
|
||||||
|
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||||
|
|
||||||
|
// Under the Discord API model, embeds can only have at most one image.
|
||||||
|
// Because of that, embeds that are rendered with multiple images on the client
|
||||||
|
// (e.g. tweet embeds), are exposed from the API as multiple separate embeds.
|
||||||
|
// Our embed model is consistent with the user-facing side of Discord, so images
|
||||||
|
// are stored as an array. The API will only ever return one image, but we deal
|
||||||
|
// with this by merging related embeds at the end of the message parsing process.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
|
||||||
|
var images =
|
||||||
|
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ??
|
||||||
|
Array.Empty<EmbedImage>();
|
||||||
|
|
||||||
|
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||||
|
|
||||||
|
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
||||||
|
|
||||||
return new Embed(
|
return new Embed(
|
||||||
title,
|
title,
|
||||||
|
kind,
|
||||||
url,
|
url,
|
||||||
timestamp,
|
timestamp,
|
||||||
color,
|
color,
|
||||||
@@ -59,7 +83,8 @@ public partial record Embed
|
|||||||
description,
|
description,
|
||||||
fields,
|
fields,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
image,
|
images,
|
||||||
|
video,
|
||||||
footer
|
footer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-types
|
||||||
|
public enum EmbedKind
|
||||||
|
{
|
||||||
|
Rich,
|
||||||
|
Image,
|
||||||
|
Video,
|
||||||
|
Gifv,
|
||||||
|
Link
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using JsonExtensions.Reading;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure
|
||||||
|
public record EmbedVideo(
|
||||||
|
string? Url,
|
||||||
|
string? ProxyUrl,
|
||||||
|
int? Width,
|
||||||
|
int? Height)
|
||||||
|
{
|
||||||
|
public static EmbedVideo Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
|
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
|
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
||||||
|
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
||||||
|
|
||||||
|
return new EmbedVideo(url, proxyUrl, width, height);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using DiscordChatExporter.Core.Utils;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
|
||||||
|
|
||||||
public record PlainImageEmbedProjection(string Url)
|
|
||||||
{
|
|
||||||
public static PlainImageEmbedProjection? TryResolve(Embed embed)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Has to be an embed without any data (except URL and image)
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title) ||
|
|
||||||
embed.Timestamp is not null ||
|
|
||||||
embed.Author is not null ||
|
|
||||||
!string.IsNullOrWhiteSpace(embed.Description) ||
|
|
||||||
embed.Fields.Any() ||
|
|
||||||
embed.Footer is not null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Has to be an image file
|
|
||||||
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new PlainImageEmbedProjection(embed.Url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,9 @@ public partial record SpotifyTrackEmbedProjection
|
|||||||
|
|
||||||
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
|
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
|
||||||
{
|
{
|
||||||
|
if (embed.Kind != EmbedKind.Link)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public partial record YouTubeVideoEmbedProjection(string VideoId)
|
|||||||
public partial record YouTubeVideoEmbedProjection
|
public partial record YouTubeVideoEmbedProjection
|
||||||
{
|
{
|
||||||
// Adapted from YoutubeExplode
|
// Adapted from YoutubeExplode
|
||||||
// https://github.com/Tyrrrz/YoutubeExplode/blob/5be164be20019783913f76fcc98f18c65aebe9f0/YoutubeExplode/Videos/VideoId.cs#L34-L64
|
// https://github.com/Tyrrrz/YoutubeExplode/blob/bc700b631bd105d0be208a88116347034bdca88b/YoutubeExplode/Videos/VideoId.cs#L40-L62
|
||||||
private static string? TryParseVideoId(string embedUrl)
|
private static string? TryParseVideoId(string embedUrl)
|
||||||
{
|
{
|
||||||
// Regular URL
|
// Regular URL
|
||||||
@@ -31,11 +31,20 @@ public partial record YouTubeVideoEmbedProjection
|
|||||||
if (!string.IsNullOrWhiteSpace(embedMatch))
|
if (!string.IsNullOrWhiteSpace(embedMatch))
|
||||||
return embedMatch;
|
return embedMatch;
|
||||||
|
|
||||||
|
// Shorts URL
|
||||||
|
// https://www.youtube.com/shorts/sKL1vjP0tIo
|
||||||
|
var shortsMatch = Regex.Match(embedUrl, @"youtube\..+?/shorts/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(shortsMatch))
|
||||||
|
return shortsMatch;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
|
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
|
||||||
{
|
{
|
||||||
|
if (embed.Kind != EmbedKind.Video)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|||||||
@@ -10,40 +10,48 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||||||
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
||||||
public partial record Emoji(
|
public partial record Emoji(
|
||||||
// Only present on custom emoji
|
// Only present on custom emoji
|
||||||
string? Id,
|
Snowflake? Id,
|
||||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||||
string Name,
|
string Name,
|
||||||
bool IsAnimated,
|
bool IsAnimated,
|
||||||
string ImageUrl)
|
string ImageUrl)
|
||||||
{
|
{
|
||||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
public string Code => Id is not null
|
||||||
? Name
|
? Name
|
||||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Emoji
|
public partial record Emoji
|
||||||
{
|
{
|
||||||
private static string GetTwemojiName(string name) => string.Join("-",
|
private static string GetTwemojiId(string name)
|
||||||
name
|
{
|
||||||
.GetRunes()
|
var runes = name.GetRunes().ToArray();
|
||||||
// Variant selector rune is skipped in Twemoji names
|
|
||||||
.Where(r => r.Value != 0xfe0f)
|
|
||||||
.Select(r => r.Value.ToString("x"))
|
|
||||||
);
|
|
||||||
|
|
||||||
private static string GetImageUrl(string id, bool isAnimated) => isAnimated
|
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner.
|
||||||
|
// VS = 0xfe0f; ZWJ = 0x200d.
|
||||||
|
var filteredRunes = runes.Any(r => r.Value == 0x200d)
|
||||||
|
? runes
|
||||||
|
: runes.Where(r => r.Value != 0xfe0f);
|
||||||
|
|
||||||
|
return string.Join(
|
||||||
|
"-",
|
||||||
|
filteredRunes.Select(r => r.Value.ToString("x"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated
|
||||||
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
||||||
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||||
|
|
||||||
private static string GetImageUrl(string name) =>
|
private static string GetImageUrl(string name) =>
|
||||||
$"https://twemoji.maxcdn.com/2/svg/{GetTwemojiName(name)}.svg";
|
$"https://twemoji.maxcdn.com/v/latest/svg/{GetTwemojiId(name)}.svg";
|
||||||
|
|
||||||
public static string GetImageUrl(string? id, string? name, bool isAnimated)
|
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
|
||||||
{
|
{
|
||||||
// Custom emoji
|
// Custom emoji
|
||||||
if (!string.IsNullOrWhiteSpace(id))
|
if (id is not null)
|
||||||
return GetImageUrl(id, isAnimated);
|
return GetImageUrl(id.Value, isAnimated);
|
||||||
|
|
||||||
// Standard emoji
|
// Standard emoji
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
@@ -55,10 +63,9 @@ public partial record Emoji
|
|||||||
|
|
||||||
public static Emoji Parse(JsonElement json)
|
public static Emoji Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull();
|
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
|
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
||||||
|
|
||||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
var imageUrl = GetImageUrl(id, name, isAnimated);
|
||||||
|
|
||||||
return new Emoji(
|
return new Emoji(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
@@ -17,15 +18,21 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
|||||||
private static string GetDefaultIconUrl() =>
|
private static string GetDefaultIconUrl() =>
|
||||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
|
|
||||||
private static string GetIconUrl(Snowflake id, string iconHash) =>
|
private static string GetIconUrl(Snowflake id, string iconHash)
|
||||||
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
|
{
|
||||||
|
var extension = iconHash.StartsWith("a_", StringComparison.Ordinal)
|
||||||
|
? "gif"
|
||||||
|
: "png";
|
||||||
|
|
||||||
|
return $"https://cdn.discordapp.com/icons/{id}/{iconHash}.{extension}";
|
||||||
|
}
|
||||||
|
|
||||||
public static Guild Parse(JsonElement json)
|
public static Guild Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var name = json.GetProperty("name").GetNonNullString();
|
var name = json.GetProperty("name").GetNonNullString();
|
||||||
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
|
|
||||||
|
|
||||||
|
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
||||||
? GetIconUrl(id, iconHash)
|
? GetIconUrl(id, iconHash)
|
||||||
: GetDefaultIconUrl();
|
: GetDefaultIconUrl();
|
||||||
|
|||||||
@@ -21,23 +21,82 @@ public record Message(
|
|||||||
string Content,
|
string Content,
|
||||||
IReadOnlyList<Attachment> Attachments,
|
IReadOnlyList<Attachment> Attachments,
|
||||||
IReadOnlyList<Embed> Embeds,
|
IReadOnlyList<Embed> Embeds,
|
||||||
|
IReadOnlyList<Sticker> Stickers,
|
||||||
IReadOnlyList<Reaction> Reactions,
|
IReadOnlyList<Reaction> Reactions,
|
||||||
IReadOnlyList<User> MentionedUsers,
|
IReadOnlyList<User> MentionedUsers,
|
||||||
MessageReference? Reference,
|
MessageReference? Reference,
|
||||||
Message? ReferencedMessage) : IHasId
|
Message? ReferencedMessage) : IHasId
|
||||||
{
|
{
|
||||||
|
private static IReadOnlyList<Embed> NormalizeEmbeds(IReadOnlyList<Embed> embeds)
|
||||||
|
{
|
||||||
|
if (embeds.Count <= 1)
|
||||||
|
return embeds;
|
||||||
|
|
||||||
|
// Discord API doesn't support embeds with multiple images, even though the Discord client does.
|
||||||
|
// To work around this, it seems that the API returns multiple consecutive embeds with different images,
|
||||||
|
// which are then merged together on the client. We need to replicate the same behavior ourselves.
|
||||||
|
// Currently, only known case where this workaround is required is Twitter embeds.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
|
||||||
|
|
||||||
|
var normalizedEmbeds = new List<Embed>();
|
||||||
|
|
||||||
|
for (var i = 0; i < embeds.Count; i++)
|
||||||
|
{
|
||||||
|
var embed = embeds[i];
|
||||||
|
|
||||||
|
if (embed.Url?.Contains("://twitter.com/", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
// Find embeds with the same URL that only contain a single image and nothing else
|
||||||
|
var trailingEmbeds = embeds
|
||||||
|
.Skip(i + 1)
|
||||||
|
.TakeWhile(e =>
|
||||||
|
e.Url == embed.Url &&
|
||||||
|
e.Timestamp is null &&
|
||||||
|
e.Author is null &&
|
||||||
|
e.Color is null &&
|
||||||
|
string.IsNullOrWhiteSpace(e.Description) &&
|
||||||
|
!e.Fields.Any() &&
|
||||||
|
e.Images.Count == 1 &&
|
||||||
|
e.Footer is null
|
||||||
|
)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (trailingEmbeds.Any())
|
||||||
|
{
|
||||||
|
// Concatenate all images into one embed
|
||||||
|
var images = embed.Images.Concat(trailingEmbeds.SelectMany(e => e.Images)).ToArray();
|
||||||
|
normalizedEmbeds.Add(embed with { Images = images });
|
||||||
|
|
||||||
|
i += trailingEmbeds.Length;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
normalizedEmbeds.Add(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
normalizedEmbeds.Add(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedEmbeds;
|
||||||
|
}
|
||||||
|
|
||||||
public static Message Parse(JsonElement json)
|
public static Message Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
|
var kind = (MessageKind)json.GetProperty("type").GetInt32();
|
||||||
var author = json.GetProperty("author").Pipe(User.Parse);
|
var author = json.GetProperty("author").Pipe(User.Parse);
|
||||||
|
|
||||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||||
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")
|
var callEndedTimestamp = json
|
||||||
?.GetDateTimeOffset();
|
.GetPropertyOrNull("call")?
|
||||||
var kind = (MessageKind)json.GetProperty("type").GetInt32();
|
.GetPropertyOrNull("ended_timestamp")?
|
||||||
|
.GetDateTimeOffset();
|
||||||
|
|
||||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
|
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
|
||||||
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
|
||||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
|
||||||
|
|
||||||
var content = kind switch
|
var content = kind switch
|
||||||
{
|
{
|
||||||
@@ -48,6 +107,7 @@ public record Message(
|
|||||||
MessageKind.ChannelNameChange => "Changed the channel name.",
|
MessageKind.ChannelNameChange => "Changed the channel name.",
|
||||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||||
|
MessageKind.ThreadCreated => "Started a thread.",
|
||||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||||
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
|
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
|
||||||
};
|
};
|
||||||
@@ -56,9 +116,14 @@ public record Message(
|
|||||||
json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ??
|
json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ??
|
||||||
Array.Empty<Attachment>();
|
Array.Empty<Attachment>();
|
||||||
|
|
||||||
var embeds =
|
var embeds = NormalizeEmbeds(
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
|
||||||
Array.Empty<Embed>();
|
Array.Empty<Embed>()
|
||||||
|
);
|
||||||
|
|
||||||
|
var stickers =
|
||||||
|
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??
|
||||||
|
Array.Empty<Sticker>();
|
||||||
|
|
||||||
var reactions =
|
var reactions =
|
||||||
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ??
|
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ??
|
||||||
@@ -68,6 +133,9 @@ public record Message(
|
|||||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() ??
|
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() ??
|
||||||
Array.Empty<User>();
|
Array.Empty<User>();
|
||||||
|
|
||||||
|
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
||||||
|
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||||
|
|
||||||
return new Message(
|
return new Message(
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
@@ -79,6 +147,7 @@ public record Message(
|
|||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
embeds,
|
embeds,
|
||||||
|
stickers,
|
||||||
reactions,
|
reactions,
|
||||||
mentionedUsers,
|
mentionedUsers,
|
||||||
messageReference,
|
messageReference,
|
||||||
|
|||||||
@@ -11,5 +11,11 @@ public enum MessageKind
|
|||||||
ChannelIconChange = 5,
|
ChannelIconChange = 5,
|
||||||
ChannelPinnedMessage = 6,
|
ChannelPinnedMessage = 6,
|
||||||
GuildMemberJoin = 7,
|
GuildMemberJoin = 7,
|
||||||
|
ThreadCreated = 18,
|
||||||
Reply = 19
|
Reply = 19
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class MessageKindExtensions
|
||||||
|
{
|
||||||
|
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
|
||||||
|
}
|
||||||
@@ -9,9 +9,20 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
|||||||
{
|
{
|
||||||
public static MessageReference Parse(JsonElement json)
|
public static MessageReference Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var messageId = json.GetPropertyOrNull("message_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
var messageId = json
|
||||||
var channelId = json.GetPropertyOrNull("channel_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
.GetPropertyOrNull("message_id")?
|
||||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
var channelId = json
|
||||||
|
.GetPropertyOrNull("channel_id")?
|
||||||
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
var guildId = json
|
||||||
|
.GetPropertyOrNull("guild_id")?
|
||||||
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
return new MessageReference(messageId, channelId, guildId);
|
return new MessageReference(messageId, channelId, guildId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/sticker#sticker-resource
|
||||||
|
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
|
||||||
|
{
|
||||||
|
private static string GetSourceUrl(Snowflake id, StickerFormat format)
|
||||||
|
{
|
||||||
|
var extension = format == StickerFormat.Lottie ? "json" : "png";
|
||||||
|
return $"https://discord.com/stickers/{id}.{extension}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Sticker Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
|
var name = json.GetProperty("name").GetNonNullString();
|
||||||
|
var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
|
||||||
|
var sourceUrl = GetSourceUrl(id, format);
|
||||||
|
|
||||||
|
return new Sticker(id, name, format, sourceUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
public enum StickerFormat
|
||||||
|
{
|
||||||
|
Png = 1,
|
||||||
|
PngAnimated = 2,
|
||||||
|
Lottie = 3
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ public partial record User
|
|||||||
? "gif"
|
? "gif"
|
||||||
: "png";
|
: "png";
|
||||||
|
|
||||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=128";
|
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=512";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static User Parse(JsonElement json)
|
public static User Parse(JsonElement json)
|
||||||
@@ -39,8 +39,8 @@ public partial record User
|
|||||||
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
|
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
|
||||||
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
||||||
var name = json.GetProperty("username").GetNonNullString();
|
var name = json.GetProperty("username").GetNonNullString();
|
||||||
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
|
|
||||||
|
|
||||||
|
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
||||||
? GetAvatarUrl(id, avatarHash)
|
? GetAvatarUrl(id, avatarHash)
|
||||||
: GetDefaultAvatarUrl(discriminator);
|
: GetDefaultAvatarUrl(discriminator);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -12,6 +12,7 @@ using DiscordChatExporter.Core.Discord.Data;
|
|||||||
using DiscordChatExporter.Core.Exceptions;
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using Gress;
|
||||||
using JsonExtensions.Http;
|
using JsonExtensions.Http;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
@@ -20,64 +21,101 @@ namespace DiscordChatExporter.Core.Discord;
|
|||||||
public class DiscordClient
|
public class DiscordClient
|
||||||
{
|
{
|
||||||
private readonly string _token;
|
private readonly string _token;
|
||||||
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
|
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
|
||||||
|
|
||||||
private TokenKind _tokenKind = TokenKind.Unknown;
|
private TokenKind? _resolvedTokenKind;
|
||||||
|
|
||||||
public DiscordClient(string token) => _token = token;
|
public DiscordClient(string token) => _token = token;
|
||||||
|
|
||||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||||
string url,
|
string url,
|
||||||
bool isBot,
|
TokenKind tokenKind,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
|
|
||||||
request.Headers.Authorization = isBot
|
// Don't validate because the token can have special characters
|
||||||
? new AuthenticationHeaderValue("Bot", _token)
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||||
: new AuthenticationHeaderValue(_token);
|
request.Headers.TryAddWithoutValidation(
|
||||||
|
"Authorization",
|
||||||
|
tokenKind == TokenKind.Bot
|
||||||
|
? $"Bot {_token}"
|
||||||
|
: _token
|
||||||
|
);
|
||||||
|
|
||||||
return await Http.Client.SendAsync(
|
var response = await Http.Client.SendAsync(
|
||||||
request,
|
request,
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
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 => TimeSpan.FromSeconds(double.Parse(s, CultureInfo.InvariantCulture)));
|
||||||
|
|
||||||
|
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;
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<TokenKind> GetTokenKindAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Try authenticating as a user
|
||||||
|
using var userResponse = await GetResponseAsync(
|
||||||
|
"users/@me",
|
||||||
|
TokenKind.User,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||||
|
return TokenKind.User;
|
||||||
|
|
||||||
|
// Try authenticating as a bot
|
||||||
|
using var botResponse = await GetResponseAsync(
|
||||||
|
"users/@me",
|
||||||
|
TokenKind.Bot,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||||
|
return TokenKind.Bot;
|
||||||
|
|
||||||
|
throw DiscordChatExporterException.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||||
string url,
|
string url,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
|
var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
|
||||||
{
|
return await GetResponseAsync(url, tokenKind, cancellationToken);
|
||||||
if (_tokenKind == TokenKind.User)
|
|
||||||
return await GetResponseAsync(url, false, innerCancellationToken);
|
|
||||||
|
|
||||||
if (_tokenKind == TokenKind.Bot)
|
|
||||||
return await GetResponseAsync(url, true, innerCancellationToken);
|
|
||||||
|
|
||||||
// Try to authenticate as user
|
|
||||||
var userResponse = await GetResponseAsync(url, false, innerCancellationToken);
|
|
||||||
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
|
|
||||||
{
|
|
||||||
_tokenKind = TokenKind.User;
|
|
||||||
return userResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
userResponse.Dispose();
|
|
||||||
|
|
||||||
// Otherwise, try to authenticate as bot
|
|
||||||
var botResponse = await GetResponseAsync(url, true, innerCancellationToken);
|
|
||||||
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
|
|
||||||
{
|
|
||||||
_tokenKind = TokenKind.Bot;
|
|
||||||
return botResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The token is probably invalid altogether.
|
|
||||||
// Return the last response anyway, upstream should handle the error.
|
|
||||||
return botResponse;
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<JsonElement> GetJsonResponseAsync(
|
private async ValueTask<JsonElement> GetJsonResponseAsync(
|
||||||
@@ -178,6 +216,8 @@ public class DiscordClient
|
|||||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
||||||
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Discord positions are not deterministic, so we need to normalize them
|
||||||
|
// because the user may refer to the channel position via file name template.
|
||||||
var position = 0;
|
var position = 0;
|
||||||
|
|
||||||
foreach (var channelJson in responseOrdered)
|
foreach (var channelJson in responseOrdered)
|
||||||
@@ -273,7 +313,7 @@ public class DiscordClient
|
|||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake? after = null,
|
Snowflake? after = null,
|
||||||
Snowflake? before = null,
|
Snowflake? before = null,
|
||||||
IProgress<double>? progress = null,
|
IProgress<Percentage>? progress = null,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Get the last message in the specified range.
|
// Get the last message in the specified range.
|
||||||
@@ -322,16 +362,13 @@ public class DiscordClient
|
|||||||
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
|
|
||||||
if (totalDuration > TimeSpan.Zero)
|
progress.Report(Percentage.FromFraction(
|
||||||
{
|
// Avoid division by zero if all messages have the exact same timestamp
|
||||||
progress.Report(exportedDuration / totalDuration);
|
// (which may be the case if there's only one message in the channel)
|
||||||
}
|
totalDuration > TimeSpan.Zero
|
||||||
// Avoid division by zero if all messages have the exact same timestamp
|
? exportedDuration / totalDuration
|
||||||
// (which may be the case if there's only one message in the channel)
|
: 1
|
||||||
else
|
));
|
||||||
{
|
|
||||||
progress.Report(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return message;
|
yield return message;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord;
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ public partial record struct Snowflake
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
// As number
|
// As number
|
||||||
if (Regex.IsMatch(str, @"^\d+$") && ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value))
|
if (ulong.TryParse(str, NumberStyles.None, formatProvider, out var value))
|
||||||
{
|
{
|
||||||
return new Snowflake(value);
|
return new Snowflake(value);
|
||||||
}
|
}
|
||||||
@@ -49,7 +48,15 @@ public partial record struct Snowflake
|
|||||||
public static Snowflake Parse(string str) => Parse(str, null);
|
public static Snowflake Parse(string str) => Parse(str, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record struct Snowflake : IComparable<Snowflake>
|
public partial record struct Snowflake : IComparable<Snowflake>, IComparable
|
||||||
{
|
{
|
||||||
public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
|
public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
|
||||||
|
|
||||||
|
public int CompareTo(object? obj)
|
||||||
|
{
|
||||||
|
if (obj is not Snowflake other)
|
||||||
|
throw new ArgumentException($"Object must be of type {nameof(Snowflake)}.");
|
||||||
|
|
||||||
|
return Value.CompareTo(other.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
public enum TokenKind
|
public enum TokenKind
|
||||||
{
|
{
|
||||||
Unknown,
|
|
||||||
User,
|
User,
|
||||||
Bot
|
Bot
|
||||||
}
|
}
|
||||||
@@ -5,14 +5,16 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.0" />
|
|
||||||
<PackageReference Include="Polly" Version="7.2.3" />
|
<PackageReference Include="Polly" Version="7.2.3" />
|
||||||
|
<PackageReference Include="RazorBlade" Version="0.4.1" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||||
|
<PackageReference Include="WebMarkupMin.Core" Version="2.13.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AdditionalFiles Include="Exporting\Writers\Html\*.cshtml" IsRazorTemplate="true" />
|
<RazorBlade Include="Exporting\Writers\Html\*.cshtml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Discord.Data;
|
|||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using Gress;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ public class ChannelExporter
|
|||||||
|
|
||||||
public async ValueTask ExportChannelAsync(
|
public async ValueTask ExportChannelAsync(
|
||||||
ExportRequest request,
|
ExportRequest request,
|
||||||
IProgress<double>? progress = null,
|
IProgress<Percentage>? progress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Build context
|
// Build context
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
|
internal partial class ExportAssetDownloader
|
||||||
|
{
|
||||||
|
private readonly string _workingDirPath;
|
||||||
|
private readonly bool _reuse;
|
||||||
|
|
||||||
|
// File paths of the previously downloaded assets
|
||||||
|
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||||
|
{
|
||||||
|
_workingDirPath = workingDirPath;
|
||||||
|
_reuse = reuse;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||||
|
return cachedFilePath;
|
||||||
|
|
||||||
|
var fileName = GetFileNameFromUrl(url);
|
||||||
|
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||||
|
|
||||||
|
// Reuse existing files if we're allowed to
|
||||||
|
if (!_reuse || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_workingDirPath);
|
||||||
|
|
||||||
|
await Http.ResiliencePolicy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// Download the file
|
||||||
|
using var response = await Http.Client.GetAsync(url, cancellationToken);
|
||||||
|
await using (var output = File.Create(filePath))
|
||||||
|
await response.Content.CopyToAsync(output, cancellationToken);
|
||||||
|
|
||||||
|
// Try to set the file date according to the last-modified header
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||||
|
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||||
|
? date
|
||||||
|
: (DateTimeOffset?) null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastModified is not null)
|
||||||
|
{
|
||||||
|
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// This can apparently fail for some reason.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||||
|
// Updating file dates is not a critical task, so we'll just
|
||||||
|
// ignore exceptions thrown here.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return _pathCache[url] = filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class ExportAssetDownloader
|
||||||
|
{
|
||||||
|
private static string GetUrlHash(string url)
|
||||||
|
{
|
||||||
|
using var hash = SHA256.Create();
|
||||||
|
|
||||||
|
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
||||||
|
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFileNameFromUrl(string url)
|
||||||
|
{
|
||||||
|
var urlHash = GetUrlHash(url);
|
||||||
|
|
||||||
|
// Try to extract file name from URL
|
||||||
|
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||||
|
|
||||||
|
// If it's not there, just use the URL hash as the file name
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
return urlHash;
|
||||||
|
|
||||||
|
// Otherwise, use the original file name but inject the hash in the middle
|
||||||
|
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
var fileExtension = Path.GetExtension(fileName);
|
||||||
|
|
||||||
|
// Probably not a file extension, just a dot in a long file name
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708
|
||||||
|
if (fileExtension.Length > 41)
|
||||||
|
{
|
||||||
|
fileNameWithoutExtension = fileName;
|
||||||
|
fileExtension = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
|
|
||||||
internal class ExportContext
|
internal class ExportContext
|
||||||
{
|
{
|
||||||
private readonly MediaDownloader _mediaDownloader;
|
private readonly ExportAssetDownloader _assetDownloader;
|
||||||
|
|
||||||
public ExportRequest Request { get; }
|
public ExportRequest Request { get; }
|
||||||
|
|
||||||
@@ -35,14 +35,14 @@ internal class ExportContext
|
|||||||
Channels = channels;
|
Channels = channels;
|
||||||
Roles = roles;
|
Roles = roles;
|
||||||
|
|
||||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
|
_assetDownloader = new ExportAssetDownloader(request.OutputAssetsDirPath, request.ShouldReuseAssets);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
||||||
{
|
{
|
||||||
"unix" => date.ToUnixTimeSeconds().ToString(),
|
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||||
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||||
var dateFormat => date.ToLocalString(dateFormat)
|
var format => date.ToLocalString(format)
|
||||||
};
|
};
|
||||||
|
|
||||||
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
|
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
|
||||||
@@ -63,14 +63,14 @@ internal class ExportContext
|
|||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<string> ResolveMediaUrlAsync(string url, CancellationToken cancellationToken = default)
|
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (!Request.ShouldDownloadMedia)
|
if (!Request.ShouldDownloadAssets)
|
||||||
return url;
|
return url;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var filePath = await _mediaDownloader.DownloadAsync(url, cancellationToken);
|
var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken);
|
||||||
|
|
||||||
// We want relative path so that the output files can be copied around without breaking.
|
// We want relative path so that the output files can be copied around without breaking.
|
||||||
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ public partial record ExportRequest(
|
|||||||
Snowflake? Before,
|
Snowflake? Before,
|
||||||
PartitionLimit PartitionLimit,
|
PartitionLimit PartitionLimit,
|
||||||
MessageFilter MessageFilter,
|
MessageFilter MessageFilter,
|
||||||
bool ShouldDownloadMedia,
|
bool ShouldDownloadAssets,
|
||||||
bool ShouldReuseMedia,
|
bool ShouldReuseAssets,
|
||||||
string DateFormat)
|
string DateFormat)
|
||||||
{
|
{
|
||||||
private string? _outputBaseFilePath;
|
private string? _outputBaseFilePath;
|
||||||
@@ -35,49 +35,11 @@ public partial record ExportRequest(
|
|||||||
|
|
||||||
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
|
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
|
||||||
|
|
||||||
public string OutputMediaDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
|
public string OutputAssetsDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record ExportRequest
|
public partial record ExportRequest
|
||||||
{
|
{
|
||||||
private static string GetOutputBaseFilePath(
|
|
||||||
Guild guild,
|
|
||||||
Channel channel,
|
|
||||||
string outputPath,
|
|
||||||
ExportFormat format,
|
|
||||||
Snowflake? after = null,
|
|
||||||
Snowflake? before = null)
|
|
||||||
{
|
|
||||||
// Formats path
|
|
||||||
outputPath = Regex.Replace(outputPath, "%.", m =>
|
|
||||||
PathEx.EscapeFileName(m.Value switch
|
|
||||||
{
|
|
||||||
"%g" => guild.Id.ToString(),
|
|
||||||
"%G" => guild.Name,
|
|
||||||
"%t" => channel.Category.Id.ToString(),
|
|
||||||
"%T" => channel.Category.Name,
|
|
||||||
"%c" => channel.Id.ToString(),
|
|
||||||
"%C" => channel.Name,
|
|
||||||
"%p" => channel.Position?.ToString() ?? "0",
|
|
||||||
"%P" => channel.Category.Position?.ToString() ?? "0",
|
|
||||||
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
|
|
||||||
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
|
|
||||||
"%%" => "%",
|
|
||||||
_ => m.Value
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Output is a directory
|
|
||||||
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
|
|
||||||
{
|
|
||||||
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
|
|
||||||
return Path.Combine(outputPath, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output is a file
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetDefaultOutputFileName(
|
public static string GetDefaultOutputFileName(
|
||||||
Guild guild,
|
Guild guild,
|
||||||
Channel channel,
|
Channel channel,
|
||||||
@@ -93,7 +55,7 @@ public partial record ExportRequest
|
|||||||
// Date range
|
// Date range
|
||||||
if (after is not null || before is not null)
|
if (after is not null || before is not null)
|
||||||
{
|
{
|
||||||
buffer.Append(" (");
|
buffer.Append(' ').Append('(');
|
||||||
|
|
||||||
// Both 'after' and 'before' are set
|
// Both 'after' and 'before' are set
|
||||||
if (after is not null && before is not null)
|
if (after is not null && before is not null)
|
||||||
@@ -111,12 +73,53 @@ public partial record ExportRequest
|
|||||||
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.Append(")");
|
buffer.Append(')');
|
||||||
}
|
}
|
||||||
|
|
||||||
// File extension
|
// File extension
|
||||||
buffer.Append($".{format.GetFileExtension()}");
|
buffer.Append('.').Append(format.GetFileExtension());
|
||||||
|
|
||||||
return PathEx.EscapeFileName(buffer.ToString());
|
return PathEx.EscapeFileName(buffer.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetOutputBaseFilePath(
|
||||||
|
Guild guild,
|
||||||
|
Channel channel,
|
||||||
|
string outputPath,
|
||||||
|
ExportFormat format,
|
||||||
|
Snowflake? after = null,
|
||||||
|
Snowflake? before = null)
|
||||||
|
{
|
||||||
|
// Format path
|
||||||
|
var actualOutputPath = Regex.Replace(
|
||||||
|
outputPath,
|
||||||
|
"%.",
|
||||||
|
m => PathEx.EscapeFileName(m.Value switch
|
||||||
|
{
|
||||||
|
"%g" => guild.Id.ToString(),
|
||||||
|
"%G" => guild.Name,
|
||||||
|
"%t" => channel.Category.Id.ToString(),
|
||||||
|
"%T" => channel.Category.Name,
|
||||||
|
"%c" => channel.Id.ToString(),
|
||||||
|
"%C" => channel.Name,
|
||||||
|
"%p" => channel.Position?.ToString() ?? "0",
|
||||||
|
"%P" => channel.Category.Position?.ToString() ?? "0",
|
||||||
|
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
|
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
|
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
|
||||||
|
"%%" => "%",
|
||||||
|
_ => m.Value
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Output is a directory
|
||||||
|
if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath)))
|
||||||
|
{
|
||||||
|
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
|
||||||
|
return Path.Combine(actualOutputPath, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output is a file
|
||||||
|
return actualOutputPath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,11 +10,19 @@ internal class ContainsMessageFilter : MessageFilter
|
|||||||
|
|
||||||
public ContainsMessageFilter(string text) => _text = text;
|
public ContainsMessageFilter(string text) => _text = text;
|
||||||
|
|
||||||
|
// Match content within word boundaries, between spaces, or as the whole input.
|
||||||
|
// For example, "max" shouldn't match on content "our maximum effort",
|
||||||
|
// but should match on content "our max effort".
|
||||||
|
// Also, "(max)" should match on content "our (max) effort", even though
|
||||||
|
// parentheses are not considered word characters.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909
|
||||||
private bool IsMatch(string? content) =>
|
private bool IsMatch(string? content) =>
|
||||||
!string.IsNullOrWhiteSpace(content) &&
|
!string.IsNullOrWhiteSpace(content) &&
|
||||||
Regex.IsMatch(
|
Regex.IsMatch(
|
||||||
content,
|
content,
|
||||||
"\\b" + Regex.Escape(_text) + "\\b",
|
@"(?:\b|\s|^)" +
|
||||||
|
Regex.Escape(_text) +
|
||||||
|
@"(?:\b|\s|$)",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal class HasMessageFilter : MessageFilter
|
|||||||
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
||||||
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
||||||
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||||
|
MessageContentMatchKind.Pin => message.IsPinned,
|
||||||
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
|
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,6 @@ internal enum MessageContentMatchKind
|
|||||||
File,
|
File,
|
||||||
Video,
|
Video,
|
||||||
Image,
|
Image,
|
||||||
Sound
|
Sound,
|
||||||
|
Pin
|
||||||
}
|
}
|
||||||
@@ -15,87 +15,107 @@ internal static class FilterGrammar
|
|||||||
from close in Character.EqualTo(open)
|
from close in Character.EqualTo(open)
|
||||||
select value;
|
select value;
|
||||||
|
|
||||||
private static readonly TextParser<char> FreeCharacter =
|
|
||||||
Character.Matching(c =>
|
|
||||||
!char.IsWhiteSpace(c) &&
|
|
||||||
// Avoid all special tokens used by the grammar
|
|
||||||
c is not ('(' or ')' or '"' or '\'' or '-' or '|' or '&'),
|
|
||||||
"any character except whitespace or `(`, `)`, `\"`, `'`, `-`, `|`, `&`"
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly TextParser<string> UnquotedString =
|
private static readonly TextParser<string> UnquotedString =
|
||||||
Parse.OneOf(EscapedCharacter, FreeCharacter).AtLeastOnce().Text();
|
Parse.OneOf(
|
||||||
|
EscapedCharacter,
|
||||||
|
// Avoid whitespace as it's treated as an implicit 'and' operator.
|
||||||
|
// Also avoid all special tokens used by other parsers.
|
||||||
|
Character.ExceptIn(' ', '(', ')', '"', '\'', '-', '~', '|', '&')
|
||||||
|
).AtLeastOnce().Text();
|
||||||
|
|
||||||
private static readonly TextParser<string> String =
|
private static readonly TextParser<string> String =
|
||||||
Parse.OneOf(QuotedString, UnquotedString).Named("text string");
|
Parse.OneOf(QuotedString, UnquotedString).Named("text string");
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> ContainsFilter =
|
private static readonly TextParser<MessageFilter> ContainsFilter =
|
||||||
String.Select(v => (MessageFilter) new ContainsMessageFilter(v));
|
String.Select(v => (MessageFilter)new ContainsMessageFilter(v));
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> FromFilter = Span
|
private static readonly TextParser<MessageFilter> FromFilter =
|
||||||
.EqualToIgnoreCase("from:")
|
Span
|
||||||
.IgnoreThen(String)
|
.EqualToIgnoreCase("from:")
|
||||||
.Select(v => (MessageFilter) new FromMessageFilter(v))
|
.Try()
|
||||||
.Named("from:<value>");
|
.IgnoreThen(String)
|
||||||
|
.Select(v => (MessageFilter)new FromMessageFilter(v))
|
||||||
|
.Named("from:<value>");
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> MentionsFilter = Span
|
private static readonly TextParser<MessageFilter> MentionsFilter =
|
||||||
.EqualToIgnoreCase("mentions:")
|
Span
|
||||||
.IgnoreThen(String)
|
.EqualToIgnoreCase("mentions:")
|
||||||
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
|
.Try()
|
||||||
.Named("mentions:<value>");
|
.IgnoreThen(String)
|
||||||
|
.Select(v => (MessageFilter)new MentionsMessageFilter(v))
|
||||||
|
.Named("mentions:<value>");
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> HasFilter = Span
|
private static readonly TextParser<MessageFilter> ReactionFilter =
|
||||||
.EqualToIgnoreCase("has:")
|
Span
|
||||||
.IgnoreThen(Parse.OneOf(
|
.EqualToIgnoreCase("reaction:")
|
||||||
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
.Try()
|
||||||
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
.IgnoreThen(String)
|
||||||
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
.Select(v => (MessageFilter)new ReactionMessageFilter(v))
|
||||||
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
.Named("reaction:<value>");
|
||||||
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
|
||||||
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound))
|
|
||||||
))
|
|
||||||
.Select(k => (MessageFilter) new HasMessageFilter(k))
|
|
||||||
.Named("has:<value>");
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> NegatedFilter = Character
|
private static readonly TextParser<MessageFilter> HasFilter =
|
||||||
.EqualTo('-')
|
Span
|
||||||
.IgnoreThen(Parse.Ref(() => StandaloneFilter!))
|
.EqualToIgnoreCase("has:")
|
||||||
.Select(f => (MessageFilter) new NegatedMessageFilter(f));
|
.Try()
|
||||||
|
.IgnoreThen(Parse.OneOf(
|
||||||
|
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
||||||
|
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
||||||
|
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||||
|
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||||
|
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||||
|
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
|
||||||
|
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
|
||||||
|
))
|
||||||
|
.Select(k => (MessageFilter)new HasMessageFilter(k))
|
||||||
|
.Named("has:<value>");
|
||||||
|
|
||||||
|
// Make sure that property-based filters like 'has:link' don't prevent text like 'hello' from being parsed.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909#issuecomment-1227575455
|
||||||
|
private static readonly TextParser<MessageFilter> PrimitiveFilter =
|
||||||
|
Parse.OneOf(
|
||||||
|
FromFilter,
|
||||||
|
MentionsFilter,
|
||||||
|
ReactionFilter,
|
||||||
|
HasFilter,
|
||||||
|
ContainsFilter
|
||||||
|
);
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> GroupedFilter =
|
private static readonly TextParser<MessageFilter> GroupedFilter =
|
||||||
from open in Character.EqualTo('(')
|
from open in Character.EqualTo('(')
|
||||||
from content in Parse.Ref(() => BinaryExpressionFilter!).Token()
|
from content in Parse.Ref(() => ChainedFilter!).Token()
|
||||||
from close in Character.EqualTo(')')
|
from close in Character.EqualTo(')')
|
||||||
select content;
|
select content;
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> StandaloneFilter = Parse.OneOf(
|
private static readonly TextParser<MessageFilter> NegatedFilter =
|
||||||
GroupedFilter,
|
Character
|
||||||
FromFilter,
|
// Dash is annoying to use from CLI due to conflicts with options, so we provide tilde as an alias
|
||||||
MentionsFilter,
|
.In('-', '~')
|
||||||
HasFilter,
|
.IgnoreThen(Parse.OneOf(GroupedFilter, PrimitiveFilter))
|
||||||
ContainsFilter
|
.Select(f => (MessageFilter)new NegatedMessageFilter(f));
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> UnaryExpressionFilter = Parse.OneOf(
|
private static readonly TextParser<MessageFilter> ChainedFilter =
|
||||||
NegatedFilter,
|
Parse.Chain(
|
||||||
StandaloneFilter
|
// Operator
|
||||||
);
|
Parse.OneOf(
|
||||||
|
// Explicit operator
|
||||||
private static readonly TextParser<MessageFilter> BinaryExpressionFilter = Parse.Chain(
|
Character.In('|', '&').Token().Try(),
|
||||||
Parse.OneOf(
|
// Implicit operator (resolves to 'and')
|
||||||
// Explicit operator
|
Character.EqualTo(' ').AtLeastOnce().IgnoreThen(Parse.Return(' '))
|
||||||
Character.In('|', '&').Token().Try(),
|
),
|
||||||
// Implicit operator (resolves to 'and')
|
// Operand
|
||||||
Character.WhiteSpace.AtLeastOnce().IgnoreThen(Parse.Return(' '))
|
Parse.OneOf(
|
||||||
),
|
NegatedFilter,
|
||||||
UnaryExpressionFilter,
|
GroupedFilter,
|
||||||
(op, left, right) => op switch
|
PrimitiveFilter
|
||||||
{
|
),
|
||||||
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
// Reducer
|
||||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
(op, left, right) => op switch
|
||||||
}
|
{
|
||||||
);
|
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
||||||
|
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
public static readonly TextParser<MessageFilter> Filter =
|
public static readonly TextParser<MessageFilter> Filter =
|
||||||
BinaryExpressionFilter.Token().AtEnd();
|
ChainedFilter.Token().AtEnd();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class ReactionMessageFilter : MessageFilter
|
||||||
|
{
|
||||||
|
private readonly string _value;
|
||||||
|
|
||||||
|
public ReactionMessageFilter(string value) => _value = value;
|
||||||
|
|
||||||
|
public override bool IsMatch(Message message) => message.Reactions.Any(r =>
|
||||||
|
string.Equals(_value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DiscordChatExporter.Core.Utils;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
|
||||||
|
|
||||||
internal partial class MediaDownloader
|
|
||||||
{
|
|
||||||
private readonly string _workingDirPath;
|
|
||||||
private readonly bool _reuseMedia;
|
|
||||||
|
|
||||||
// File paths of already downloaded media
|
|
||||||
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public MediaDownloader(string workingDirPath, bool reuseMedia)
|
|
||||||
{
|
|
||||||
_workingDirPath = workingDirPath;
|
|
||||||
_reuseMedia = reuseMedia;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
|
||||||
return cachedFilePath;
|
|
||||||
|
|
||||||
var fileName = GetFileNameFromUrl(url);
|
|
||||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
|
||||||
|
|
||||||
// Reuse existing files if we're allowed to
|
|
||||||
if (_reuseMedia && File.Exists(filePath))
|
|
||||||
return _pathCache[url] = filePath;
|
|
||||||
|
|
||||||
Directory.CreateDirectory(_workingDirPath);
|
|
||||||
|
|
||||||
// This retries on IOExceptions which is dangerous as we're also working with files
|
|
||||||
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
// Download the file
|
|
||||||
using var response = await Http.Client.GetAsync(url, cancellationToken);
|
|
||||||
await using (var output = File.Create(filePath))
|
|
||||||
{
|
|
||||||
await response.Content.CopyToAsync(output, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to set the file date according to the last-modified header
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
|
||||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
|
||||||
? date
|
|
||||||
: (DateTimeOffset?) null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastModified is not null)
|
|
||||||
{
|
|
||||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// This can apparently fail for some reason.
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
|
||||||
// Updating file dates is not a critical task, so we'll just
|
|
||||||
// ignore exceptions thrown here.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return _pathCache[url] = filePath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal partial class MediaDownloader
|
|
||||||
{
|
|
||||||
private static string GetUrlHash(string url)
|
|
||||||
{
|
|
||||||
using var hash = SHA256.Create();
|
|
||||||
|
|
||||||
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
|
||||||
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetFileNameFromUrl(string url)
|
|
||||||
{
|
|
||||||
var urlHash = GetUrlHash(url);
|
|
||||||
|
|
||||||
// Try to extract file name from URL
|
|
||||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
|
||||||
|
|
||||||
// If it's not there, just use the URL hash as the file name
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
|
||||||
return urlHash;
|
|
||||||
|
|
||||||
// Otherwise, use the original file name but inject the hash in the middle
|
|
||||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
|
||||||
var fileExtension = Path.GetExtension(fileName);
|
|
||||||
|
|
||||||
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,8 +19,10 @@ internal partial class CsvMessageWriter : MessageWriter
|
|||||||
_writer = new StreamWriter(stream);
|
_writer = new StreamWriter(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatMarkdown(string? markdown) =>
|
private ValueTask<string> FormatMarkdownAsync(
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
string markdown,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken);
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
|
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
|
||||||
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
||||||
@@ -37,7 +39,7 @@ internal partial class CsvMessageWriter : MessageWriter
|
|||||||
|
|
||||||
buffer
|
buffer
|
||||||
.AppendIfNotEmpty(',')
|
.AppendIfNotEmpty(',')
|
||||||
.Append(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
.Append(await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
||||||
@@ -84,7 +86,7 @@ internal partial class CsvMessageWriter : MessageWriter
|
|||||||
await _writer.WriteAsync(',');
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
// Message content
|
// Message content
|
||||||
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content)));
|
await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content, cancellationToken)));
|
||||||
await _writer.WriteAsync(',');
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
|
internal static class MessageExtensions
|
||||||
|
{
|
||||||
|
// Message content is hidden if it's a link to an embedded media
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||||
|
public static bool IsContentHidden(this Message message)
|
||||||
|
{
|
||||||
|
if (message.Embeds.Count != 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var embed = message.Embeds[0];
|
||||||
|
|
||||||
|
return
|
||||||
|
string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
|
||||||
|
|
||||||
// Used for grouping contiguous messages in HTML export
|
|
||||||
internal partial class MessageGroup
|
|
||||||
{
|
|
||||||
public User Author { get; }
|
|
||||||
|
|
||||||
public DateTimeOffset Timestamp { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { get; }
|
|
||||||
|
|
||||||
public MessageReference? Reference { get; }
|
|
||||||
|
|
||||||
public Message? ReferencedMessage {get; }
|
|
||||||
|
|
||||||
public MessageGroup(
|
|
||||||
User author,
|
|
||||||
DateTimeOffset timestamp,
|
|
||||||
MessageReference? reference,
|
|
||||||
Message? referencedMessage,
|
|
||||||
IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
Author = author;
|
|
||||||
Timestamp = timestamp;
|
|
||||||
Reference = reference;
|
|
||||||
ReferencedMessage = referencedMessage;
|
|
||||||
Messages = messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal partial class MessageGroup
|
|
||||||
{
|
|
||||||
public static bool CanJoin(Message message1, Message message2) =>
|
|
||||||
// Must be from the same author
|
|
||||||
message1.Author.Id == message2.Author.Id &&
|
|
||||||
// Author's name must not have changed between messages
|
|
||||||
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
|
||||||
// Duration between messages must be 7 minutes or less
|
|
||||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
|
|
||||||
// Other message must not be a reply
|
|
||||||
message2.Reference is null;
|
|
||||||
|
|
||||||
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
var first = messages.First();
|
|
||||||
|
|
||||||
return new MessageGroup(
|
|
||||||
first.Author,
|
|
||||||
first.Timestamp,
|
|
||||||
first.Reference,
|
|
||||||
first.ReferencedMessage,
|
|
||||||
messages
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,430 +1,539 @@
|
|||||||
@using System
|
@using System
|
||||||
|
@using System.Collections.Generic
|
||||||
@using System.Linq
|
@using System.Linq
|
||||||
|
@using System.Threading
|
||||||
@using System.Threading.Tasks
|
@using System.Threading.Tasks
|
||||||
@using DiscordChatExporter.Core.Exporting.Writers.Html;
|
@using DiscordChatExporter.Core.Discord.Data
|
||||||
|
@using DiscordChatExporter.Core.Discord.Data.Embeds
|
||||||
|
@using DiscordChatExporter.Core.Exporting
|
||||||
|
@using DiscordChatExporter.Core.Exporting.Writers.Html
|
||||||
|
@using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
||||||
|
@using DiscordChatExporter.Core.Utils.Extensions
|
||||||
|
|
||||||
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
@inherits RazorBlade.HtmlTemplate
|
||||||
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
|
|
||||||
|
@functions {
|
||||||
|
public required ExportContext ExportContext { get; init; }
|
||||||
|
|
||||||
|
public required IReadOnlyList<Message> Messages { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
@{
|
@{
|
||||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
ValueTask<string> ResolveAssetUrlAsync(string url) =>
|
||||||
|
ExportContext.ResolveAssetUrlAsync(url, CancellationToken);
|
||||||
|
|
||||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
string FormatDate(DateTimeOffset date) =>
|
||||||
|
ExportContext.FormatDate(date);
|
||||||
|
|
||||||
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
ValueTask<string> FormatMarkdownAsync(string markdown) =>
|
||||||
|
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown, true, CancellationToken);
|
||||||
|
|
||||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
ValueTask<string> FormatEmbedMarkdownAsync(string markdown) =>
|
||||||
|
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown, false, CancellationToken);
|
||||||
|
|
||||||
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
var firstMessage = Messages.First();
|
||||||
|
|
||||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
var userMember = ExportContext.TryGetMember(firstMessage.Author.Id);
|
||||||
|
|
||||||
var userColorStyle = userColor is not null
|
var userColor = ExportContext.TryGetUserColor(firstMessage.Author.Id);
|
||||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
|
||||||
|
var userNick = firstMessage.Author.IsBot
|
||||||
|
? firstMessage.Author.Name
|
||||||
|
: userMember?.Nick ?? firstMessage.Author.Name;
|
||||||
|
|
||||||
|
var referencedUserMember = firstMessage.ReferencedMessage is not null
|
||||||
|
? ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var userNick = Model.MessageGroup.Author.IsBot
|
var referencedUserColor = firstMessage.ReferencedMessage is not null
|
||||||
? Model.MessageGroup.Author.Name
|
? ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id)
|
||||||
: userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
|
||||||
|
|
||||||
var referencedUserMember = Model.MessageGroup.ReferencedMessage is not null
|
|
||||||
? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var referencedUserColor = Model.MessageGroup.ReferencedMessage is not null
|
var referencedUserNick = firstMessage.ReferencedMessage is not null
|
||||||
? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id)
|
? firstMessage.ReferencedMessage.Author.IsBot
|
||||||
: null;
|
? firstMessage.ReferencedMessage.Author.Name
|
||||||
|
: referencedUserMember?.Nick ?? firstMessage.ReferencedMessage.Author.Name
|
||||||
var referencedUserColorStyle = referencedUserColor is not null
|
|
||||||
? $"color: rgb({referencedUserColor?.R},{referencedUserColor?.G},{referencedUserColor?.B})"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var referencedUserNick = Model.MessageGroup.ReferencedMessage is not null
|
|
||||||
? Model.MessageGroup.ReferencedMessage.Author.IsBot
|
|
||||||
? Model.MessageGroup.ReferencedMessage.Author.Name
|
|
||||||
: referencedUserMember?.Nick ?? Model.MessageGroup.ReferencedMessage.Author.Name
|
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
@{/* Referenced message */}
|
@foreach (var (message, i) in Messages.WithIndex())
|
||||||
@if (Model.MessageGroup.Reference is not null)
|
{
|
||||||
{
|
var isFirst = i == 0;
|
||||||
<div class="chatlog__reference-symbol"></div>
|
|
||||||
<div class="chatlog__reference">
|
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
|
||||||
@if (Model.MessageGroup.ReferencedMessage is not null)
|
<div class="chatlog__message">
|
||||||
|
@{/* System notification */}
|
||||||
|
@if (message.Kind.IsSystemNotification())
|
||||||
{
|
{
|
||||||
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
// System notifications are grouped even if the message author is different.
|
||||||
<span class="chatlog__reference-name" title="@Model.MessageGroup.ReferencedMessage.Author.FullName" style="@referencedUserColorStyle">@referencedUserNick</span>
|
// That's why we have to update the user values with the author of the current message.
|
||||||
<div class="chatlog__reference-content">
|
userMember = ExportContext.TryGetMember(message.Author.Id);
|
||||||
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
|
userColor = ExportContext.TryGetUserColor(message.Author.Id);
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content))
|
userNick = message.Author.IsBot
|
||||||
|
? message.Author.Name
|
||||||
|
: userMember?.Nick ?? message.Author.Name;
|
||||||
|
|
||||||
|
<div class="chatlog__message-aside">
|
||||||
|
<svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chatlog__message-primary">
|
||||||
|
@{/* Author name */}
|
||||||
|
<span class="chatlog__system-notification-author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||||
|
|
||||||
|
@{/* System notification content */}
|
||||||
|
<span class="chatlog__system-notification-content">
|
||||||
|
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
|
||||||
{
|
{
|
||||||
@Raw(FormatEmbedMarkdown(Model.MessageGroup.ReferencedMessage.Content))
|
<span> pinned</span>
|
||||||
}
|
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId"> a message</a>
|
||||||
else if (Model.MessageGroup.ReferencedMessage.Attachments.Any() || Model.MessageGroup.ReferencedMessage.Embeds.Any())
|
<span> to this channel.</span>
|
||||||
{
|
|
||||||
<em>Click to see attachment</em> <span>🖼️</span>
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<em>Click to see original message</em>
|
<span>@message.Content.ToLowerInvariant()</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp is not null)
|
@{/* Timestamp */}
|
||||||
{
|
<span class="chatlog__system-notification-timestamp">
|
||||||
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(Model.MessageGroup.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
|
||||||
}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
// Regular message
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="chatlog__reference-unknown">
|
<div class="chatlog__message-aside">
|
||||||
Original message was deleted or could not be loaded.
|
@if (isFirst)
|
||||||
</span>
|
{
|
||||||
}
|
// Reference symbol
|
||||||
</div>
|
if (message.Reference is not null)
|
||||||
}
|
{
|
||||||
|
<div class="chatlog__reference-symbol"></div>
|
||||||
|
}
|
||||||
|
|
||||||
@{/* Avatar */}
|
// Avatar
|
||||||
<div class="chatlog__author-avatar-container">
|
<img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||||
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
}
|
||||||
</div>
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="chatlog__messages">
|
<div class="chatlog__message-primary">
|
||||||
@{/* Author name */}
|
@if (isFirst)
|
||||||
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
|
{
|
||||||
|
// Reference
|
||||||
|
if (message.Reference is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__reference">
|
||||||
|
@if (message.ReferencedMessage is not null)
|
||||||
|
{
|
||||||
|
<img class="chatlog__reference-avatar" src="@await ResolveAssetUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||||
|
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
|
||||||
|
<div class="chatlog__reference-content">
|
||||||
|
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
|
||||||
|
{
|
||||||
|
<!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(message.ReferencedMessage.Content))<!--/wmm:ignore-->
|
||||||
|
}
|
||||||
|
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
|
||||||
|
{
|
||||||
|
<em>Click to see attachment</em>
|
||||||
|
<span>🖼️</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<em>Click to see original message</em>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
@{/* Bot tag */}
|
@if (message.ReferencedMessage.EditedTimestamp is not null)
|
||||||
@if (Model.MessageGroup.Author.IsBot)
|
{
|
||||||
{
|
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
||||||
<span class="chatlog__bot-tag">BOT</span>
|
}
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__reference-unknown">
|
||||||
|
Original message was deleted or could not be loaded.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@{/* Message timestamp */}
|
// Header
|
||||||
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
|
<div class="chatlog__header">
|
||||||
|
@{/* Author name */}
|
||||||
|
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||||
|
|
||||||
@{/* Messages in a group */}
|
@{/* Bot label */}
|
||||||
@foreach (var message in Model.MessageGroup.Messages)
|
@if (message.Author.IsBot)
|
||||||
{
|
{
|
||||||
<div class="chatlog__message @(message.IsPinned ? "chatlog__message--pinned" : null)" data-message-id="@message.Id" id="message-@message.Id" title="Message sent: @FormatDate(message.Timestamp)">
|
<span class="chatlog__bot-label">BOT</span>
|
||||||
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
|
}
|
||||||
{
|
|
||||||
<div class="chatlog__content">
|
|
||||||
<div class="markdown">
|
|
||||||
@{/* Message content */}
|
|
||||||
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
|
|
||||||
|
|
||||||
@{/* Edit timestamp */}
|
@{/* Timestamp */}
|
||||||
|
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Content */}
|
||||||
|
@if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__content chatlog__markdown">
|
||||||
|
@{/* Text */}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
|
||||||
|
{
|
||||||
|
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Edited timestamp */}
|
||||||
@if (message.EditedTimestamp is not null)
|
@if (message.EditedTimestamp is not null)
|
||||||
{
|
{
|
||||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Attachments */}
|
|
||||||
@foreach (var attachment in message.Attachments)
|
|
||||||
{
|
|
||||||
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
|
|
||||||
@{/* Spoiler caption */}
|
|
||||||
@if (attachment.IsSpoiler)
|
|
||||||
{
|
|
||||||
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Attachment preview */}
|
|
||||||
@if (attachment.IsImage)
|
|
||||||
{
|
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
|
||||||
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")" loading="lazy">
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
else if (attachment.IsVideo)
|
|
||||||
{
|
|
||||||
<video class="chatlog__attachment-media" controls>
|
|
||||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
|
|
||||||
</video>
|
|
||||||
}
|
|
||||||
else if (attachment.IsAudio)
|
|
||||||
{
|
|
||||||
<audio class="chatlog__attachment-media" controls>
|
|
||||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
|
|
||||||
</audio>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="chatlog__attachment-generic">
|
|
||||||
<svg class="chatlog__attachment-generic-icon">
|
|
||||||
<use href="#icon-attachment" />
|
|
||||||
</svg>
|
|
||||||
<div class="chatlog__attachment-generic-name">
|
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
|
||||||
@attachment.FileName
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="chatlog__attachment-generic-size">
|
|
||||||
@attachment.FileSize
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Embeds */}
|
|
||||||
@foreach (var embed in message.Embeds)
|
|
||||||
{
|
|
||||||
// Plain image embed
|
|
||||||
if (embed.TryGetPlainImage() is { } plainImageEmbed)
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed">
|
|
||||||
<a href="@await ResolveUrlAsync(plainImageEmbed.Url)">
|
|
||||||
<img class="chatlog__embed-plainimage" src="@await ResolveUrlAsync(plainImageEmbed.Url)" alt="Embedded image" loading="lazy">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
// Spotify embed
|
|
||||||
else if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
|
@{/* Attachments */}
|
||||||
|
@foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
|
||||||
<div class="chatlog__embed-spotify-container">
|
@{/* Spoiler caption */}
|
||||||
<iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
|
@if (attachment.IsSpoiler)
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
// YouTube embed
|
|
||||||
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed">
|
|
||||||
@{/* Color pill */}
|
|
||||||
@if (embed.Color is not null)
|
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Attachment preview */}
|
||||||
|
@if (attachment.IsImage)
|
||||||
|
{
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
<img class="chatlog__attachment-media" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (attachment.IsVideo)
|
||||||
|
{
|
||||||
|
<video class="chatlog__attachment-media" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else if (attachment.IsAudio)
|
||||||
|
{
|
||||||
|
<audio class="chatlog__attachment-media" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</audio>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
<div class="chatlog__attachment-generic">
|
||||||
|
<svg class="chatlog__attachment-generic-icon">
|
||||||
|
<use href="#attachment-icon"/>
|
||||||
|
</svg>
|
||||||
|
<div class="chatlog__attachment-generic-name">
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
@attachment.FileName
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="chatlog__attachment-generic-size">
|
||||||
|
@attachment.FileSize
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="chatlog__embed-content-container">
|
@{/* Embeds */}
|
||||||
<div class="chatlog__embed-content">
|
@foreach (var embed in message.Embeds)
|
||||||
<div class="chatlog__embed-text">
|
{
|
||||||
@{/* Embed author */}
|
// Spotify embed
|
||||||
@if (embed.Author is not null)
|
if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-author">
|
<div class="chatlog__embed">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
<div class="chatlog__embed-spotify-container">
|
||||||
{
|
<iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
</div>
|
||||||
}
|
</div>
|
||||||
|
}
|
||||||
|
// YouTube embed
|
||||||
|
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed">
|
||||||
|
@{/* Color pill */}
|
||||||
|
@if (embed.Color is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
<div class="chatlog__embed-content-container">
|
||||||
{
|
<div class="chatlog__embed-content">
|
||||||
<span class="chatlog__embed-author-name">
|
<div class="chatlog__embed-text">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
@{/* Embed author */}
|
||||||
|
@if (embed.Author is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-author-container">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
|
{
|
||||||
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||||
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@embed.Author.Name
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
}
|
}
|
||||||
</span>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Embed title */}
|
@{/* Embed title */}
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-title">
|
<div class="chatlog__embed-title">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@{/* Video player */}
|
@{/* Video player */}
|
||||||
<div class="chatlog__embed-youtube-container">
|
<div class="chatlog__embed-youtube-container">
|
||||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
// Generic image embed
|
||||||
// Generic embed
|
else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
|
||||||
else
|
{
|
||||||
{
|
<div class="chatlog__embed">
|
||||||
<div class="chatlog__embed">
|
<a href="@await ResolveAssetUrlAsync(embed.Url)">
|
||||||
@{/* Color pill */}
|
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
|
||||||
@if (embed.Color is not null)
|
</a>
|
||||||
{
|
</div>
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
}
|
||||||
}
|
// Generic gifv embed
|
||||||
else
|
else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Video?.Url))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
<div class="chatlog__embed">
|
||||||
}
|
<video class="chatlog__embed-generic-gifv" loop width="@embed.Video.Width" height="@embed.Video.Height" onmouseover="this.play()" onmouseout="this.pause()">
|
||||||
|
<source src="@await ResolveAssetUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
// Rich embed
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed">
|
||||||
|
@{/* Color pill */}
|
||||||
|
@if (embed.Color is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="chatlog__embed-content-container">
|
<div class="chatlog__embed-content-container">
|
||||||
<div class="chatlog__embed-content">
|
<div class="chatlog__embed-content">
|
||||||
<div class="chatlog__embed-text">
|
<div class="chatlog__embed-text">
|
||||||
@{/* Embed author */}
|
@{/* Embed author */}
|
||||||
@if (embed.Author is not null)
|
@if (embed.Author is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-author">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
{
|
{
|
||||||
<span class="chatlog__embed-author-name">
|
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||||
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@embed.Author.Name
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
}
|
}
|
||||||
</span>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Embed title */}
|
@{/* Embed title */}
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-title">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
|
{
|
||||||
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Embed description */}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-description">
|
||||||
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Embed fields */}
|
||||||
|
@if (embed.Fields.Any())
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-fields">
|
||||||
|
@foreach (var field in embed.Fields)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-field-name">
|
||||||
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Name))<!--/wmm:ignore--></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-field-value">
|
||||||
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@{/* Embed content */}
|
||||||
|
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-title">
|
<div class="chatlog__embed-thumbnail-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||||
{
|
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
</a>
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Embed description */}
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed-description">
|
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Embed fields */}
|
|
||||||
@if (embed.Fields.Any())
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed-fields">
|
|
||||||
@foreach (var field in embed.Fields)
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
|
|
||||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed-field-name">
|
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed-field-value">
|
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@{/* Embed content */}
|
@{/* Embed images */}
|
||||||
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
|
@if (embed.Images.Any())
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-thumbnail-container">
|
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
|
||||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
@foreach (var image in embed.Images)
|
||||||
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
{
|
||||||
</a>
|
if (!string.IsNullOrWhiteSpace(image.Url))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-image-container">
|
||||||
|
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||||
|
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Embed footer & icon */}
|
||||||
|
@if (embed.Footer is not null || embed.Timestamp is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-footer">
|
||||||
|
@{/* Footer icon */}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||||
|
{
|
||||||
|
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="chatlog__embed-footer-text">
|
||||||
|
@{/* Footer text */}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||||
|
{
|
||||||
|
@embed.Footer.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
|
||||||
|
{
|
||||||
|
@(" • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Embed timestamp */}
|
||||||
|
@if (embed.Timestamp is not null)
|
||||||
|
{
|
||||||
|
@FormatDate(embed.Timestamp.Value)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@{/* Embed image */}
|
|
||||||
@if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url))
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed-image-container">
|
|
||||||
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)">
|
|
||||||
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image" loading="lazy">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Embed footer & icon */}
|
|
||||||
@if (embed.Footer is not null || embed.Timestamp is not null)
|
|
||||||
{
|
|
||||||
<div class="chatlog__embed-footer">
|
|
||||||
@{/* Footer icon */}
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
|
||||||
{
|
|
||||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
|
||||||
}
|
|
||||||
|
|
||||||
<span class="chatlog__embed-footer-text">
|
|
||||||
@{/* Footer text */}
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
|
||||||
{
|
|
||||||
@embed.Footer.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
|
|
||||||
{
|
|
||||||
@(" • ")
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Embed timestamp */}
|
|
||||||
@if (embed.Timestamp is not null)
|
|
||||||
{
|
|
||||||
@FormatDate(embed.Timestamp.Value)
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Message reactions */}
|
|
||||||
@if (message.Reactions.Any())
|
|
||||||
{
|
|
||||||
<div class="chatlog__reactions">
|
|
||||||
@foreach (var reaction in message.Reactions)
|
|
||||||
{
|
|
||||||
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
|
||||||
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
|
||||||
<span class="chatlog__reaction-count">@reaction.Count</span>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
}
|
||||||
}
|
|
||||||
</div>
|
@{/* Stickers */}
|
||||||
}
|
@foreach (var sticker in message.Stickers)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker" title="@sticker.Name">
|
||||||
|
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
|
||||||
|
{
|
||||||
|
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||||
|
}
|
||||||
|
else if (sticker.Format == StickerFormat.Lottie)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Message reactions */}
|
||||||
|
@if (message.Reactions.Any())
|
||||||
|
{
|
||||||
|
<div class="chatlog__reactions">
|
||||||
|
@foreach (var reaction in message.Reactions)
|
||||||
|
{
|
||||||
|
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
||||||
|
<img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveAssetUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
||||||
|
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
|
||||||
|
|
||||||
internal class MessageGroupTemplateContext
|
|
||||||
{
|
|
||||||
public ExportContext ExportContext { get; }
|
|
||||||
|
|
||||||
public MessageGroup MessageGroup { get; }
|
|
||||||
|
|
||||||
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
|
|
||||||
{
|
|
||||||
ExportContext = exportContext;
|
|
||||||
MessageGroup = messageGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
|
||||||
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
@using DiscordChatExporter.Core.Exporting.Writers.Html;
|
@using System.Threading
|
||||||
|
@using DiscordChatExporter.Core.Exporting
|
||||||
|
|
||||||
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
@inherits RazorBlade.HtmlTemplate
|
||||||
@inherits MiniRazor.TemplateBase<PostambleTemplateContext>
|
|
||||||
|
|
||||||
@{/* Close elements opened by preamble */}
|
@functions {
|
||||||
|
public required ExportContext ExportContext { get; init; }
|
||||||
|
|
||||||
|
public required long MessagesWritten { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@{
|
||||||
|
/* Close elements opened by preamble */
|
||||||
|
}
|
||||||
|
<!--wmm:ignore-->
|
||||||
</div>
|
</div>
|
||||||
|
<!--/wmm:ignore-->
|
||||||
|
|
||||||
<div class="postamble">
|
<div class="postamble">
|
||||||
<div class="postamble__entry">Exported @Model.MessagesWritten.ToString("N0") message(s)</div>
|
<div class="postamble__entry">Exported @MessagesWritten.ToString("N0") message(s)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
|
||||||
|
|
||||||
internal class PostambleTemplateContext
|
|
||||||
{
|
|
||||||
public ExportContext ExportContext { get; }
|
|
||||||
|
|
||||||
public long MessagesWritten { get; }
|
|
||||||
|
|
||||||
public PostambleTemplateContext(ExportContext exportContext, long messagesWritten)
|
|
||||||
{
|
|
||||||
ExportContext = exportContext;
|
|
||||||
MessagesWritten = messagesWritten;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
|||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
|
||||||
|
|
||||||
internal class PreambleTemplateContext
|
|
||||||
{
|
|
||||||
public ExportContext ExportContext { get; }
|
|
||||||
|
|
||||||
public string ThemeName { get; }
|
|
||||||
|
|
||||||
public PreambleTemplateContext(ExportContext exportContext, string themeName)
|
|
||||||
{
|
|
||||||
ExportContext = exportContext;
|
|
||||||
ThemeName = themeName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
|
||||||
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Writers.Html;
|
using DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
using WebMarkupMin.Core;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers;
|
namespace DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
@@ -13,7 +15,8 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
private readonly TextWriter _writer;
|
private readonly TextWriter _writer;
|
||||||
private readonly string _themeName;
|
private readonly string _themeName;
|
||||||
|
|
||||||
private readonly List<Message> _messageGroupBuffer = new();
|
private readonly HtmlMinifier _minifier = new();
|
||||||
|
private readonly List<Message> _messageGroup = new();
|
||||||
|
|
||||||
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
||||||
: base(stream, context)
|
: base(stream, context)
|
||||||
@@ -22,27 +25,78 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
_themeName = themeName;
|
_themeName = themeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
private bool CanJoinGroup(Message message)
|
||||||
{
|
{
|
||||||
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
// If the group is empty, any message can join it
|
||||||
|
if (_messageGroup.LastOrDefault() is not { } lastMessage)
|
||||||
|
return true;
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
// Reply messages cannot join existing groups because they need to appear first
|
||||||
// does not actually do asynchronous writes to stream.
|
if (message.Kind == MessageKind.Reply)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Grouping for system notifications
|
||||||
|
if (message.Kind.IsSystemNotification())
|
||||||
|
{
|
||||||
|
// Can only be grouped with other system notifications
|
||||||
|
if (!lastMessage.Kind.IsSystemNotification())
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Grouping for normal messages
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Can only be grouped with other normal messages
|
||||||
|
if (lastMessage.Kind.IsSystemNotification())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Messages must be within 7 minutes of each other
|
||||||
|
if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Messages must be from the same author
|
||||||
|
if (message.Author.Id != lastMessage.Author.Id)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// If the user changed their name after the last message, their new messages
|
||||||
|
// cannot join an existing group.
|
||||||
|
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use <!--wmm:ignore--> to preserve blocks of code inside the templates
|
||||||
|
private string Minify(string html) => _minifier
|
||||||
|
.Minify(html, false)
|
||||||
|
.MinifiedContent;
|
||||||
|
|
||||||
|
public override async ValueTask WritePreambleAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
await _writer.WriteLineAsync(
|
await _writer.WriteLineAsync(
|
||||||
await PreambleTemplate.RenderAsync(templateContext, cancellationToken)
|
Minify(
|
||||||
|
await new PreambleTemplate
|
||||||
|
{
|
||||||
|
ExportContext = Context,
|
||||||
|
ThemeName = _themeName
|
||||||
|
}.RenderAsync(cancellationToken)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask WriteMessageGroupAsync(
|
private async ValueTask WriteMessageGroupAsync(
|
||||||
MessageGroup messageGroup,
|
IReadOnlyList<Message> messages,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
|
||||||
// does not actually do asynchronous writes to stream.
|
|
||||||
await _writer.WriteLineAsync(
|
await _writer.WriteLineAsync(
|
||||||
await MessageGroupTemplate.RenderAsync(templateContext, cancellationToken)
|
Minify(
|
||||||
|
await new MessageGroupTemplate
|
||||||
|
{
|
||||||
|
ExportContext = Context,
|
||||||
|
Messages = messages
|
||||||
|
}.RenderAsync(cancellationToken)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,38 +106,35 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
await base.WriteMessageAsync(message, cancellationToken);
|
await base.WriteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
// If message group is empty or the given message can be grouped, buffer the given message
|
// If the message can be grouped, buffer it for now
|
||||||
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
|
if (CanJoinGroup(message))
|
||||||
{
|
{
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroup.Add(message);
|
||||||
}
|
}
|
||||||
// Otherwise, flush the group and render messages
|
// Otherwise, flush the group and render messages
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken);
|
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
|
||||||
|
|
||||||
_messageGroupBuffer.Clear();
|
_messageGroup.Clear();
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroup.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Flush current message group
|
// Flush current message group
|
||||||
if (_messageGroupBuffer.Any())
|
if (_messageGroup.Any())
|
||||||
{
|
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
|
||||||
await WriteMessageGroupAsync(
|
|
||||||
MessageGroup.Join(_messageGroupBuffer),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
|
||||||
// does not actually do asynchronous writes to stream.
|
|
||||||
await _writer.WriteLineAsync(
|
await _writer.WriteLineAsync(
|
||||||
await PostambleTemplate.RenderAsync(templateContext, cancellationToken)
|
Minify(
|
||||||
|
await new PostambleTemplate
|
||||||
|
{
|
||||||
|
ExportContext = Context,
|
||||||
|
MessagesWritten = MessagesWritten
|
||||||
|
}.RenderAsync(cancellationToken)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
Indented = true,
|
Indented = true,
|
||||||
// Validation errors may mask actual failures
|
// Validation errors may mask actual failures
|
||||||
@@ -28,8 +29,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatMarkdown(string? markdown) =>
|
private ValueTask<string> FormatMarkdownAsync(
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
string markdown,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken);
|
||||||
|
|
||||||
private async ValueTask WriteAttachmentAsync(
|
private async ValueTask WriteAttachmentAsync(
|
||||||
Attachment attachment,
|
Attachment attachment,
|
||||||
@@ -38,7 +41,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("id", attachment.Id.ToString());
|
_writer.WriteString("id", attachment.Id.ToString());
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
_writer.WriteString("url", await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
|
||||||
_writer.WriteString("fileName", attachment.FileName);
|
_writer.WriteString("fileName", attachment.FileName);
|
||||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||||
|
|
||||||
@@ -50,29 +53,18 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
EmbedAuthor embedAuthor,
|
EmbedAuthor embedAuthor,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("author");
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("name", embedAuthor.Name);
|
_writer.WriteString("name", embedAuthor.Name);
|
||||||
_writer.WriteString("url", embedAuthor.Url);
|
_writer.WriteString("url", embedAuthor.Url);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
|
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
|
||||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken));
|
{
|
||||||
|
_writer.WriteString(
|
||||||
_writer.WriteEndObject();
|
"iconUrl",
|
||||||
await _writer.FlushAsync(cancellationToken);
|
await Context.ResolveAssetUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken)
|
||||||
}
|
);
|
||||||
|
}
|
||||||
private async ValueTask WriteEmbedThumbnailAsync(
|
|
||||||
EmbedImage embedThumbnail,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject("thumbnail");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedThumbnail.Url))
|
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.ProxyUrl ?? embedThumbnail.Url, cancellationToken));
|
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedThumbnail.Width);
|
|
||||||
_writer.WriteNumber("height", embedThumbnail.Height);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
@@ -82,10 +74,15 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
EmbedImage embedImage,
|
EmbedImage embedImage,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("image");
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken));
|
{
|
||||||
|
_writer.WriteString(
|
||||||
|
"url",
|
||||||
|
await Context.ResolveAssetUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedImage.Width);
|
_writer.WriteNumber("width", embedImage.Width);
|
||||||
_writer.WriteNumber("height", embedImage.Height);
|
_writer.WriteNumber("height", embedImage.Height);
|
||||||
@@ -98,12 +95,17 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
EmbedFooter embedFooter,
|
EmbedFooter embedFooter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("footer");
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("text", embedFooter.Text);
|
_writer.WriteString("text", embedFooter.Text);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
|
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
|
||||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken));
|
{
|
||||||
|
_writer.WriteString(
|
||||||
|
"iconUrl",
|
||||||
|
await Context.ResolveAssetUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
@@ -115,8 +117,8 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("name", FormatMarkdown(embedField.Name));
|
_writer.WriteString("name", await FormatMarkdownAsync(embedField.Name, cancellationToken));
|
||||||
_writer.WriteString("value", FormatMarkdown(embedField.Value));
|
_writer.WriteString("value", await FormatMarkdownAsync(embedField.Value, cancellationToken));
|
||||||
_writer.WriteBoolean("isInline", embedField.IsInline);
|
_writer.WriteBoolean("isInline", embedField.IsInline);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
@@ -129,25 +131,46 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("title", FormatMarkdown(embed.Title));
|
_writer.WriteString("title", await FormatMarkdownAsync(embed.Title ?? "", cancellationToken));
|
||||||
_writer.WriteString("url", embed.Url);
|
_writer.WriteString("url", embed.Url);
|
||||||
_writer.WriteString("timestamp", embed.Timestamp);
|
_writer.WriteString("timestamp", embed.Timestamp);
|
||||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
_writer.WriteString("description", await FormatMarkdownAsync(embed.Description ?? "", cancellationToken));
|
||||||
|
|
||||||
if (embed.Color is not null)
|
if (embed.Color is not null)
|
||||||
_writer.WriteString("color", embed.Color.Value.ToHex());
|
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||||
|
|
||||||
if (embed.Author is not null)
|
if (embed.Author is not null)
|
||||||
|
{
|
||||||
|
_writer.WritePropertyName("author");
|
||||||
await WriteEmbedAuthorAsync(embed.Author, cancellationToken);
|
await WriteEmbedAuthorAsync(embed.Author, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (embed.Thumbnail is not null)
|
if (embed.Thumbnail is not null)
|
||||||
await WriteEmbedThumbnailAsync(embed.Thumbnail, cancellationToken);
|
{
|
||||||
|
_writer.WritePropertyName("thumbnail");
|
||||||
|
await WriteEmbedImageAsync(embed.Thumbnail, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (embed.Image is not null)
|
// Legacy: backwards-compatibility for old embeds with a single image
|
||||||
await WriteEmbedImageAsync(embed.Image, cancellationToken);
|
if (embed.Images.Count > 0)
|
||||||
|
{
|
||||||
|
_writer.WritePropertyName("image");
|
||||||
|
await WriteEmbedImageAsync(embed.Images[0], cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (embed.Footer is not null)
|
if (embed.Footer is not null)
|
||||||
|
{
|
||||||
|
_writer.WritePropertyName("footer");
|
||||||
await WriteEmbedFooterAsync(embed.Footer, cancellationToken);
|
await WriteEmbedFooterAsync(embed.Footer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
_writer.WriteStartArray("images");
|
||||||
|
|
||||||
|
foreach (var image in embed.Images)
|
||||||
|
await WriteEmbedImageAsync(image, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
_writer.WriteStartArray("fields");
|
_writer.WriteStartArray("fields");
|
||||||
@@ -161,6 +184,21 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteStickerAsync(
|
||||||
|
Sticker sticker,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", sticker.Id.ToString());
|
||||||
|
_writer.WriteString("name", sticker.Name);
|
||||||
|
_writer.WriteString("format", sticker.Format.ToString());
|
||||||
|
_writer.WriteString("sourceUrl", await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken));
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteReactionAsync(
|
private async ValueTask WriteReactionAsync(
|
||||||
Reaction reaction,
|
Reaction reaction,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -169,10 +207,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
|
|
||||||
// Emoji
|
// Emoji
|
||||||
_writer.WriteStartObject("emoji");
|
_writer.WriteStartObject("emoji");
|
||||||
_writer.WriteString("id", reaction.Emoji.Id);
|
_writer.WriteString("id", reaction.Emoji.Id.ToString());
|
||||||
_writer.WriteString("name", reaction.Emoji.Name);
|
_writer.WriteString("name", reaction.Emoji.Name);
|
||||||
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
||||||
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
_writer.WriteString("imageUrl", await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
_writer.WriteNumber("count", reaction.Count);
|
_writer.WriteNumber("count", reaction.Count);
|
||||||
@@ -206,7 +244,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteStartObject("guild");
|
_writer.WriteStartObject("guild");
|
||||||
_writer.WriteString("id", Context.Request.Guild.Id.ToString());
|
_writer.WriteString("id", Context.Request.Guild.Id.ToString());
|
||||||
_writer.WriteString("name", Context.Request.Guild.Name);
|
_writer.WriteString("name", Context.Request.Guild.Name);
|
||||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl, cancellationToken));
|
_writer.WriteString("iconUrl", await Context.ResolveAssetUrlAsync(Context.Request.Guild.IconUrl, cancellationToken));
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
// Channel
|
// Channel
|
||||||
@@ -247,7 +285,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteBoolean("isPinned", message.IsPinned);
|
_writer.WriteBoolean("isPinned", message.IsPinned);
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
_writer.WriteString("content", FormatMarkdown(message.Content));
|
_writer.WriteString("content", await FormatMarkdownAsync(message.Content, cancellationToken));
|
||||||
|
|
||||||
// Author
|
// Author
|
||||||
_writer.WriteStartObject("author");
|
_writer.WriteStartObject("author");
|
||||||
@@ -257,7 +295,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name);
|
_writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name);
|
||||||
_writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
|
_writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
|
||||||
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
||||||
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl, cancellationToken));
|
_writer.WriteString("avatarUrl", await Context.ResolveAssetUrlAsync(message.Author.AvatarUrl, cancellationToken));
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
@@ -276,6 +314,14 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Stickers
|
||||||
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
|
foreach (var sticker in message.Stickers)
|
||||||
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
// Reactions
|
// Reactions
|
||||||
_writer.WriteStartArray("reactions");
|
_writer.WriteStartArray("reactions");
|
||||||
|
|
||||||
|
|||||||
+112
-53
@@ -3,7 +3,8 @@ using System.Linq;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Markdown;
|
using DiscordChatExporter.Core.Markdown;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
@@ -24,58 +25,91 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
_isJumbo = isJumbo;
|
_isJumbo = isJumbo;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitText(TextNode text)
|
protected override async ValueTask<MarkdownNode> VisitTextAsync(
|
||||||
|
TextNode text,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_buffer.Append(HtmlEncode(text.Text));
|
_buffer.Append(HtmlEncode(text.Text));
|
||||||
return base.VisitText(text);
|
return await base.VisitTextAsync(text, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitFormatting(FormattingNode formatting)
|
protected override async ValueTask<MarkdownNode> VisitFormattingAsync(
|
||||||
|
FormattingNode formatting,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var (tagOpen, tagClose) = formatting.Kind switch
|
var (openingTag, closingTag) = formatting.Kind switch
|
||||||
{
|
{
|
||||||
FormattingKind.Bold => ("<strong>", "</strong>"),
|
FormattingKind.Bold => (
|
||||||
FormattingKind.Italic => ("<em>", "</em>"),
|
"<strong>",
|
||||||
FormattingKind.Underline => ("<u>", "</u>"),
|
"</strong>"
|
||||||
FormattingKind.Strikethrough => ("<s>", "</s>"),
|
),
|
||||||
|
|
||||||
|
FormattingKind.Italic => (
|
||||||
|
"<em>",
|
||||||
|
"</em>"
|
||||||
|
),
|
||||||
|
|
||||||
|
FormattingKind.Underline => (
|
||||||
|
"<u>",
|
||||||
|
"</u>"
|
||||||
|
),
|
||||||
|
|
||||||
|
FormattingKind.Strikethrough => (
|
||||||
|
"<s>",
|
||||||
|
"</s>"
|
||||||
|
),
|
||||||
|
|
||||||
FormattingKind.Spoiler => (
|
FormattingKind.Spoiler => (
|
||||||
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
|
"<span class=\"chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden\" onclick=\"showSpoiler(event, this)\">",
|
||||||
FormattingKind.Quote => ("<div class=\"quote\">", "</div>"),
|
"</span>"
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
|
),
|
||||||
|
|
||||||
|
FormattingKind.Quote => (
|
||||||
|
"<div class=\"chatlog__markdown-quote\"><div class=\"chatlog__markdown-quote-border\"></div><div class=\"chatlog__markdown-quote-content\">",
|
||||||
|
"</div></div>"
|
||||||
|
),
|
||||||
|
|
||||||
|
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
|
||||||
};
|
};
|
||||||
|
|
||||||
_buffer.Append(tagOpen);
|
_buffer.Append(openingTag);
|
||||||
var result = base.VisitFormatting(formatting);
|
var result = await base.VisitFormattingAsync(formatting, cancellationToken);
|
||||||
_buffer.Append(tagClose);
|
_buffer.Append(closingTag);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
protected override async ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(
|
||||||
|
InlineCodeBlockNode inlineCodeBlock,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"pre pre--inline\">")
|
.Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
|
||||||
.Append(HtmlEncode(inlineCodeBlock.Code))
|
.Append(HtmlEncode(inlineCodeBlock.Code))
|
||||||
.Append("</span>");
|
.Append("</code>");
|
||||||
|
|
||||||
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
return await base.VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
|
protected override async ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(
|
||||||
|
MultiLineCodeBlockNode multiLineCodeBlock,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
||||||
? $"language-{multiLineCodeBlock.Language}"
|
? $"language-{multiLineCodeBlock.Language}"
|
||||||
: "nohighlight";
|
: "nohighlight";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<div class=\"pre pre--multiline {highlightCssClass}\">")
|
.Append($"<code class=\"chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightCssClass}\">")
|
||||||
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
||||||
.Append("</div>");
|
.Append("</code>");
|
||||||
|
|
||||||
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
return await base.VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitLink(LinkNode link)
|
protected override async ValueTask<MarkdownNode> VisitLinkAsync(
|
||||||
|
LinkNode link,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Try to extract message ID if the link refers to a Discord message
|
// Try to extract message ID if the link refers to a Discord message
|
||||||
var linkedMessageId = Regex.Match(
|
var linkedMessageId = Regex.Match(
|
||||||
@@ -85,79 +119,99 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
|
|
||||||
_buffer.Append(
|
_buffer.Append(
|
||||||
!string.IsNullOrWhiteSpace(linkedMessageId)
|
!string.IsNullOrWhiteSpace(linkedMessageId)
|
||||||
? $"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
|
? $"<a href=\"{HtmlEncode(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
|
||||||
: $"<a href=\"{Uri.EscapeUriString(link.Url)}\">"
|
: $"<a href=\"{HtmlEncode(link.Url)}\">"
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = base.VisitLink(link);
|
var result = await base.VisitLinkAsync(link, cancellationToken);
|
||||||
_buffer.Append("</a>");
|
_buffer.Append("</a>");
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
protected override async ValueTask<MarkdownNode> VisitEmojiAsync(
|
||||||
|
EmojiNode emoji,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||||
var jumboClass = _isJumbo ? "emoji--large" : "";
|
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
|
||||||
|
|
||||||
_buffer
|
_buffer.Append(
|
||||||
.Append($"<img loading=\"lazy\" class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
$"<img " +
|
||||||
|
$"loading=\"lazy\" " +
|
||||||
|
$"class=\"chatlog__emoji {jumboClass}\" " +
|
||||||
|
$"alt=\"{emoji.Name}\" " +
|
||||||
|
$"title=\"{emoji.Code}\" " +
|
||||||
|
$"src=\"{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}\"" +
|
||||||
|
$">"
|
||||||
|
);
|
||||||
|
|
||||||
return base.VisitEmoji(emoji);
|
return await base.VisitEmojiAsync(emoji, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
protected override async ValueTask<MarkdownNode> VisitMentionAsync(
|
||||||
|
MentionNode mention,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var mentionId = Snowflake.TryParse(mention.Id);
|
if (mention.Kind == MentionKind.Everyone)
|
||||||
if (mention.Kind == MentionKind.Meta)
|
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"mention\">")
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
.Append("@").Append(HtmlEncode(mention.Id))
|
.Append("@everyone")
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Here)
|
||||||
|
{
|
||||||
|
_buffer
|
||||||
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
|
.Append("@here")
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.User)
|
else if (mention.Kind == MentionKind.User)
|
||||||
{
|
{
|
||||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
var member = mention.TargetId?.Pipe(_context.TryGetMember);
|
||||||
var fullName = member?.User.FullName ?? "Unknown";
|
var fullName = member?.User.FullName ?? "Unknown";
|
||||||
var nick = member?.Nick ?? "Unknown";
|
var nick = member?.Nick ?? "Unknown";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
|
.Append($"<span class=\"chatlog__markdown-mention\" title=\"{HtmlEncode(fullName)}\">")
|
||||||
.Append("@").Append(HtmlEncode(nick))
|
.Append('@').Append(HtmlEncode(nick))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
||||||
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
|
var symbol = channel?.SupportsVoice == true ? "🔊" : "#";
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"mention\">")
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
.Append(symbol).Append(HtmlEncode(name))
|
.Append(symbol).Append(HtmlEncode(name))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Role)
|
else if (mention.Kind == MentionKind.Role)
|
||||||
{
|
{
|
||||||
var role = mentionId?.Pipe(_context.TryGetRole);
|
var role = mention.TargetId?.Pipe(_context.TryGetRole);
|
||||||
var name = role?.Name ?? "deleted-role";
|
var name = role?.Name ?? "deleted-role";
|
||||||
var color = role?.Color;
|
var color = role?.Color;
|
||||||
|
|
||||||
var style = color is not null
|
var style = color is not null
|
||||||
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
|
? $"color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); " +
|
||||||
|
$"background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);"
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"mention\" style=\"{style}\">")
|
.Append($"<span class=\"chatlog__markdown-mention\" style=\"{style}\">")
|
||||||
.Append("@").Append(HtmlEncode(name))
|
.Append('@').Append(HtmlEncode(name))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
|
|
||||||
return base.VisitMention(mention);
|
return await base.VisitMentionAsync(mention, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(
|
||||||
|
UnixTimestampNode timestamp,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var dateString = timestamp.Date is not null
|
var dateString = timestamp.Date is not null
|
||||||
? _context.FormatDate(timestamp.Date.Value)
|
? _context.FormatDate(timestamp.Date.Value)
|
||||||
@@ -169,11 +223,11 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
: "Invalid date";
|
: "Invalid date";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
.Append($"<span class=\"chatlog__markdown-timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
||||||
.Append(HtmlEncode(dateString))
|
.Append(HtmlEncode(dateString))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
|
|
||||||
return base.VisitUnixTimestamp(timestamp);
|
return await base.VisitUnixTimestampAsync(timestamp, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +235,11 @@ internal partial class HtmlMarkdownVisitor
|
|||||||
{
|
{
|
||||||
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
|
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
|
||||||
|
|
||||||
public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true)
|
public static async ValueTask<string> FormatAsync(
|
||||||
|
ExportContext context,
|
||||||
|
string markdown,
|
||||||
|
bool isJumboAllowed = true,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var nodes = MarkdownParser.Parse(markdown);
|
var nodes = MarkdownParser.Parse(markdown);
|
||||||
|
|
||||||
@@ -191,7 +249,8 @@ internal partial class HtmlMarkdownVisitor
|
|||||||
|
|
||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
new HtmlMarkdownVisitor(context, buffer, isJumbo).Visit(nodes);
|
await new HtmlMarkdownVisitor(context, buffer, isJumbo)
|
||||||
|
.VisitAsync(nodes, cancellationToken);
|
||||||
|
|
||||||
return buffer.ToString();
|
return buffer.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-18
@@ -1,5 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Markdown;
|
using DiscordChatExporter.Core.Markdown;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
@@ -17,13 +18,17 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
_buffer = buffer;
|
_buffer = buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitText(TextNode text)
|
protected override async ValueTask<MarkdownNode> VisitTextAsync(
|
||||||
|
TextNode text,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_buffer.Append(text.Text);
|
_buffer.Append(text.Text);
|
||||||
return base.VisitText(text);
|
return await base.VisitTextAsync(text, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
protected override async ValueTask<MarkdownNode> VisitEmojiAsync(
|
||||||
|
EmojiNode emoji,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_buffer.Append(
|
_buffer.Append(
|
||||||
emoji.IsCustomEmoji
|
emoji.IsCustomEmoji
|
||||||
@@ -31,46 +36,53 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
: emoji.Name
|
: emoji.Name
|
||||||
);
|
);
|
||||||
|
|
||||||
return base.VisitEmoji(emoji);
|
return await base.VisitEmojiAsync(emoji, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
protected override async ValueTask<MarkdownNode> VisitMentionAsync(
|
||||||
|
MentionNode mention,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var mentionId = Snowflake.TryParse(mention.Id);
|
if (mention.Kind == MentionKind.Everyone)
|
||||||
if (mention.Kind == MentionKind.Meta)
|
|
||||||
{
|
{
|
||||||
_buffer.Append($"@{mention.Id}");
|
_buffer.Append("@everyone");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Here)
|
||||||
|
{
|
||||||
|
_buffer.Append("@here");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.User)
|
else if (mention.Kind == MentionKind.User)
|
||||||
{
|
{
|
||||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
var member = mention.TargetId?.Pipe(_context.TryGetMember);
|
||||||
var name = member?.User.Name ?? "Unknown";
|
var name = member?.User.Name ?? "Unknown";
|
||||||
|
|
||||||
_buffer.Append($"@{name}");
|
_buffer.Append($"@{name}");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer.Append($"#{name}");
|
_buffer.Append($"#{name}");
|
||||||
|
|
||||||
// Voice channel marker
|
// Voice channel marker
|
||||||
if (channel?.IsVoiceChannel == true)
|
if (channel?.SupportsVoice == true)
|
||||||
_buffer.Append(" [voice]");
|
_buffer.Append(" [voice]");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Role)
|
else if (mention.Kind == MentionKind.Role)
|
||||||
{
|
{
|
||||||
var role = mentionId?.Pipe(_context.TryGetRole);
|
var role = mention.TargetId?.Pipe(_context.TryGetRole);
|
||||||
var name = role?.Name ?? "deleted-role";
|
var name = role?.Name ?? "deleted-role";
|
||||||
|
|
||||||
_buffer.Append($"@{name}");
|
_buffer.Append($"@{name}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return base.VisitMention(mention);
|
return await base.VisitMentionAsync(mention, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(
|
||||||
|
UnixTimestampNode timestamp,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_buffer.Append(
|
_buffer.Append(
|
||||||
timestamp.Date is not null
|
timestamp.Date is not null
|
||||||
@@ -78,18 +90,22 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
: "Invalid date"
|
: "Invalid date"
|
||||||
);
|
);
|
||||||
|
|
||||||
return base.VisitUnixTimestamp(timestamp);
|
return await base.VisitUnixTimestampAsync(timestamp, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class PlainTextMarkdownVisitor
|
internal partial class PlainTextMarkdownVisitor
|
||||||
{
|
{
|
||||||
public static string Format(ExportContext context, string markdown)
|
public static async ValueTask<string> FormatAsync(
|
||||||
|
ExportContext context,
|
||||||
|
string markdown,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var nodes = MarkdownParser.ParseMinimal(markdown);
|
var nodes = MarkdownParser.ParseMinimal(markdown);
|
||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
new PlainTextMarkdownVisitor(context, buffer).Visit(nodes);
|
await new PlainTextMarkdownVisitor(context, buffer)
|
||||||
|
.VisitAsync(nodes, cancellationToken);
|
||||||
|
|
||||||
return buffer.ToString();
|
return buffer.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
_writer = new StreamWriter(stream);
|
_writer = new StreamWriter(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatMarkdown(string? markdown) =>
|
private ValueTask<string> FormatMarkdownAsync(
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
string markdown,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken);
|
||||||
|
|
||||||
private async ValueTask WriteMessageHeaderAsync(Message message)
|
private async ValueTask WriteMessageHeaderAsync(Message message)
|
||||||
{
|
{
|
||||||
@@ -48,7 +50,9 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
await _writer.WriteLineAsync(
|
||||||
|
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
@@ -65,39 +69,99 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
await _writer.WriteLineAsync("{Embed}");
|
await _writer.WriteLineAsync("{Embed}");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
|
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
|
||||||
|
{
|
||||||
await _writer.WriteLineAsync(embed.Author.Name);
|
await _writer.WriteLineAsync(embed.Author.Name);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Url))
|
if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
|
{
|
||||||
await _writer.WriteLineAsync(embed.Url);
|
await _writer.WriteLineAsync(embed.Url);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(embed.Title));
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(embed.Title, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(embed.Description));
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(embed.Description, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var field in embed.Fields)
|
foreach (var field in embed.Fields)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(field.Name))
|
if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(field.Name, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(field.Value))
|
if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(field.Value, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken));
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await Context.ResolveAssetUrlAsync(
|
||||||
|
embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
|
foreach (var image in embed.Images)
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url, cancellationToken));
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(image.Url))
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await Context.ResolveAssetUrlAsync(
|
||||||
|
image.ProxyUrl ?? image.Url,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||||
|
{
|
||||||
await _writer.WriteLineAsync(embed.Footer.Text);
|
await _writer.WriteLineAsync(embed.Footer.Text);
|
||||||
|
}
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteStickersAsync(
|
||||||
|
IReadOnlyList<Sticker> stickers,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!stickers.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync("{Stickers}");
|
||||||
|
|
||||||
|
foreach (var sticker in stickers)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteReactionsAsync(
|
private async ValueTask WriteReactionsAsync(
|
||||||
IReadOnlyList<Reaction> reactions,
|
IReadOnlyList<Reaction> reactions,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -114,7 +178,9 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
await _writer.WriteAsync(reaction.Emoji.Name);
|
await _writer.WriteAsync(reaction.Emoji.Name);
|
||||||
|
|
||||||
if (reaction.Count > 1)
|
if (reaction.Count > 1)
|
||||||
|
{
|
||||||
await _writer.WriteAsync($" ({reaction.Count})");
|
await _writer.WriteAsync($" ({reaction.Count})");
|
||||||
|
}
|
||||||
|
|
||||||
await _writer.WriteAsync(' ');
|
await _writer.WriteAsync(' ');
|
||||||
}
|
}
|
||||||
@@ -129,13 +195,19 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
|
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
||||||
|
{
|
||||||
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
||||||
|
}
|
||||||
|
|
||||||
if (Context.Request.After is not null)
|
if (Context.Request.After is not null)
|
||||||
|
{
|
||||||
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
|
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
|
||||||
|
}
|
||||||
|
|
||||||
if (Context.Request.Before is not null)
|
if (Context.Request.Before is not null)
|
||||||
|
{
|
||||||
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
|
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
|
||||||
|
}
|
||||||
|
|
||||||
await _writer.WriteLineAsync(new string('=', 62));
|
await _writer.WriteLineAsync(new string('=', 62));
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
@@ -152,13 +224,18 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
|
|
||||||
// Content
|
// Content
|
||||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(message.Content, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
// Attachments, embeds, reactions
|
// Attachments, embeds, reactions, etc.
|
||||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||||
|
await WriteStickersAsync(message.Stickers, cancellationToken);
|
||||||
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown;
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
internal record EmojiNode(
|
internal record EmojiNode(
|
||||||
// Only present on custom emoji
|
// Only present on custom emoji
|
||||||
string? Id,
|
Snowflake? Id,
|
||||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||||
string Name,
|
string Name,
|
||||||
bool IsAnimated) : MarkdownNode
|
bool IsAnimated) : MarkdownNode
|
||||||
{
|
{
|
||||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
public bool IsCustomEmoji => Id is not null;
|
||||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
|
||||||
|
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||||
|
public string Code => IsCustomEmoji
|
||||||
? Name
|
? Name
|
||||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||||
|
|
||||||
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);
|
|
||||||
|
|
||||||
public EmojiNode(string name)
|
public EmojiNode(string name)
|
||||||
: this(null, name, false)
|
: this(null, name, false)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
internal enum MentionKind
|
internal enum MentionKind
|
||||||
{
|
{
|
||||||
Meta,
|
Everyone,
|
||||||
|
Here,
|
||||||
User,
|
User,
|
||||||
Channel,
|
Channel,
|
||||||
Role
|
Role
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
namespace DiscordChatExporter.Core.Markdown;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
internal record MentionNode(string Id, MentionKind Kind) : MarkdownNode;
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
|
// Null ID means it's a meta mention or an invalid mention
|
||||||
|
internal record MentionNode(Snowflake? TargetId, MentionKind Kind) : MarkdownNode;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user