mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
271 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1495ed9b90 | |||
| be66c74c08 | |||
| 6f54c09d96 | |||
| 45926b0990 | |||
| 25d29dc079 | |||
| efde9931c9 | |||
| 2c3b461d49 | |||
| b405052fd6 | |||
| da6d903f36 | |||
| e1b1755fba | |||
| 1ff026eba6 | |||
| 1fe4ecb3af | |||
| 47a1518cd9 | |||
| d78c10ebb7 | |||
| 3db29b520c | |||
| 752003abc3 | |||
| e26a0660d1 | |||
| 7dfc3b2723 | |||
| 06c33373de | |||
| 563f5cb67b | |||
| 1f20915b4d | |||
| e58c7aefff | |||
| ac5ccc3fa4 | |||
| 2fc0aa43e8 | |||
| ba66b52fa4 | |||
| ac64d9943a | |||
| 94a85cdb01 | |||
| a1d34e8fb6 | |||
| 4a35a7b530 | |||
| 247f3a9c5b | |||
| be73f0b92c | |||
| d03be8b1dd | |||
| 9d0d7cd5dd | |||
| b2a48d338a | |||
| 88a4fa792b | |||
| dc79813ad7 | |||
| 6a430cdc76 | |||
| 2a02894d64 | |||
| 8685a3d7e3 | |||
| 130c0b6fe2 | |||
| f153aad3f1 | |||
| 6f1c944cb3 | |||
| de9e7caaeb | |||
| 9711a8cca4 | |||
| 4ab6607e78 | |||
| d74e6453f6 | |||
| ca7fc4d8d1 | |||
| 81ed85a3e0 | |||
| 18979c6a38 | |||
| 5bce4b52ff | |||
| dac7c664d9 | |||
| 6a8579787d | |||
| dbf15c7bee | |||
| a41a4ca8a7 | |||
| c098f4b137 | |||
| ff15257d23 | |||
| 79e43c4144 | |||
| 7bb8038ce9 | |||
| ee27167998 | |||
| 9f4277ae84 | |||
| 70a1c9db8c | |||
| 378f0a20db | |||
| a24fe3906c | |||
| eececb6701 | |||
| d3adf19500 | |||
| a01835efb6 | |||
| 1d39fe9c53 | |||
| 9f6090b3af | |||
| 9fa40dca00 | |||
| 8a4f306012 | |||
| 37be80e9e1 | |||
| 1475ffaf0e | |||
| aa50dd6dc7 | |||
| 539f57f455 | |||
| e1f83997aa | |||
| 26d713a17c | |||
| 5c2e725739 | |||
| bf56902134 | |||
| b4df267372 | |||
| 4ebc681327 | |||
| 5cb6adf19e | |||
| cec8409ac4 | |||
| 3af9421304 | |||
| cc6f930dc6 | |||
| e6aac3cdb9 | |||
| 8f1aec241b | |||
| 41c828eb3e | |||
| 964317dac0 | |||
| 2a223599f9 | |||
| fc38afe6a0 | |||
| 23512dae64 | |||
| 8382ca725d | |||
| 457e14d0b6 | |||
| b830014a46 | |||
| 35e1114318 | |||
| 610bf4baa3 | |||
| d84222ea09 | |||
| cc1bb924e7 | |||
| 0d2ae8b5db | |||
| 632b9f953b | |||
| 9a52c6a6f6 | |||
| fc8c1c5914 | |||
| 279a6aee84 | |||
| 73ab983056 | |||
| e5a2852165 | |||
| 1bf9d9e2e2 | |||
| 9299b95250 | |||
| 5e0a2c6761 | |||
| ccbd11e526 | |||
| 2c36f6a826 | |||
| d6980cef8d | |||
| fa4bb3b693 | |||
| 05c551f80c | |||
| 69cfe1845b | |||
| 3a0d351143 | |||
| f04719c4bd | |||
| d88cd9b228 | |||
| cd042e5368 | |||
| 533671c59f | |||
| 29c35f6754 | |||
| 7b32101517 | |||
| 9bb88128b0 | |||
| 12a5091d73 | |||
| 82af36c90d | |||
| a2beb9836b | |||
| 60f46bad29 | |||
| 2cdb230b1e | |||
| 9241b7d2d1 | |||
| 40f54d9a98 | |||
| f2faf823b9 | |||
| 5b9eaa57f5 | |||
| 58ba99e0c6 | |||
| 117d1b7e4a | |||
| 835910ee22 | |||
| 5630020d88 | |||
| 71e6a8de7a | |||
| 592b8b6294 | |||
| e6edcd27a4 | |||
| 557b5be844 | |||
| 02fe2a46e8 | |||
| 76c7f9b419 | |||
| 9a383d2bd4 | |||
| 2ed374ee5c | |||
| 0ced31ca4a | |||
| 3bb5638b56 | |||
| 3b5d752c95 | |||
| 2b1f599cbe | |||
| 7cb1c21897 | |||
| 37bcea86e3 | |||
| 559ef71d2f | |||
| ab0ce478db | |||
| afb9f52221 | |||
| d65ca409b1 | |||
| 7784383ea7 | |||
| 26322da800 | |||
| 1325edb135 | |||
| dec2c26d83 | |||
| 82b0b8cb0a | |||
| a7da90943f | |||
| 1edeaf9d3a | |||
| 9c043376f9 | |||
| 77366cc9b4 | |||
| ffdca7b799 | |||
| 30cba7959f | |||
| 7951703cf2 | |||
| 795dfbb213 | |||
| 6d9bc3625f | |||
| 4bfb2ec7fd | |||
| 359278afec | |||
| 19f58f2c8c | |||
| 640b1f5866 | |||
| d13d04e75b | |||
| 6bf0ce3790 | |||
| aef8a1f6d2 | |||
| a63bf95212 | |||
| af9855fc15 | |||
| 4113101c0c | |||
| cac48b3541 | |||
| 0a60dea5c1 | |||
| 889446fff7 | |||
| 6d1cfef729 | |||
| 77bf32aa5a | |||
| ac2b450892 | |||
| 4046fd0ad9 | |||
| 6fa61bf75a | |||
| 1976e3ad08 | |||
| adbe544c57 | |||
| 6f4d1c3d77 | |||
| 33f71c1612 | |||
| 9b78472e05 | |||
| 1f6d1f21e6 | |||
| 918cb4820f | |||
| 32af1aa05d | |||
| dda32d8af2 | |||
| d1d82f975b | |||
| b1de9c4eca | |||
| 48ec4d085a | |||
| 668ac70fae | |||
| 0d1ce38489 | |||
| 666f2d8d4a | |||
| 2dd0742c1c | |||
| 69fe8af87a | |||
| e493357a9f | |||
| 492003c75a | |||
| 28175e2110 | |||
| a564906719 | |||
| 019278f6d4 | |||
| 5161593e0f | |||
| 847a05a269 | |||
| f09f30c7bd | |||
| 88727a1fe6 | |||
| bb98049df7 | |||
| e519bdcc6f | |||
| 1356d4483a | |||
| 4a3dc5c5ee | |||
| e665356890 | |||
| 7b614ed448 | |||
| 6531462220 | |||
| e89f2735a0 | |||
| 01d276a9dc | |||
| c446d4069a | |||
| ba5718aaf5 | |||
| 20ff4a15eb | |||
| 991dfccf3c | |||
| 524c86b8f2 | |||
| e4b0d60c40 | |||
| 65c5df89f4 | |||
| 659c499bd0 | |||
| b6fcbac030 | |||
| b3e2dd3994 | |||
| ba5790e312 | |||
| 2859a740eb | |||
| b88a1f8b47 | |||
| 78b44aed19 | |||
| 13925c69af | |||
| e22e1c03fb | |||
| bf5cedb646 | |||
| 17b0161ade | |||
| fa1e84b518 | |||
| 6d5ed9e9ca | |||
| bdaeea5512 | |||
| dddb13fcc5 | |||
| cacbb58683 | |||
| ca727e9f23 | |||
| fd15b62adf | |||
| 78d01b47b4 | |||
| daf38153f4 | |||
| ac91c536dd | |||
| 0dbd619244 | |||
| 0d3510222e | |||
| 083bdef419 | |||
| d620010e85 | |||
| beadc25e61 | |||
| 8bc29c4928 | |||
| 0b14952346 | |||
| b518fb2f59 | |||
| 94165dcd8d | |||
| d6507598fa | |||
| a0359b1e43 | |||
| aa53cecd4e | |||
| 4144911208 | |||
| d28e81f8dc | |||
| 761cb032d7 | |||
| 47f0561c71 | |||
| 23116b776b | |||
| ba27a9f909 | |||
| 52684c264f | |||
| bcad5b4ed1 | |||
| 95a4217ab3 | |||
| 4e8fb80ac5 | |||
| 7ae560dde0 |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"xamlstyler.console": {
|
||||
"version": "3.2003.9",
|
||||
"commands": [
|
||||
"xstyler"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
github: Tyrrrz
|
||||
patreon: Tyrrrz
|
||||
custom: ['buymeacoffee.com/Tyrrrz', 'tyrrrz.me/donate']
|
||||
@@ -0,0 +1,66 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v1.4.0
|
||||
with:
|
||||
dotnet-version: 3.1.100
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,34 @@
|
||||
name: CI
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install .NET Core
|
||||
uses: actions/setup-dotnet@v1.4.0
|
||||
with:
|
||||
dotnet-version: 3.1.100
|
||||
|
||||
- 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/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
+184
-2
@@ -1,9 +1,191 @@
|
||||
### v2.22 (12-Aug-2020)
|
||||
|
||||
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||
- [GUI] Improved exporting options by adding a set of controls that can be used to limit the date range of the export down to minutes. Previously it was only possible to configure the date range without the time component. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||
- [HTML] Fixed an issue where the export didn't reflect changes in nicknames between messages sent by bots. This affected chat logs that contained interactions with the Tupperbox bot. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||
- [CLI] Fixed an issue where the application crashed if there were two environment variables defined that had the same name but in different case.
|
||||
|
||||
### v2.21.2 (30-Jul-2020)
|
||||
|
||||
- [GUI] When copy-pasting token, any surrounding spaces are now discarded, in addition to double quotes.
|
||||
- [HTML] Changed underlying templating engine to provide higher performance and better error messages. New templating engine adds a small cold start, but any export after the first should be faster.
|
||||
- Changed naming schema for downloaded media so that it follows `<original filename> (nn)` format rather than `<original filename>-salt`.
|
||||
- [HTML] Fixed an issue where downloaded media was sometimes inaccessible due to reserved characters appearing in the URL.
|
||||
- Fixed an issue where the application crashed when trying to download media with a file name that exceeds system's maximum allowed length.
|
||||
- Fixed an issue where the application crashed when trying to download media with illegal characters in the file name.
|
||||
|
||||
### v2.21.1 (19-Jul-2020)
|
||||
|
||||
- Fixed an issue where the export crashed if an embedded image has been deleted. Such media files are now ignored.
|
||||
- Changed the naming convention for downloaded media files so that the original file names are used when possible.
|
||||
|
||||
### v2.21 (18-Jul-2020)
|
||||
|
||||
- Added a new option that enables self-contained exports for all output formats. You can turn it on in the export setup dialog in GUI or using the `--media` option in CLI. When using this, the application will additionally download any media content directly referenced from the exported file instead of linking back to Discord CDN. The files which are downloaded include: guild icons, user avatars, attachments, embedded images, reaction emojis. Note that only files which are actually referenced by the export are downloaded, which means that, for example, user avatars will not be downloaded when using plain text export format. This option is not meant to enable complete offline viewing for HTML exports, but rather to make it easier to archive media content that may eventually get deleted from Discord servers. Also keep in mind that this option may make the export drastically slower and the total file size larger.
|
||||
- Changed "discordapp.com" to "discord.com" where applicable as Discord is migrating to a new domain. CDN will remain on "cdn.discordapp.com" for the foreseeable future.
|
||||
|
||||
Note that all existing and current HTML exports will likely not render accurately because Discord enabled CORS for their font resources, which prevents them from loading locally. Please refer to [issue #322](https://github.com/Tyrrrz/DiscordChatExporter/issues/322) for discussion on this topic.
|
||||
|
||||
### v2.20 (27-Apr-2020)
|
||||
|
||||
- [CLI] Added environment variables as fallback for `--token` and `--bot` options. They are `DISCORD_TOKEN` and `DISCORD_TOKEN_BOT` respectively.
|
||||
- [GUI] Fixed a crash that occurred if the application was closed in the middle of an auto-update.
|
||||
- [GUI] Added a link to project's wiki in the usage guide.
|
||||
- [HTML] Added full spoiler support for text and images. To spoil the hidden content you now need to click on it, similarly to how it works in Discord. (Thanks [@FM-96](https://github.com/FM-96))
|
||||
- [HTML] Updated grammar for consecutive single-line quotes. Previously they appeared separately, now they're grouped together as if it was one multi-line quote instead.
|
||||
- [HTML] Fixed an issue where messages sent by the same bot but with different usernames were grouped together when they shouldn't be.
|
||||
- [HTML] Updated grammar for single-line code blocks to accept double backticks as synonymous with single backticks. This change makes code blocks appear the same, whether they were inserted manually or using Discord's buggy markdown preview mode.
|
||||
- [HTML] Fixed styling inconsistencies and other minor issues.
|
||||
- [TXT/JSON] Fixed an issue where markdown was not parsed correctly in embed fields.
|
||||
- [HTML] Changed markdown formatting in embeds so it follows the same set of rules as in Discord.
|
||||
- Added channel category to the default file name used when exporting chat logs.
|
||||
- [HTML/TXT/JSON] Added channel category to exported metadata.
|
||||
- [CLI] Updated commands to also show channel category as well as channel name wherever relevant.
|
||||
- Starting with this release, Docker images will have new tags: `stable` for latest stable release and `2.20` for a specific version.
|
||||
|
||||
### v2.19 (26-Mar-2020)
|
||||
|
||||
- Added parallel exporting. This option allows you to export multiple channels faster by doing it in parallel. You can configure the parallel limit in settings (GUI) or with the `--parallel` option (CLI). Default value is `1`, which means there is no parallelization. Warning: be careful when using this option, as this will result in higher volume of concurrent HTTP requests sent to Discord, which might get you flagged. Use at your own risk.
|
||||
- [HTML] Added support for user colors. User names are now appropriately colored according to the assigned guild roles.
|
||||
- [HTML] Added support for nicknames. User names are replaced with nicknames where it's applicable. It's still possible to see the full user name by hovering your mouse over the nickname.
|
||||
- [HTML] Improved styling for mentions to bring it more in line with how it looks in Discord.
|
||||
|
||||
### v2.18 (04-Feb-2020)
|
||||
|
||||
- Added JSON export format. It's a structured data format which is easy to parse. If you're using DiscordChatExporter to export chat logs for further ingestion by another tool, this is most likely the format you will want to use.
|
||||
- [GUI] You can now quickly jump to a specific channel in a list. For example, if you want to jump to a channel named "General", you can simply press the 'g' key in the channel list view. You can also press 'g', 'e' and 'n' in quick succession which will jump to the next channel that starts with "gen". This mechanic is consistent with other Windows applications.
|
||||
- [CLI] Improved help text screen.
|
||||
|
||||
### v2.17 (12-Jan-2020)
|
||||
|
||||
- Fixed an issue where an empty file was produced when exporting a channel with no messages (for specified period). With the new behavior, no file will be created and instead a message will be shown to the user informing of the failure.
|
||||
- [HTML/TXT] Added message count to the bottom of the file. This number shows how many messages were exported as part of the current file. Prior to 2.16 this number was present along with the rest of the metadata at the top, then removed due to changes in the exporter, and now brought back at the end of the file instead.
|
||||
- [HTML] Fixed an issue where some emojis were not properly rendered.
|
||||
- [GUI] Added a setting that lets you configure whether to remember the last used token. Previously it was the default behavior, but now you can disable it if you don't want the token persisted on your system.
|
||||
|
||||
### v2.16 (08-Dec-2019)
|
||||
|
||||
- Migrated from .NET Framework to .NET Core. To run this and future versions of DiscordChatExporter you will need [.NET Core runtime for desktop apps](https://dotnet.microsoft.com/download/dotnet-core/3.1/runtime) (for GUI/CLI version on Windows) or [.NET Core base runtime](https://dotnet.microsoft.com/download/dotnet-core/3.1) (for CLI version on Windows, Linux or macOS).
|
||||
- Reworked the exporter engine to render output using streaming data source. This means that only a very small portion of messages are cached in memory when exporting. In other words, you are only limited by storage space and not how much RAM you have, so you can now export terabytes of chat logs without hitting out of memory exceptions. I still recommend using partitions if you're exporting very large chat logs (250k+ messages) to HTML, unless you want your computer to catch fire when you try to open the export in browser.
|
||||
- Changed how partitioned files are named, due to the fact that total number of partitions is no longer known ahead of time.
|
||||
- Added a warning about automating user accounts to the usage guide in both GUI and CLI versions.
|
||||
- Added support for announcement (news) channels. You will now be able to see them in the list and export them.
|
||||
- Fixed various issues that resulted in exceptions during export process.
|
||||
- [HTML/TXT] Removed message count from the metadata. Due to the fact that the messages are exported as they are streamed, total number of messages is not known ahead of time.
|
||||
- [HTML] Changed default color of the embed color pill to match the theme.
|
||||
- [HTML] Changed emoji parser to be less greedy. As a result it should match fewer character sequences that look like emojis but really aren't, but on the other hand it might miss some actual emojis. This means that some standard emojis (i.e. not custom server emojis) may not look like in Discord. This is a compromise I'm willing to take because detecting emojis in text is really hard to get right and not worth it at all.
|
||||
- [HTML] Some other minor styling adjustments.
|
||||
- [CSV] Replaced semicolons (;) with (,) as separators.
|
||||
|
||||
Please note that CSV export is most likely not going to receive future updates and will remain only as an option for those who wish to use it for text analysis. In other words, it won't contain any of the rich media features like embeds. If you're currently using CSV as a structured output to feed into your program, JSON output will replace that in the future.
|
||||
|
||||
### v2.15 (15-Sep-2019)
|
||||
|
||||
- Improved markdown parser and made it even faster for non-HTML formats.
|
||||
- [HTML] Added support for block quotes.
|
||||
- [HTML] Links pointing to a Discord message will now navigate to the linked message inside exported chat log if it's there.
|
||||
- [HTML] Updated light theme to match how it looks in Discord after recent changes.
|
||||
- [HTML] Added indication for when a message is pinned. Pinned messages now have a tinted background.
|
||||
- [HTML] Fixed an issue where multiline code blocks sometimes had incorrect formatting applied to them.
|
||||
- [TXT] Added indication for when a message is pinned. Pinned messages now have `(pinned)` next to timestamp and author.
|
||||
- [CSV] Added message author's user ID to output.
|
||||
- [GUI] Streamlined auto-update process a bit.
|
||||
- [GUI] Added some tooltips.
|
||||
|
||||
### v2.14 (15-Jun-2019)
|
||||
|
||||
- [TXT] Added support for embeds.
|
||||
- [TXT] Added support for reactions.
|
||||
- [CSV] Added support for reactions.
|
||||
- [TXT] Changed how attachments are rendered.
|
||||
|
||||
### v2.13.1 (06-Jun-2019)
|
||||
|
||||
- Fixed an issue where the app sometimes crashed when exporting due to `System.InvalidCastException`.
|
||||
- [HTML] Added `data-user-id` attribute to `span.chatlog__author-name`. The value of this attribute is author's Discord user ID.
|
||||
|
||||
### v2.13 (15-May-2019)
|
||||
|
||||
- Updated usage instructions.
|
||||
- [HTML] Added BOT tags to messages posted by bots.
|
||||
- [GUI] UI will now be disabled when exporting.
|
||||
- [GUI] Notification informing that the export has completed will now appear after all exports have completed, instead of each of them separately.
|
||||
|
||||
### v2.12.1 (12-Apr-2019)
|
||||
|
||||
- [GUI] Fixed an issue where the app crashed when trying to export multiple channels.
|
||||
- [HTML] Fixed an issue where some of the emojis were rendered via Twemoji while Discord renders them as plain text.
|
||||
- [HTML] Fixed an issue where emojis weren't jumbo if there were more than one of them.
|
||||
|
||||
### v2.12 (11-Apr-2019)
|
||||
|
||||
- Improved markdown parsing performance which speeds up the final stage of exporting by around 4.5 times.
|
||||
- Improved performance when exporting as PlainText or CSV.
|
||||
- [GUI] Fixed an issue where the app would freeze up and report as "not responding" during the final stage of exporting.
|
||||
- [CLI] Fixed an issue where it would say "Completed" after exporting a chat log, even if the process failed.
|
||||
|
||||
### v2.11 (14-Mar-2019)
|
||||
|
||||
- [HTML] Added syntax highlighting for multiline code blocks via Highlight.js.
|
||||
- Added retry policy for all 5xx status codes to prevent random crashes.
|
||||
|
||||
### v2.10.2 (10-Mar-2019)
|
||||
|
||||
- [HTML] Updated message grouping algorithm to make it the same as in Discord. Removed "message group limit" setting and parameter.
|
||||
- [HTML] Added small margin at the end of the chatlog so it doesn't look like it was truncated.
|
||||
- [CLI] Fixed an issue where the app would crash if stdout was redirected. Progress will not be reported in such cases.
|
||||
|
||||
### v2.10.1 (06-Mar-2019)
|
||||
|
||||
- [HTML] Fixed an issue where multiple emojis on a single line would get rendered as one emoji.
|
||||
- [HTML] Fixed an issue where an open square bracket character would incorrectly render as an emoji.
|
||||
- [HTML] Added alt text to emoji images so that they will be rendered using default font when Twemoji images are not available.
|
||||
|
||||
### v2.10 (03-Mar-2019)
|
||||
|
||||
- [HTML] Reworked the entire markdown parsing engine which fixes numerous rendering inconsistencies (over 15 issues fixed).
|
||||
- [HTML] Added support for animated emojis.
|
||||
- [HTML] Standard emojis are now rendered using Twemoji, the same image set that Discord uses.
|
||||
- [HTML] Fixed an issue where video attachments would render incorrectly.
|
||||
- [HTML] Minor fixes in styling.
|
||||
- [GUI] Aggregated export progress is now shown in the taskbar.
|
||||
- [GUI] Checkmarks are now displayed next to channels that are selected.
|
||||
- [CLI] Added progress reporting when exporting chat logs.
|
||||
|
||||
### v2.9.1 (10-Feb-2019)
|
||||
|
||||
- Fixed an issue where some of the last messages in a channel would sometimes not get exported.
|
||||
|
||||
### v2.9 (09-Feb-2019)
|
||||
|
||||
- Added categories to channel list in GUI. Channels are now also grouped by category.
|
||||
- Added ability to export multiple channels at once in GUI by selecting them with shift or ctrl keys.
|
||||
- Added CLI command to export all available channels in a guild (`exportguild`).
|
||||
- Added CLI command to export all direct message channels (`exportdm`).
|
||||
- Added retry policy for 503 status code errors to prevent random crashes.
|
||||
- Changed how channels are exported in GUI. Now you need to select them first and then click a button to export. You can also double click a single channel to export it as before.
|
||||
- Improved default file name generation by adding date ranges and channel ID to the file name.
|
||||
- Improved naming of files exported with partitioning.
|
||||
- Improved usage instructions.
|
||||
- Improved GUI layout and styling.
|
||||
- Fixed an issue where dialogs would sometimes appear over other windows.
|
||||
- Fixed an issue where launching DiscordChatExporter from Windows Start Menu would make it crash.
|
||||
|
||||
### v2.8 (03-Nov-2018)
|
||||
|
||||
- Added support for partitioning which lets you split the output into multiple files by setting the partition message limit (`-p` parameter for CLI).
|
||||
- Exported file will no longer open automatically on completion.
|
||||
- Reduced amount of memory used during exporting.
|
||||
- Disabled message grouping in PlainText export.
|
||||
- Improved encoding of newlines in CSV export.
|
||||
- Improved error messages in the GUI app during crashes.
|
||||
|
||||
### v2.7 (14-Sep-2018)
|
||||
|
||||
- Updated instructions on how to obtain the user token.
|
||||
- Expanded CLI with new commands: `channels` (get a list of channels in a guild), `dms` (get a list of DM channels), `guilds` (get a list of guilds), on top of `export` (export chatlog).
|
||||
- Expanded CLI with new commands: `channels` (get a list of channels in a guild), `dm` (get a list of DM channels), `guilds` (get a list of guilds), on top of `export` (export chatlog).
|
||||
- Improved help text and error messages in CLI.
|
||||
- In CLI, if the provided output file path is a directory, a file name will be generated and appended to it automatically.
|
||||
- In CLI, the file name will be automatically generated if the provided output file path is a directory.
|
||||
|
||||
### v2.6 (25-Jul-2018)
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
|
||||
|
||||
# GUI
|
||||
$files = @()
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
|
||||
|
||||
# CLI
|
||||
$files = @()
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Utilities;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
public abstract class ExportCommandBase : TokenCommandBase
|
||||
{
|
||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
||||
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption("format", 'f', Description = "Output file format.")]
|
||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
[CommandOption("after", Description = "Limit to messages sent after this date.")]
|
||||
public DateTimeOffset? After { get; set; }
|
||||
|
||||
[CommandOption("before", Description = "Limit to messages sent before this date.")]
|
||||
public DateTimeOffset? Before { get; set; }
|
||||
|
||||
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
|
||||
public int? PartitionLimit { get; set; }
|
||||
|
||||
[CommandOption("media", Description = "Download referenced media content.")]
|
||||
public bool ShouldDownloadMedia { get; set; }
|
||||
|
||||
[CommandOption("dateformat", Description = "Date format used in output.")]
|
||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||
|
||||
protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient());
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, Guild guild, Channel channel)
|
||||
{
|
||||
console.Output.Write($"Exporting channel '{channel.Category} / {channel.Name}'... ");
|
||||
var progress = console.CreateProgressTicker();
|
||||
|
||||
var request = new ExportRequest(
|
||||
guild,
|
||||
channel,
|
||||
OutputPath,
|
||||
ExportFormat,
|
||||
After,
|
||||
Before,
|
||||
PartitionLimit,
|
||||
ShouldDownloadMedia,
|
||||
DateFormat
|
||||
);
|
||||
|
||||
await GetChannelExporter().ExportChannelAsync(request, progress);
|
||||
|
||||
console.Output.WriteLine();
|
||||
console.Output.WriteLine("Done.");
|
||||
}
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, Channel channel)
|
||||
{
|
||||
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
|
||||
await ExportAsync(console, guild, channel);
|
||||
}
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, string channelId)
|
||||
{
|
||||
var channel = await GetDiscordClient().GetChannelAsync(channelId);
|
||||
await ExportAsync(console, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Utilities;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Exporting;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using Gress;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
||||
{
|
||||
[CommandOption("parallel", Description = "Export this number of channels in parallel.")]
|
||||
public int ParallelLimit { get; set; } = 1;
|
||||
|
||||
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
// HACK: this uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
|
||||
|
||||
console.Output.Write($"Exporting {channels.Count} channels... ");
|
||||
var progress = console.CreateProgressTicker();
|
||||
|
||||
var operations = progress.Wrap().CreateOperations(channels.Count);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
var successfulExportCount = 0;
|
||||
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||
{
|
||||
var (channel, operation) = tuple;
|
||||
|
||||
try
|
||||
{
|
||||
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
|
||||
|
||||
var request = new ExportRequest(
|
||||
guild,
|
||||
channel,
|
||||
OutputPath,
|
||||
ExportFormat,
|
||||
After,
|
||||
Before,
|
||||
PartitionLimit,
|
||||
ShouldDownloadMedia,
|
||||
DateFormat
|
||||
);
|
||||
|
||||
await GetChannelExporter().ExportChannelAsync(request, operation);
|
||||
|
||||
Interlocked.Increment(ref successfulExportCount);
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
|
||||
{
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
operation.Dispose();
|
||||
}
|
||||
}, ParallelLimit.ClampMin(1));
|
||||
|
||||
console.Output.WriteLine();
|
||||
|
||||
foreach (var error in errors)
|
||||
console.Error.WriteLine(error);
|
||||
|
||||
console.Output.WriteLine($"Successfully exported {successfulExportCount} channel(s).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
public abstract class TokenCommandBase : ICommand
|
||||
{
|
||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN",
|
||||
Description = "Authorization token.")]
|
||||
public string TokenValue { get; set; } = "";
|
||||
|
||||
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT",
|
||||
Description = "Whether this authorization token belongs to a bot.")]
|
||||
public bool IsBotToken { get; set; }
|
||||
|
||||
protected AuthToken GetAuthToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
||||
|
||||
protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken());
|
||||
|
||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("export", Description = "Export a channel.")]
|
||||
public class ExportChannelCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
|
||||
public string ChannelId { get; set; } = "";
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console) => await ExportAsync(console, ChannelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportdm", Description = "Export all direct message channels.")]
|
||||
public class ExportDirectMessagesCommand : ExportMultipleCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
await ExportMultipleAsync(console, dmChannels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
||||
public class ExportGuildCommand : ExportMultipleCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||
public string GuildId { get; set; } = "";
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
await ExportMultipleAsync(console, guildChannels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("channels", Description = "Get the list of channels in specified guild.")]
|
||||
public class GetChannelsCommand : TokenCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||
public string GuildId { get; set; } = "";
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
|
||||
foreach (var channel in guildChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("dm", Description = "Get the list of direct message channels.")]
|
||||
public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
|
||||
foreach (var channel in dmChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("guilds", Description = "Get the list of accessible guilds.")]
|
||||
public class GetGuildsCommand : TokenCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var guilds = await GetDiscordClient().GetUserGuildsAsync();
|
||||
|
||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
||||
console.Output.WriteLine($"{guild.Id} | {guild.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("guide", Description = "Explains how to obtain token, guild or channel ID.")]
|
||||
public class GuideCommand : ICommand
|
||||
{
|
||||
public ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get user token:"));
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||
console.Output.WriteLine(" 3. Navigate to the Application tab");
|
||||
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discord.com\" on the left");
|
||||
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
|
||||
console.Output.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
|
||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get bot token:"));
|
||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||
console.Output.WriteLine(" 2. Open your application's settings");
|
||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get guild ID or guild channel ID:"));
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Open Settings");
|
||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
||||
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();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get direct message channel ID:"));
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Open the desired direct message channel");
|
||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
console.Output.WriteLine(" 4. Navigate to the Console tab");
|
||||
console.Output.WriteLine(" 5. Type \"window.location.href\" and press Enter");
|
||||
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.Output.WriteLine("For more information, check out the wiki:");
|
||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using CommonServiceLocator;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using GalaSoft.MvvmLight.Ioc;
|
||||
|
||||
namespace DiscordChatExporter.Cli
|
||||
{
|
||||
public class Container
|
||||
{
|
||||
public Container()
|
||||
{
|
||||
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
|
||||
SimpleIoc.Default.Reset();
|
||||
|
||||
// Services
|
||||
SimpleIoc.Default.Register<IDataService, DataService>();
|
||||
SimpleIoc.Default.Register<IExportService, ExportService>();
|
||||
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
|
||||
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
|
||||
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
|
||||
}
|
||||
|
||||
public T Resolve<T>(string key = null)
|
||||
{
|
||||
return ServiceLocator.Current.GetInstance<T>(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../DiscordChatExporter.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<Version>2.7</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.2.1" />
|
||||
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
|
||||
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="1.3.2" />
|
||||
<PackageReference Include="Gress" Version="1.2.0" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Domain\DiscordChatExporter.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,73 +1,14 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Cli.Verbs;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
|
||||
namespace DiscordChatExporter.Cli
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static void PrintTokenHelp()
|
||||
{
|
||||
Console.WriteLine("# To get user token:");
|
||||
Console.WriteLine(" 1. Open Discord app");
|
||||
Console.WriteLine(" 2. Log in if you haven't");
|
||||
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
Console.WriteLine(" 4. Press Ctrl+R to trigger reload");
|
||||
Console.WriteLine(" 5. Navigate to the Application tab");
|
||||
Console.WriteLine(" 6. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
||||
Console.WriteLine(" 7. Find \"token\" under key and copy the value");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("# To get bot token:");
|
||||
Console.WriteLine(" 1. Go to Discord developer portal");
|
||||
Console.WriteLine(" 2. Log in if you haven't");
|
||||
Console.WriteLine(" 3. Open your application's settings");
|
||||
Console.WriteLine(" 4. Navigate to the Bot section on the left");
|
||||
Console.WriteLine(" 5. Under Token click Copy");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("# To get guild or channel ID:");
|
||||
Console.WriteLine(" 1. Open Discord app");
|
||||
Console.WriteLine(" 2. Log in if you haven't");
|
||||
Console.WriteLine(" 3. Open Settings");
|
||||
Console.WriteLine(" 4. Go to Appearance section");
|
||||
Console.WriteLine(" 5. Enable Developer Mode");
|
||||
Console.WriteLine(" 6. Right click on the desired guild or channel and click Copy ID");
|
||||
}
|
||||
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
// Get all verb types
|
||||
var verbTypes = new[]
|
||||
{
|
||||
typeof(ExportChatOptions),
|
||||
typeof(GetChannelsOptions),
|
||||
typeof(GetDirectMessageChannelsOptions),
|
||||
typeof(GetGuildsOptions),
|
||||
//typeof(UpdateAppOptions)
|
||||
};
|
||||
|
||||
// Parse command line arguments
|
||||
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
|
||||
|
||||
// Execute commands
|
||||
parsedArgs.WithParsed<ExportChatOptions>(o => new ExportChatVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<UpdateAppOptions>(o => new UpdateAppVerb(o).Execute());
|
||||
|
||||
// Show token help if help requested or no verb specified
|
||||
parsedArgs.WithNotParsed(errs =>
|
||||
{
|
||||
var err = errs.First();
|
||||
|
||||
if (err.Tag == ErrorType.NoVerbSelectedError)
|
||||
PrintTokenHelp();
|
||||
|
||||
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
|
||||
PrintTokenHelp();
|
||||
});
|
||||
}
|
||||
public static async Task<int> Main(string[] args) =>
|
||||
await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class ExportChatVerb : Verb<ExportChatOptions>
|
||||
{
|
||||
public ExportChatVerb(ExportChatOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get services
|
||||
var container = new Container();
|
||||
var settingsService = container.Resolve<ISettingsService>();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
var messageGroupService = container.Resolve<IMessageGroupService>();
|
||||
var exportService = container.Resolve<IExportService>();
|
||||
|
||||
// Configure settings
|
||||
if (Options.DateFormat.IsNotBlank())
|
||||
settingsService.DateFormat = Options.DateFormat;
|
||||
if (Options.MessageGroupLimit > 0)
|
||||
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
|
||||
|
||||
// Get channel and guild
|
||||
var channel = await dataService.GetChannelAsync(Options.GetToken(), Options.ChannelId);
|
||||
var guild = channel.GuildId == Guild.DirectMessages.Id
|
||||
? Guild.DirectMessages
|
||||
: await dataService.GetGuildAsync(Options.GetToken(), channel.GuildId);
|
||||
|
||||
// Generate file path if not set
|
||||
var filePath = Options.FilePath;
|
||||
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
|
||||
{
|
||||
filePath += $"{guild.Name} - {channel.Name}.{Options.ExportFormat.GetFileExtension()}"
|
||||
.Replace(Path.GetInvalidFileNameChars(), '_');
|
||||
}
|
||||
|
||||
// TODO: extract this to make it reusable across implementations
|
||||
// Get messages
|
||||
var messages =
|
||||
await dataService.GetChannelMessagesAsync(Options.GetToken(), channel.Id,
|
||||
Options.After, Options.Before);
|
||||
|
||||
// Group messages
|
||||
var messageGroups = messageGroupService.GroupMessages(messages);
|
||||
|
||||
// Get mentionables
|
||||
var mentionables = await dataService.GetMentionablesAsync(Options.GetToken(), guild.Id, messages);
|
||||
|
||||
// Create log
|
||||
var log = new ChatLog(guild, channel, Options.After, Options.Before, messageGroups, mentionables);
|
||||
|
||||
// Export
|
||||
exportService.Export(Options.ExportFormat, filePath, log);
|
||||
|
||||
// Print result
|
||||
Console.WriteLine($"Exported chat to [{filePath}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetChannelsVerb : Verb<GetChannelsOptions>
|
||||
{
|
||||
public GetChannelsVerb(GetChannelsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var container = new Container();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels.Where(c => c.Type.IsEither(ChannelType.GuildTextChat))
|
||||
.OrderBy(c => c.Name))
|
||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
|
||||
{
|
||||
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var container = new Container();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels.OrderBy(c => c.Name))
|
||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetGuildsVerb : Verb<GetGuildsOptions>
|
||||
{
|
||||
public GetGuildsVerb(GetGuildsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var container = new Container();
|
||||
var dataService = container.Resolve<IDataService>();
|
||||
|
||||
// Get guilds
|
||||
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
|
||||
|
||||
// Print result
|
||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
||||
Console.WriteLine($"{guild.Id} | {guild.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
using System;
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("export", HelpText = "Export channel chat log to a file.")]
|
||||
public class ExportChatOptions : TokenOptions
|
||||
{
|
||||
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
|
||||
public string ChannelId { get; set; }
|
||||
|
||||
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
|
||||
public ExportFormat ExportFormat { get; set; }
|
||||
|
||||
[Option('o', "output", Default = null, HelpText = "Output file path.")]
|
||||
public string FilePath { get; set; }
|
||||
|
||||
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
|
||||
public DateTime? After { get; set; }
|
||||
|
||||
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
|
||||
public DateTime? Before { get; set; }
|
||||
|
||||
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
|
||||
public string DateFormat { get; set; }
|
||||
|
||||
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
|
||||
public int MessageGroupLimit { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("channels", HelpText = "Get the list of channels in the given guild.")]
|
||||
public class GetChannelsOptions : TokenOptions
|
||||
{
|
||||
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
|
||||
public string GuildId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("dm", HelpText = "Get the list of direct message channels.")]
|
||||
public class GetDirectMessageChannelsOptions : TokenOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
|
||||
public class GetGuildsOptions : TokenOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
public class TokenOptions
|
||||
{
|
||||
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
|
||||
public string TokenValue { get; set; }
|
||||
|
||||
[Option('b', "bot", Default = false, HelpText = "Whether this authorization token belongs to a bot.")]
|
||||
public bool IsBotToken { get; set; }
|
||||
|
||||
public AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("update", HelpText = "Updates this application to the latest version.")]
|
||||
public class UpdateAppOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class UpdateAppVerb : Verb<UpdateAppOptions>
|
||||
{
|
||||
public UpdateAppVerb(UpdateAppOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get update service
|
||||
var container = new Container();
|
||||
var updateService = container.Resolve<IUpdateService>();
|
||||
|
||||
// TODO: this is configured only for GUI
|
||||
// Get update version
|
||||
var updateVersion = await updateService.CheckPrepareUpdateAsync();
|
||||
|
||||
if (updateVersion != null)
|
||||
{
|
||||
Console.WriteLine($"Updating to version {updateVersion}");
|
||||
|
||||
updateService.NeedRestart = false;
|
||||
updateService.FinalizeUpdate();
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("There are no application updates available.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public abstract class Verb<TOptions>
|
||||
{
|
||||
protected TOptions Options { get; }
|
||||
|
||||
protected Verb(TOptions options)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public abstract Task ExecuteAsync();
|
||||
|
||||
public virtual void Execute() => ExecuteAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<Version>2.7</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\Core.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\Shared.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\DarkTheme.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\LightTheme.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Csv.csv" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="mscorlib" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
||||
<PackageReference Include="Onova" Version="2.1.0" />
|
||||
<PackageReference Include="Polly" Version="6.0.1" />
|
||||
<PackageReference Include="Scriban" Version="1.2.1" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,19 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exceptions
|
||||
{
|
||||
public class HttpErrorStatusCodeException : Exception
|
||||
{
|
||||
public HttpStatusCode StatusCode { get; }
|
||||
|
||||
public string ReasonPhrase { get; }
|
||||
|
||||
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
|
||||
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ReasonPhrase = reasonPhrase;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Internal
|
||||
{
|
||||
internal static class Extensions
|
||||
{
|
||||
public static string ToSnowflake(this DateTime dateTime)
|
||||
{
|
||||
const long epoch = 62135596800000;
|
||||
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
|
||||
var value = ((ulong) unixTime - 1420070400000UL) << 22;
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
public static string Base64Encode(this string str) => str.GetBytes().ToBase64();
|
||||
|
||||
public static string Base64Decode(this string str) => str.FromBase64().GetString();
|
||||
|
||||
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||
|
||||
public class Attachment
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public int? Width { get; }
|
||||
|
||||
public int? Height { get; }
|
||||
|
||||
public bool IsImage => Width != null;
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public long FileSize { get; }
|
||||
|
||||
public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FileName = fileName;
|
||||
FileSize = fileSize;
|
||||
}
|
||||
|
||||
public override string ToString() => FileName;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class AuthToken
|
||||
{
|
||||
public AuthTokenType Type { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public AuthToken(AuthTokenType type, string value)
|
||||
{
|
||||
Type = type;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum AuthTokenType
|
||||
{
|
||||
User,
|
||||
Bot
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#channel-object
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string GuildId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string Topic { get; }
|
||||
|
||||
public ChannelType Type { get; }
|
||||
|
||||
public Channel(string id, string guildId, string name, string topic, ChannelType type)
|
||||
{
|
||||
Id = id;
|
||||
GuildId = guildId;
|
||||
Name = name;
|
||||
Topic = topic;
|
||||
Type = type;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
public static Channel CreateDeletedChannel(string id) =>
|
||||
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
|
||||
public enum ChannelType
|
||||
{
|
||||
GuildTextChat,
|
||||
DirectTextChat,
|
||||
GuildVoiceChat,
|
||||
DirectGroupTextChat,
|
||||
Category
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class ChatLog
|
||||
{
|
||||
public Guild Guild { get; }
|
||||
|
||||
public Channel Channel { get; }
|
||||
|
||||
public DateTime? From { get; }
|
||||
|
||||
public DateTime? To { get; }
|
||||
|
||||
public IReadOnlyList<MessageGroup> MessageGroups { get; }
|
||||
|
||||
public int TotalMessageCount => MessageGroups.Sum(g => g.Messages.Count);
|
||||
|
||||
public Mentionables Mentionables { get; }
|
||||
|
||||
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
|
||||
IReadOnlyList<MessageGroup> messageGroups, Mentionables mentionables)
|
||||
{
|
||||
Guild = guild;
|
||||
Channel = channel;
|
||||
From = from;
|
||||
To = to;
|
||||
MessageGroups = messageGroups;
|
||||
Mentionables = mentionables;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#embed-object
|
||||
|
||||
public class Embed
|
||||
{
|
||||
public string Title { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public DateTime? Timestamp { get; }
|
||||
|
||||
public Color Color { get; }
|
||||
|
||||
public EmbedAuthor Author { get; }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public IReadOnlyList<EmbedField> Fields { get; }
|
||||
|
||||
public EmbedImage Thumbnail { get; }
|
||||
|
||||
public EmbedImage Image { get; }
|
||||
|
||||
public EmbedFooter Footer { get; }
|
||||
|
||||
public Embed(string title, string url, DateTime? timestamp, Color color, EmbedAuthor author, string description,
|
||||
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
|
||||
{
|
||||
Title = title;
|
||||
Url = url;
|
||||
Timestamp = timestamp;
|
||||
Color = color;
|
||||
Author = author;
|
||||
Description = description;
|
||||
Fields = fields;
|
||||
Thumbnail = thumbnail;
|
||||
Image = image;
|
||||
Footer = footer;
|
||||
}
|
||||
|
||||
public override string ToString() => Title;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||
|
||||
public class EmbedAuthor
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public string IconUrl { get; }
|
||||
|
||||
public EmbedAuthor(string name, string url, string iconUrl)
|
||||
{
|
||||
Name = name;
|
||||
Url = url;
|
||||
IconUrl = iconUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||
|
||||
public class EmbedField
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public bool IsInline { get; }
|
||||
|
||||
public EmbedField(string name, string value, bool isInline)
|
||||
{
|
||||
Name = name;
|
||||
Value = value;
|
||||
IsInline = isInline;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Name} | {Value}";
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||
|
||||
public class EmbedFooter
|
||||
{
|
||||
public string Text { get; }
|
||||
|
||||
public string IconUrl { get; }
|
||||
|
||||
public EmbedFooter(string text, string iconUrl)
|
||||
{
|
||||
Text = text;
|
||||
IconUrl = iconUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Text;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||
|
||||
public class EmbedImage
|
||||
{
|
||||
public string Url { get; }
|
||||
|
||||
public int? Width { get; }
|
||||
|
||||
public int? Height { get; }
|
||||
|
||||
public EmbedImage(string url, int? width, int? height)
|
||||
{
|
||||
Url = url;
|
||||
Height = height;
|
||||
Width = width;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public enum ExportFormat
|
||||
{
|
||||
PlainText,
|
||||
HtmlDark,
|
||||
HtmlLight,
|
||||
Csv
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public static class Extensions
|
||||
{
|
||||
public static string GetFileExtension(this ExportFormat format)
|
||||
{
|
||||
if (format == ExportFormat.PlainText)
|
||||
return "txt";
|
||||
if (format == ExportFormat.HtmlDark)
|
||||
return "html";
|
||||
if (format == ExportFormat.HtmlLight)
|
||||
return "html";
|
||||
if (format == ExportFormat.Csv)
|
||||
return "csv";
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(format));
|
||||
}
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format)
|
||||
{
|
||||
if (format == ExportFormat.PlainText)
|
||||
return "Plain Text";
|
||||
if (format == ExportFormat.HtmlDark)
|
||||
return "HTML (Dark)";
|
||||
if (format == ExportFormat.HtmlLight)
|
||||
return "HTML (Light)";
|
||||
if (format == ExportFormat.Csv)
|
||||
return "Comma Seperated Values (CSV)";
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(format));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string IconHash { get; }
|
||||
|
||||
public string IconUrl => IconHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
|
||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
|
||||
public Guild(string id, string name, string iconHash)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IconHash = iconHash;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class Mentionables
|
||||
{
|
||||
public IReadOnlyList<User> Users { get; }
|
||||
|
||||
public IReadOnlyList<Channel> Channels { get; }
|
||||
|
||||
public IReadOnlyList<Role> Roles { get; }
|
||||
|
||||
public Mentionables(IReadOnlyList<User> users, IReadOnlyList<Channel> channels, IReadOnlyList<Role> roles)
|
||||
{
|
||||
Users = users;
|
||||
Channels = channels;
|
||||
Roles = roles;
|
||||
}
|
||||
|
||||
public User GetUser(string id) =>
|
||||
Users.FirstOrDefault(u => u.Id == id) ?? User.CreateUnknownUser(id);
|
||||
|
||||
public Channel GetChannel(string id) =>
|
||||
Channels.FirstOrDefault(c => c.Id == id) ?? Channel.CreateDeletedChannel(id);
|
||||
|
||||
public Role GetRole(string id) =>
|
||||
Roles.FirstOrDefault(r => r.Id == id) ?? Role.CreateDeletedRole(id);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#message-object
|
||||
|
||||
public class Message
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string ChannelId { get; }
|
||||
|
||||
public MessageType Type { get; }
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
public DateTime? EditedTimestamp { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public IReadOnlyList<Attachment> Attachments { get; }
|
||||
|
||||
public IReadOnlyList<Embed> Embeds { get; }
|
||||
|
||||
public IReadOnlyList<Reaction> Reactions { get; }
|
||||
|
||||
public IReadOnlyList<User> MentionedUsers { get; }
|
||||
|
||||
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
|
||||
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
||||
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
|
||||
{
|
||||
Id = id;
|
||||
ChannelId = channelId;
|
||||
Type = type;
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
EditedTimestamp = editedTimestamp;
|
||||
Content = content;
|
||||
Attachments = attachments;
|
||||
Embeds = embeds;
|
||||
Reactions = reactions;
|
||||
MentionedUsers = mentionedUsers;
|
||||
}
|
||||
|
||||
public override string ToString() => Content;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
public class MessageGroup
|
||||
{
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
Messages = messages;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages";
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
Default,
|
||||
RecipientAdd,
|
||||
RecipientRemove,
|
||||
Call,
|
||||
ChannelNameChange,
|
||||
ChannelIconChange,
|
||||
ChannelPinnedMessage,
|
||||
GuildMemberJoin
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#reaction-object
|
||||
|
||||
public class Reaction
|
||||
{
|
||||
public int Count { get; }
|
||||
|
||||
public string EmojiId { get; }
|
||||
|
||||
public string EmojiName { get; }
|
||||
|
||||
public Reaction(int count, string emojiId, string emojiName)
|
||||
{
|
||||
Count = count;
|
||||
EmojiId = emojiId;
|
||||
EmojiName = emojiName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||
|
||||
public partial class Role
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public Role(string id, string name)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Role
|
||||
{
|
||||
public static Role CreateDeletedRole(string id) =>
|
||||
new Role(id, "deleted-role");
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||
|
||||
public partial class User
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public int Discriminator { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||
|
||||
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
||||
|
||||
public string AvatarHash { get; }
|
||||
|
||||
public bool IsAvatarAnimated =>
|
||||
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
|
||||
|
||||
public string AvatarUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
// Custom avatar
|
||||
if (AvatarHash.IsNotBlank())
|
||||
{
|
||||
// Animated
|
||||
if (IsAvatarAnimated)
|
||||
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.gif";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png";
|
||||
}
|
||||
|
||||
// Default avatar
|
||||
return $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
|
||||
}
|
||||
}
|
||||
|
||||
public User(string id, int discriminator, string name, string avatarHash)
|
||||
{
|
||||
Id = id;
|
||||
Discriminator = discriminator;
|
||||
Name = name;
|
||||
AvatarHash = avatarHash;
|
||||
}
|
||||
|
||||
public override string ToString() => FullName;
|
||||
}
|
||||
|
||||
public partial class User
|
||||
{
|
||||
public static User CreateUnknownUser(string id) =>
|
||||
new User(id, 0, "Unknown", null);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
Author;Date;Content;Attachments;
|
||||
{{~ for group in Model.MessageGroups -}}
|
||||
{{- for message in group.Messages -}}
|
||||
{{- message.Author.FullName }};
|
||||
|
||||
{{- message.Timestamp | FormatDate }};
|
||||
|
||||
{{- message.Content | FormatContent }};
|
||||
|
||||
{{- message.Attachments | array.map "Url" | array.join "," }};
|
||||
{{~ end -}}
|
||||
{{- end -}}
|
||||
|
Can't render this file because it contains an unexpected character in line 10 and column 41.
|
@@ -1,216 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<style>
|
||||
{{ StyleSheet }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{{~ # Info ~}}
|
||||
<div class="info">
|
||||
<div class="info__guild-icon-container">
|
||||
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
|
||||
</div>
|
||||
<div class="info__metadata">
|
||||
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
|
||||
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
|
||||
|
||||
{{~ if Model.Channel.Topic ~}}
|
||||
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
|
||||
{{~ end ~}}
|
||||
|
||||
<div class="info__channel-message-count">{{ Model.TotalMessageCount | Format "N0" }} messages</div>
|
||||
|
||||
{{~ if Model.From || Model.To ~}}
|
||||
<div class="info__channel-date-range">
|
||||
{{~ if Model.From && Model.To ~}}
|
||||
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
|
||||
{{~ else if Model.From ~}}
|
||||
After {{ Model.From | FormatDate | html.escape }}
|
||||
{{~ else if Model.To ~}}
|
||||
Before {{ Model.To | FormatDate | html.escape }}
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{~ # Log ~}}
|
||||
<div class="chatlog">
|
||||
{{~ for group in Model.MessageGroups ~}}
|
||||
<div class="chatlog__message-group">
|
||||
{{~ # Avatar ~}}
|
||||
<div class="chatlog__author-avatar-container">
|
||||
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
|
||||
</div>
|
||||
<div class="chatlog__messages">
|
||||
{{~ # Author name and timestamp ~}}
|
||||
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
|
||||
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
|
||||
|
||||
{{~ # Messages ~}}
|
||||
{{~ for message in group.Messages ~}}
|
||||
{{~ # Content ~}}
|
||||
{{~ if message.Content ~}}
|
||||
<div class="chatlog__content">
|
||||
{{ message.Content | FormatContent }}
|
||||
|
||||
{{~ # Edited timestamp ~}}
|
||||
{{~ if message.EditedTimestamp ~}}
|
||||
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Attachments ~}}
|
||||
{{~ for attachment in message.Attachments ~}}
|
||||
<div class="chatlog__attachment">
|
||||
<a href="{{ attachment.Url }}">
|
||||
{{ # Image }}
|
||||
{{~ if attachment.IsImage ~}}
|
||||
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
|
||||
{{~ # Non-image ~}}
|
||||
{{~ else ~}}
|
||||
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
|
||||
{{~ end ~}}
|
||||
</a>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Embeds ~}}
|
||||
{{~ for embed in message.Embeds ~}}
|
||||
<div class="chatlog__embed">
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div>
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
{{~ # Author ~}}
|
||||
{{~ if embed.Author ~}}
|
||||
<div class="chatlog__embed-author">
|
||||
{{~ if embed.Author.IconUrl ~}}
|
||||
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ if embed.Author.Name ~}}
|
||||
<span class="chatlog__embed-author-name">
|
||||
{{~ if embed.Author.Url ~}}
|
||||
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
|
||||
{{~ else ~}}
|
||||
{{ embed.Author.Name | html.escape }}
|
||||
{{~ end ~}}
|
||||
</span>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Title ~}}
|
||||
{{~ if embed.Title ~}}
|
||||
<div class="chatlog__embed-title">
|
||||
{{~ if embed.Url ~}}
|
||||
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
|
||||
{{~ else ~}}
|
||||
{{ embed.Title | FormatContent }}
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Description ~}}
|
||||
{{~ if embed.Description ~}}
|
||||
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Fields ~}}
|
||||
{{~ if embed.Fields | array.size > 0 ~}}
|
||||
<div class="chatlog__embed-fields">
|
||||
{{~ for field in embed.Fields ~}}
|
||||
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
||||
{{~ if field.Name ~}}
|
||||
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
|
||||
{{~ end ~}}
|
||||
{{~ if field.Value ~}}
|
||||
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
|
||||
{{~ # Thumbnail ~}}
|
||||
{{~ if embed.Thumbnail ~}}
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
|
||||
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
|
||||
</a>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
|
||||
{{~ # Image ~}}
|
||||
{{~ if embed.Image ~}}
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
|
||||
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
|
||||
</a>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Footer ~}}
|
||||
{{~ if embed.Footer || embed.Timestamp ~}}
|
||||
<div class="chatlog__embed-footer">
|
||||
{{~ if embed.Footer ~}}
|
||||
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
|
||||
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
|
||||
{{~ end ~}}
|
||||
{{~ end ~}}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
{{~ if embed.Footer ~}}
|
||||
{{~ if embed.Footer.Text ~}}
|
||||
{{ embed.Footer.Text | html.escape }}
|
||||
{{ if embed.Timestamp }} • {{ end }}
|
||||
{{~ end ~}}
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ if embed.Timestamp ~}}
|
||||
{{ embed.Timestamp | FormatDate | html.escape }}
|
||||
{{~ end ~}}
|
||||
</span>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Reactions ~}}
|
||||
{{~ if message.Reactions | array.size > 0 ~}}
|
||||
<div class="chatlog__reactions">
|
||||
{{~ for reaction in message.Reactions ~}}
|
||||
<div class="chatlog__reaction">
|
||||
<span class="chatlog__reaction-emoji">
|
||||
{{~ # Custom emoji ~}}
|
||||
{{~ if reaction.EmojiId ~}}
|
||||
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
|
||||
{{~ # Default emoji ~}}
|
||||
{{~ else ~}}
|
||||
{{ reaction.EmojiName }}
|
||||
{{~ end ~}}
|
||||
</span>
|
||||
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,97 +0,0 @@
|
||||
/* === GENERAL === */
|
||||
|
||||
body {
|
||||
background-color: #36393e;
|
||||
color: #ffffffb3;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0096cf;
|
||||
}
|
||||
|
||||
.pre {
|
||||
background-color: #2f3136;
|
||||
}
|
||||
|
||||
.pre--multiline {
|
||||
border-color: #282b30;
|
||||
color: #839496;
|
||||
}
|
||||
|
||||
.mention {
|
||||
background-color: #738bd71a;
|
||||
color: #7289da;
|
||||
}
|
||||
|
||||
/* === INFO === */
|
||||
|
||||
.info__guild-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.info__channel-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.info__channel-topic {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* === CHATLOG === */
|
||||
|
||||
.chatlog__message-group {
|
||||
border-color: #ffffff0a;
|
||||
}
|
||||
|
||||
.chatlog__author-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chatlog__timestamp {
|
||||
color: #ffffff33;
|
||||
}
|
||||
|
||||
.chatlog__edited-timestamp {
|
||||
color: #ffffff33;
|
||||
}
|
||||
|
||||
.chatlog__embed-content-container {
|
||||
background-color: #2e30364d;
|
||||
border-color: #2e303699;
|
||||
}
|
||||
|
||||
.chatlog__embed-author-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chatlog__embed-author-name-link {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chatlog__embed-title {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chatlog__embed-description {
|
||||
color: #ffffff99;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-value {
|
||||
color: #ffffff99;
|
||||
}
|
||||
|
||||
.chatlog__embed-footer {
|
||||
color: #ffffff99;
|
||||
}
|
||||
|
||||
.chatlog__reaction {
|
||||
background-color: #ffffff0a;
|
||||
}
|
||||
|
||||
.chatlog__reaction-count {
|
||||
color: #ffffff4d;
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/* === GENERAL === */
|
||||
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #737f8d;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00b0f4;
|
||||
}
|
||||
|
||||
.pre {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.pre--multiline {
|
||||
border-color: #f3f3f3;
|
||||
color: #657b83;
|
||||
}
|
||||
|
||||
.mention {
|
||||
background-color: #f1f3fb;
|
||||
color: #7289da;
|
||||
}
|
||||
|
||||
/* === INFO === */
|
||||
|
||||
.info__guild-name {
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
.info__channel-name {
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
.info__channel-topic {
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
/* === CHATLOG === */
|
||||
|
||||
.chatlog__message-group {
|
||||
border-color: #eceeef;
|
||||
}
|
||||
|
||||
.chatlog__author-name {
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
.chatlog__timestamp {
|
||||
color: #99aab5;
|
||||
}
|
||||
|
||||
.chatlog__edited-timestamp {
|
||||
color: #99aab5;
|
||||
}
|
||||
|
||||
.chatlog__embed-content-container {
|
||||
background-color: #f9f9f94d;
|
||||
border-color: #cccccc4d;
|
||||
}
|
||||
|
||||
.chatlog__embed-author-name {
|
||||
color: #4f545c;
|
||||
}
|
||||
|
||||
.chatlog__embed-author-name-link {
|
||||
color: #4f545c;
|
||||
}
|
||||
|
||||
.chatlog__embed-title {
|
||||
color: #4f545c;
|
||||
}
|
||||
|
||||
.chatlog__embed-description {
|
||||
color: #737f8d;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-name {
|
||||
color: #36393e;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-value {
|
||||
color: #737f8d;
|
||||
}
|
||||
|
||||
.chatlog__embed-footer {
|
||||
color: #4f535b99;
|
||||
}
|
||||
|
||||
.chatlog__reaction {
|
||||
background-color: #4f545c0f;
|
||||
}
|
||||
|
||||
.chatlog__reaction-count {
|
||||
color: #99aab5;
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
/* === GENERAL === */
|
||||
|
||||
body {
|
||||
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pre {
|
||||
font-family: "Consolas", "Courier New", Courier, Monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pre--multiline {
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
border: 2px solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.pre--inline {
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin: 0 1px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.emoji--small {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.emoji--large {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.mention {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === INFO === */
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
margin: 0 5px 10px 5px;
|
||||
}
|
||||
|
||||
.info__guild-icon-container {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.info__guild-icon {
|
||||
max-width: 88px;
|
||||
max-height: 88px;
|
||||
}
|
||||
|
||||
.info__metadata {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.info__guild-name {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.info__channel-name {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.info__channel-topic {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info__channel-message-count {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info__channel-date-range {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* === CHATLOG === */
|
||||
|
||||
.chatlog {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chatlog__message-group {
|
||||
display: flex;
|
||||
margin: 0 10px;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.chatlog__author-avatar-container {
|
||||
flex: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.chatlog__author-avatar {
|
||||
border-radius: 50%;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.chatlog__messages {
|
||||
flex: 1;
|
||||
min-width: 50%;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.chatlog__author-name {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.chatlog__timestamp {
|
||||
margin-left: 5px;
|
||||
font-size: .75em;
|
||||
}
|
||||
|
||||
.chatlog__content {
|
||||
padding-top: 5px;
|
||||
font-size: .9375em;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chatlog__edited-timestamp {
|
||||
margin-left: 5px;
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
.chatlog__attachment {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.chatlog__attachment-thumbnail {
|
||||
max-width: 50%;
|
||||
max-height: 500px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed {
|
||||
display: flex;
|
||||
max-width: 520px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chatlog__embed-color-pill {
|
||||
flex-shrink: 0;
|
||||
width: 4px;
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed-content-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chatlog__embed-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chatlog__embed-author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.chatlog__embed-author-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.chatlog__embed-author-name {
|
||||
font-size: .875em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatlog__embed-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: .875em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatlog__embed-description {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chatlog__embed-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chatlog__embed-field {
|
||||
flex: 0;
|
||||
min-width: 100%;
|
||||
max-width: 506px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.chatlog__embed-field--inline {
|
||||
flex: 1;
|
||||
flex-basis: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-name {
|
||||
margin-bottom: 4px;
|
||||
font-size: .875em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-value {
|
||||
font-size: .875em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chatlog__embed-thumbnail {
|
||||
flex: 0;
|
||||
margin-left: 20px;
|
||||
max-width: 80px;
|
||||
max-height: 80px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed-image-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chatlog__embed-image {
|
||||
max-width: 500px;
|
||||
max-height: 400px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chatlog__embed-footer-icon {
|
||||
margin-right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chatlog__embed-footer-text {
|
||||
font-weight: 600;
|
||||
font-size: .75em;
|
||||
}
|
||||
|
||||
.chatlog__reactions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chatlog__reaction {
|
||||
margin: 6px 2px 2px 2px;
|
||||
padding: 2px 6px 2px 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__reaction-emoji {
|
||||
margin-left: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chatlog__reaction-count {
|
||||
font-size: .875em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{{
|
||||
$SharedStyleSheet = include "Html.Shared.css"
|
||||
$ThemeStyleSheet = include "Html.DarkTheme.css"
|
||||
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
|
||||
}}
|
||||
|
||||
{{ include "Html.Core.html" }}
|
||||
@@ -1,7 +0,0 @@
|
||||
{{
|
||||
$SharedStyleSheet = include "Html.Shared.css"
|
||||
$ThemeStyleSheet = include "Html.LightTheme.css"
|
||||
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
|
||||
}}
|
||||
|
||||
{{ include "Html.Core.html" }}
|
||||
@@ -1,25 +0,0 @@
|
||||
{{~ # Info ~}}
|
||||
==============================================================
|
||||
Guild: {{ Model.Guild.Name }}
|
||||
Channel: {{ Model.Channel.Name }}
|
||||
Topic: {{ Model.Channel.Topic }}
|
||||
Messages: {{ Model.TotalMessageCount | Format "N0" }}
|
||||
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
|
||||
==============================================================
|
||||
|
||||
{{~ # Log ~}}
|
||||
{{~ for group in Model.MessageGroups ~}}
|
||||
{{~ # Author name and timestamp ~}}
|
||||
{{~ }}[{{ group.Timestamp | FormatDate }}] {{ group.Author.FullName }}
|
||||
{{~ # Messages ~}}
|
||||
{{~ for message in group.Messages ~}}
|
||||
{{~ # Content ~}}
|
||||
{{~ message.Content | FormatContent }}
|
||||
{{~ # Attachments ~}}
|
||||
{{~ for attachment in message.Attachments ~}}
|
||||
{{~ attachment.Url }}
|
||||
{{~ end ~}}
|
||||
{{~ end ~}}
|
||||
|
||||
|
||||
{{~ end ~}}
|
||||
@@ -1,196 +0,0 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Internal;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class DataService
|
||||
{
|
||||
private User ParseUser(JToken json)
|
||||
{
|
||||
var id = json["id"].Value<string>();
|
||||
var discriminator = json["discriminator"].Value<int>();
|
||||
var name = json["username"].Value<string>();
|
||||
var avatarHash = json["avatar"].Value<string>();
|
||||
|
||||
return new User(id, discriminator, name, avatarHash);
|
||||
}
|
||||
|
||||
private Guild ParseGuild(JToken json)
|
||||
{
|
||||
var id = json["id"].Value<string>();
|
||||
var name = json["name"].Value<string>();
|
||||
var iconHash = json["icon"].Value<string>();
|
||||
|
||||
return new Guild(id, name, iconHash);
|
||||
}
|
||||
|
||||
private Channel ParseChannel(JToken json)
|
||||
{
|
||||
// Get basic data
|
||||
var id = json["id"].Value<string>();
|
||||
var type = (ChannelType) json["type"].Value<int>();
|
||||
var topic = json["topic"]?.Value<string>();
|
||||
|
||||
// Try to extract guild ID
|
||||
var guildId = json["guild_id"]?.Value<string>();
|
||||
|
||||
// If the guild ID is blank, it's direct messages
|
||||
if (guildId.IsBlank())
|
||||
guildId = Guild.DirectMessages.Id;
|
||||
|
||||
// Try to extract name
|
||||
var name = json["name"]?.Value<string>();
|
||||
|
||||
// If the name is blank, it's direct messages
|
||||
if (name.IsBlank())
|
||||
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
|
||||
|
||||
return new Channel(id, guildId, name, topic, type);
|
||||
}
|
||||
|
||||
private Role ParseRole(JToken json)
|
||||
{
|
||||
var id = json["id"].Value<string>();
|
||||
var name = json["name"].Value<string>();
|
||||
|
||||
return new Role(id, name);
|
||||
}
|
||||
|
||||
private Attachment ParseAttachment(JToken json)
|
||||
{
|
||||
var id = json["id"].Value<string>();
|
||||
var url = json["url"].Value<string>();
|
||||
var width = json["width"]?.Value<int>();
|
||||
var height = json["height"]?.Value<int>();
|
||||
var fileName = json["filename"].Value<string>();
|
||||
var fileSize = json["size"].Value<long>();
|
||||
|
||||
return new Attachment(id, width, height, url, fileName, fileSize);
|
||||
}
|
||||
|
||||
private EmbedAuthor ParseEmbedAuthor(JToken json)
|
||||
{
|
||||
var name = json["name"]?.Value<string>();
|
||||
var url = json["url"]?.Value<string>();
|
||||
var iconUrl = json["icon_url"]?.Value<string>();
|
||||
|
||||
return new EmbedAuthor(name, url, iconUrl);
|
||||
}
|
||||
|
||||
private EmbedField ParseEmbedField(JToken json)
|
||||
{
|
||||
var name = json["name"].Value<string>();
|
||||
var value = json["value"].Value<string>();
|
||||
var isInline = json["inline"]?.Value<bool>() ?? false;
|
||||
|
||||
return new EmbedField(name, value, isInline);
|
||||
}
|
||||
|
||||
private EmbedImage ParseEmbedImage(JToken json)
|
||||
{
|
||||
var url = json["url"]?.Value<string>();
|
||||
var width = json["width"]?.Value<int>();
|
||||
var height = json["height"]?.Value<int>();
|
||||
|
||||
return new EmbedImage(url, width, height);
|
||||
}
|
||||
|
||||
private EmbedFooter ParseEmbedFooter(JToken json)
|
||||
{
|
||||
var text = json["text"].Value<string>();
|
||||
var iconUrl = json["icon_url"]?.Value<string>();
|
||||
|
||||
return new EmbedFooter(text, iconUrl);
|
||||
}
|
||||
|
||||
private Embed ParseEmbed(JToken json)
|
||||
{
|
||||
// Get basic data
|
||||
var title = json["title"]?.Value<string>();
|
||||
var description = json["description"]?.Value<string>();
|
||||
var url = json["url"]?.Value<string>();
|
||||
var timestamp = json["timestamp"]?.Value<DateTime>();
|
||||
|
||||
// Get color
|
||||
var color = json["color"] != null
|
||||
? Color.FromArgb(json["color"].Value<int>()).ResetAlpha()
|
||||
: Color.FromArgb(79, 84, 92); // default color
|
||||
|
||||
// Get author
|
||||
var author = json["author"] != null ? ParseEmbedAuthor(json["author"]) : null;
|
||||
|
||||
// Get fields
|
||||
var fields = json["fields"].EmptyIfNull().Select(ParseEmbedField).ToArray();
|
||||
|
||||
// Get thumbnail
|
||||
var thumbnail = json["thumbnail"] != null ? ParseEmbedImage(json["thumbnail"]) : null;
|
||||
|
||||
// Get image
|
||||
var image = json["image"] != null ? ParseEmbedImage(json["image"]) : null;
|
||||
|
||||
// Get footer
|
||||
var footer = json["footer"] != null ? ParseEmbedFooter(json["footer"]) : null;
|
||||
|
||||
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
|
||||
}
|
||||
|
||||
private Reaction ParseReaction(JToken json)
|
||||
{
|
||||
var count = json["count"].Value<int>();
|
||||
var emojiId = json["emoji"]["id"]?.Value<string>();
|
||||
var emojiName = json["emoji"]["name"].Value<string>();
|
||||
|
||||
return new Reaction(count, emojiId, emojiName);
|
||||
}
|
||||
|
||||
private Message ParseMessage(JToken json)
|
||||
{
|
||||
// Get basic data
|
||||
var id = json["id"].Value<string>();
|
||||
var channelId = json["channel_id"].Value<string>();
|
||||
var timestamp = json["timestamp"].Value<DateTime>();
|
||||
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
|
||||
var content = json["content"].Value<string>();
|
||||
var type = (MessageType) json["type"].Value<int>();
|
||||
|
||||
// Workarounds for non-default types
|
||||
if (type == MessageType.RecipientAdd)
|
||||
content = "Added a recipient.";
|
||||
else if (type == MessageType.RecipientRemove)
|
||||
content = "Removed a recipient.";
|
||||
else if (type == MessageType.Call)
|
||||
content = "Started a call.";
|
||||
else if (type == MessageType.ChannelNameChange)
|
||||
content = "Changed the channel name.";
|
||||
else if (type == MessageType.ChannelIconChange)
|
||||
content = "Changed the channel icon.";
|
||||
else if (type == MessageType.ChannelPinnedMessage)
|
||||
content = "Pinned a message.";
|
||||
else if (type == MessageType.GuildMemberJoin)
|
||||
content = "Joined the server.";
|
||||
|
||||
// Get author
|
||||
var author = ParseUser(json["author"]);
|
||||
|
||||
// Get attachments
|
||||
var attachments = json["attachments"].EmptyIfNull().Select(ParseAttachment).ToArray();
|
||||
|
||||
// Get embeds
|
||||
var embeds = json["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray();
|
||||
|
||||
// Get reactions
|
||||
var reactions = json["reactions"].EmptyIfNull().Select(ParseReaction).ToArray();
|
||||
|
||||
// Get mentioned users
|
||||
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray();
|
||||
|
||||
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
|
||||
reactions, mentionedUsers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using DiscordChatExporter.Core.Internal;
|
||||
using Polly;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class DataService : IDataService, IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient = new HttpClient();
|
||||
|
||||
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
|
||||
params string[] parameters)
|
||||
{
|
||||
// Create request policy
|
||||
var policy = Policy
|
||||
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
|
||||
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
|
||||
|
||||
// Send request
|
||||
return await policy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Create request
|
||||
const string apiRoot = "https://discordapp.com/api/v6";
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
|
||||
{
|
||||
// Set authorization header
|
||||
request.Headers.Authorization = token.Type == AuthTokenType.Bot
|
||||
? new AuthenticationHeaderValue("Bot", token.Value)
|
||||
: new AuthenticationHeaderValue(token.Value);
|
||||
|
||||
// Add parameters
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
var key = parameter.SubstringUntil("=");
|
||||
var value = parameter.SubstringAfter("=");
|
||||
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
||||
}
|
||||
|
||||
// Get response
|
||||
using (var response = await _httpClient.SendAsync(request))
|
||||
{
|
||||
// Check status code
|
||||
// We throw our own exception here because default one doesn't have status code
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
|
||||
|
||||
// Get content
|
||||
var raw = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Parse
|
||||
return JToken.Parse(raw);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "guilds", guildId);
|
||||
var guild = ParseGuild(response);
|
||||
|
||||
return guild;
|
||||
}
|
||||
|
||||
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "channels", channelId);
|
||||
var channel = ParseChannel(response);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
|
||||
var guilds = response.Select(ParseGuild).ToArray();
|
||||
|
||||
return guilds;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "users", "@me/channels");
|
||||
var channels = response.Select(ParseChannel).ToArray();
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
|
||||
var channels = response.Select(ParseChannel).ToArray();
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
|
||||
{
|
||||
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
|
||||
var roles = response.Select(ParseRole).ToArray();
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||
{
|
||||
var result = new List<Message>();
|
||||
|
||||
// Report indeterminate progress
|
||||
progress?.Report(-1);
|
||||
|
||||
// Get the snowflakes for the selected range
|
||||
var firstId = from != null ? from.Value.ToSnowflake() : "0";
|
||||
var lastId = to != null ? to.Value.ToSnowflake() : DateTime.MaxValue.ToSnowflake();
|
||||
|
||||
// Get the last message
|
||||
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||
"limit=1", $"before={lastId}");
|
||||
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
|
||||
|
||||
// If the last message doesn't exist or it's outside of range - return
|
||||
if (lastMessage == null || lastMessage.Timestamp < from)
|
||||
{
|
||||
progress?.Report(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get other messages
|
||||
var offsetId = firstId;
|
||||
while (true)
|
||||
{
|
||||
// Get message batch
|
||||
response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||
"limit=100", $"after={offsetId}");
|
||||
|
||||
// Parse
|
||||
var messages = response
|
||||
.Select(ParseMessage)
|
||||
.Reverse() // reverse because messages appear newest first
|
||||
.ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
if (!messages.Any())
|
||||
break;
|
||||
|
||||
// Trim messages to range (until last message)
|
||||
var messagesInRange = messages
|
||||
.TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp)
|
||||
.ToArray();
|
||||
|
||||
// Add to result
|
||||
result.AddRange(messagesInRange);
|
||||
|
||||
// Break if messages were trimmed (which means the last message was encountered)
|
||||
if (messagesInRange.Length != messages.Length)
|
||||
break;
|
||||
|
||||
// Report progress (based on the time range of parsed messages compared to total)
|
||||
progress?.Report((result.Last().Timestamp - result.First().Timestamp).TotalSeconds /
|
||||
(lastMessage.Timestamp - result.First().Timestamp).TotalSeconds);
|
||||
|
||||
// Move offset
|
||||
offsetId = result.Last().Id;
|
||||
}
|
||||
|
||||
// Add last message
|
||||
result.Add(lastMessage);
|
||||
|
||||
// Report progress
|
||||
progress?.Report(1);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||
IEnumerable<Message> messages)
|
||||
{
|
||||
// Get channels and roles
|
||||
var channels = guildId != Guild.DirectMessages.Id
|
||||
? await GetGuildChannelsAsync(token, guildId)
|
||||
: Array.Empty<Channel>();
|
||||
var roles = guildId != Guild.DirectMessages.Id
|
||||
? await GetGuildRolesAsync(token, guildId)
|
||||
: Array.Empty<Role>();
|
||||
|
||||
// Get users
|
||||
var userMap = new Dictionary<string, User>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
// Author
|
||||
userMap[message.Author.Id] = message.Author;
|
||||
|
||||
// Mentioned users
|
||||
foreach (var mentionedUser in message.MentionedUsers)
|
||||
userMap[mentionedUser.Id] = mentionedUser;
|
||||
}
|
||||
|
||||
var users = userMap.Values.ToArray();
|
||||
|
||||
return new Mentionables(users, channels, roles);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using System.Reflection;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Scriban;
|
||||
using Scriban.Parsing;
|
||||
using Scriban.Runtime;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class ExportService
|
||||
{
|
||||
private class TemplateLoader : ITemplateLoader
|
||||
{
|
||||
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Resources.ExportTemplates";
|
||||
|
||||
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
|
||||
{
|
||||
return $"{ResourceRootNamespace}.{templateName}";
|
||||
}
|
||||
|
||||
public string GetPath(ExportFormat format)
|
||||
{
|
||||
return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}";
|
||||
}
|
||||
|
||||
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
|
||||
{
|
||||
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
|
||||
}
|
||||
|
||||
public string Load(ExportFormat format)
|
||||
{
|
||||
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Core.Internal;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Scriban.Runtime;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class ExportService
|
||||
{
|
||||
private class TemplateModel
|
||||
{
|
||||
private readonly ExportFormat _format;
|
||||
private readonly ChatLog _log;
|
||||
private readonly string _dateFormat;
|
||||
|
||||
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat)
|
||||
{
|
||||
_format = format;
|
||||
_log = log;
|
||||
_dateFormat = dateFormat;
|
||||
}
|
||||
|
||||
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
|
||||
|
||||
private string Format(IFormattable obj, string format) =>
|
||||
obj.ToString(format, CultureInfo.InvariantCulture);
|
||||
|
||||
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
|
||||
|
||||
private string FormatFileSize(long fileSize)
|
||||
{
|
||||
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
|
||||
double size = fileSize;
|
||||
var unit = 0;
|
||||
|
||||
while (size >= 1024)
|
||||
{
|
||||
size /= 1024;
|
||||
++unit;
|
||||
}
|
||||
|
||||
return $"{size:0.#} {units[unit]}";
|
||||
}
|
||||
|
||||
private string FormatColor(Color color)
|
||||
{
|
||||
return $"{color.R},{color.G},{color.B},{color.A}";
|
||||
}
|
||||
|
||||
private string FormatContentPlainText(string content)
|
||||
{
|
||||
// New lines
|
||||
content = content.Replace("\n", Environment.NewLine);
|
||||
|
||||
// User mentions (<@id> and <@!id>)
|
||||
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedUserId in mentionedUserIds)
|
||||
{
|
||||
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
||||
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
|
||||
}
|
||||
|
||||
// Channel mentions (<#id>)
|
||||
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedChannelId in mentionedChannelIds)
|
||||
{
|
||||
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
||||
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
|
||||
}
|
||||
|
||||
// Role mentions (<@&id>)
|
||||
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedRoleId in mentionedRoleIds)
|
||||
{
|
||||
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
||||
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
|
||||
}
|
||||
|
||||
// Custom emojis (<:name:id>)
|
||||
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private string FormatContentHtml(string content, bool allowLinks = false)
|
||||
{
|
||||
// HTML-encode content
|
||||
content = HtmlEncode(content);
|
||||
|
||||
// Encode multiline codeblocks (```text```)
|
||||
content = Regex.Replace(content,
|
||||
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
|
||||
m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM");
|
||||
|
||||
// Encode inline codeblocks (`text`)
|
||||
content = Regex.Replace(content,
|
||||
@"`([^`]+)`",
|
||||
m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI");
|
||||
|
||||
// Encode links
|
||||
if (allowLinks)
|
||||
{
|
||||
content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)",
|
||||
m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL");
|
||||
}
|
||||
|
||||
// Encode URLs
|
||||
content = Regex.Replace(content,
|
||||
@"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))",
|
||||
m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU");
|
||||
|
||||
// Process bold (**text**)
|
||||
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
|
||||
|
||||
// Process underline (__text__)
|
||||
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
|
||||
|
||||
// Process italic (*text* or _text_)
|
||||
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
|
||||
|
||||
// Process strike through (~~text~~)
|
||||
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
|
||||
|
||||
// Decode and process multiline codeblocks
|
||||
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
|
||||
m => $"<div class=\"pre pre--multiline\">{m.Groups[1].Value.Base64Decode()}</div>");
|
||||
|
||||
// Decode and process inline codeblocks
|
||||
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
|
||||
m => $"<span class=\"pre pre--inline\">{m.Groups[1].Value.Base64Decode()}</span>");
|
||||
|
||||
// Decode and process links
|
||||
if (allowLinks)
|
||||
{
|
||||
content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
|
||||
m => $"<a href=\"{m.Groups[2].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
|
||||
}
|
||||
|
||||
// Decode and process URLs
|
||||
content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
|
||||
m => $"<a href=\"{m.Groups[1].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
|
||||
|
||||
// Process new lines
|
||||
content = content.Replace("\n", "<br />");
|
||||
|
||||
// Meta mentions (@everyone)
|
||||
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
|
||||
|
||||
// Meta mentions (@here)
|
||||
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
|
||||
|
||||
// User mentions (<@id> and <@!id>)
|
||||
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedUserId in mentionedUserIds)
|
||||
{
|
||||
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
||||
content = Regex.Replace(content, $"<@!?{mentionedUserId}>",
|
||||
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
|
||||
$"@{HtmlEncode(mentionedUser.Name)}" +
|
||||
"</span>");
|
||||
}
|
||||
|
||||
// Channel mentions (<#id>)
|
||||
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedChannelId in mentionedChannelIds)
|
||||
{
|
||||
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
||||
content = content.Replace($"<#{mentionedChannelId}>",
|
||||
"<span class=\"mention\">" +
|
||||
$"#{HtmlEncode(mentionedChannel.Name)}" +
|
||||
"</span>");
|
||||
}
|
||||
|
||||
// Role mentions (<@&id>)
|
||||
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedRoleId in mentionedRoleIds)
|
||||
{
|
||||
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
||||
content = content.Replace($"<@&{mentionedRoleId}>",
|
||||
"<span class=\"mention\">" +
|
||||
$"@{HtmlEncode(mentionedRole.Name)}" +
|
||||
"</span>");
|
||||
}
|
||||
|
||||
// Custom emojis (<:name:id>)
|
||||
var isJumboable = Regex.Replace(content, "<(:.*?:)(\\d*)>", "").IsBlank();
|
||||
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
|
||||
content = Regex.Replace(content, "<(:.*?:)(\\d*)>",
|
||||
$"<img class=\"{emojiClass}\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private string FormatContentCsv(string content)
|
||||
{
|
||||
// New lines
|
||||
content = content.Replace("\n", ", ");
|
||||
|
||||
// Escape quotes
|
||||
content = content.Replace("\"", "\"\"");
|
||||
|
||||
// Escape commas and semicolons
|
||||
if (content.Contains(",") || content.Contains(";"))
|
||||
content = $"\"{content}\"";
|
||||
|
||||
// User mentions (<@id> and <@!id>)
|
||||
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedUserId in mentionedUserIds)
|
||||
{
|
||||
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
||||
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
|
||||
}
|
||||
|
||||
// Channel mentions (<#id>)
|
||||
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedChannelId in mentionedChannelIds)
|
||||
{
|
||||
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
||||
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
|
||||
}
|
||||
|
||||
// Role mentions (<@&id>)
|
||||
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
||||
.Cast<Match>()
|
||||
.Select(m => m.Groups[1].Value)
|
||||
.ExceptBlank()
|
||||
.ToArray();
|
||||
|
||||
foreach (var mentionedRoleId in mentionedRoleIds)
|
||||
{
|
||||
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
||||
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
|
||||
}
|
||||
|
||||
// Custom emojis (<:name:id>)
|
||||
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private string FormatContent(string content, bool allowLinks = false)
|
||||
{
|
||||
if (_format == ExportFormat.PlainText)
|
||||
return FormatContentPlainText(content);
|
||||
|
||||
if (_format == ExportFormat.HtmlDark)
|
||||
return FormatContentHtml(content, allowLinks);
|
||||
|
||||
if (_format == ExportFormat.HtmlLight)
|
||||
return FormatContentHtml(content, allowLinks);
|
||||
|
||||
if (_format == ExportFormat.Csv)
|
||||
return FormatContentCsv(content);
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(_format));
|
||||
}
|
||||
|
||||
public ScriptObject GetScriptObject()
|
||||
{
|
||||
// Create instance
|
||||
var scriptObject = new ScriptObject();
|
||||
|
||||
// Import chat log
|
||||
scriptObject.SetValue("Model", _log, true);
|
||||
|
||||
// Import functions
|
||||
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
|
||||
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
|
||||
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
|
||||
scriptObject.Import(nameof(FormatColor), new Func<Color, string>(FormatColor));
|
||||
scriptObject.Import(nameof(FormatContent), new Func<string, bool, string>(FormatContent));
|
||||
|
||||
return scriptObject;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using System.IO;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Scriban;
|
||||
using Scriban.Runtime;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public partial class ExportService : IExportService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public ExportService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public void Export(ExportFormat format, string filePath, ChatLog log)
|
||||
{
|
||||
// Create template loader
|
||||
var loader = new TemplateLoader();
|
||||
|
||||
// Get template
|
||||
var templateCode = loader.Load(format);
|
||||
var template = Template.Parse(templateCode);
|
||||
|
||||
// Create template context
|
||||
var context = new TemplateContext
|
||||
{
|
||||
TemplateLoader = loader,
|
||||
MemberRenamer = m => m.Name,
|
||||
MemberFilter = m => true,
|
||||
LoopLimit = int.MaxValue,
|
||||
StrictVariables = true
|
||||
};
|
||||
|
||||
// Create template model
|
||||
var templateModel = new TemplateModel(format, log, _settingsService.DateFormat);
|
||||
context.PushGlobal(templateModel.GetScriptObject());
|
||||
|
||||
// Create directory
|
||||
var dirPath = Path.GetDirectoryName(filePath);
|
||||
if (dirPath.IsNotBlank())
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
// Render output
|
||||
using (var output = File.CreateText(filePath))
|
||||
{
|
||||
// Configure output
|
||||
context.PushOutput(new TextWriterOutput(output));
|
||||
|
||||
// Render template
|
||||
template.Render(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IDataService
|
||||
{
|
||||
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
|
||||
|
||||
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
|
||||
|
||||
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
|
||||
|
||||
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
|
||||
|
||||
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
|
||||
|
||||
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
|
||||
|
||||
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
||||
|
||||
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
||||
IEnumerable<Message> messages);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IExportService
|
||||
{
|
||||
void Export(ExportFormat format, string filePath, ChatLog log);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IMessageGroupService
|
||||
{
|
||||
IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface ISettingsService
|
||||
{
|
||||
bool IsAutoUpdateEnabled { get; set; }
|
||||
|
||||
string DateFormat { get; set; }
|
||||
int MessageGroupLimit { get; set; }
|
||||
|
||||
AuthToken LastToken { get; set; }
|
||||
ExportFormat LastExportFormat { get; set; }
|
||||
|
||||
void Load();
|
||||
void Save();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public interface IUpdateService
|
||||
{
|
||||
bool NeedRestart { get; set; }
|
||||
|
||||
Task<Version> CheckPrepareUpdateAsync();
|
||||
|
||||
void FinalizeUpdate();
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class MessageGroupService : IMessageGroupService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public MessageGroupService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
public IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
||||
{
|
||||
var result = new List<MessageGroup>();
|
||||
|
||||
// Group adjacent messages by timestamp and author
|
||||
var groupBuffer = new List<Message>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
var groupFirst = groupBuffer.FirstOrDefault();
|
||||
|
||||
// Group break condition
|
||||
var breakCondition =
|
||||
groupFirst != null &&
|
||||
(
|
||||
message.Author.Id != groupFirst.Author.Id ||
|
||||
(message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
|
||||
message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
|
||||
groupBuffer.Count >= _settingsService.MessageGroupLimit
|
||||
);
|
||||
|
||||
// If condition is true - flush buffer
|
||||
if (breakCondition)
|
||||
{
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
|
||||
result.Add(group);
|
||||
groupBuffer.Clear();
|
||||
}
|
||||
|
||||
// Add message to buffer
|
||||
groupBuffer.Add(message);
|
||||
}
|
||||
|
||||
// Add what's remaining in buffer
|
||||
if (groupBuffer.Any())
|
||||
{
|
||||
var groupFirst = groupBuffer.First();
|
||||
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, groupBuffer.ToArray());
|
||||
result.Add(group);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Tyrrrz.Settings;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class SettingsService : SettingsManager, ISettingsService
|
||||
{
|
||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||
|
||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||
public int MessageGroupLimit { get; set; } = 20;
|
||||
|
||||
public AuthToken LastToken { get; set; }
|
||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
Configuration.StorageSpace = StorageSpace.Instance;
|
||||
Configuration.SubDirectoryPath = "";
|
||||
Configuration.FileName = "Settings.dat";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Onova;
|
||||
using Onova.Services;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class UpdateService : IUpdateService
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IUpdateManager _manager;
|
||||
|
||||
private Version _updateVersion;
|
||||
private bool _updateFinalized;
|
||||
|
||||
public bool NeedRestart { get; set; }
|
||||
|
||||
public UpdateService(ISettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
|
||||
_manager = new UpdateManager(
|
||||
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||
new ZipPackageExtractor());
|
||||
}
|
||||
|
||||
public async Task<Version> CheckPrepareUpdateAsync()
|
||||
{
|
||||
// If auto-update is disabled - don't check for updates
|
||||
if (!_settingsService.IsAutoUpdateEnabled)
|
||||
return null;
|
||||
|
||||
// Cleanup leftover files
|
||||
_manager.Cleanup();
|
||||
|
||||
// Check for updates
|
||||
var check = await _manager.CheckForUpdatesAsync();
|
||||
if (!check.CanUpdate)
|
||||
return null;
|
||||
|
||||
// Prepare the update
|
||||
if (!_manager.IsUpdatePrepared(check.LastVersion))
|
||||
await _manager.PrepareUpdateAsync(check.LastVersion);
|
||||
|
||||
return _updateVersion = check.LastVersion;
|
||||
}
|
||||
|
||||
public void FinalizeUpdate()
|
||||
{
|
||||
// Check if an update is pending
|
||||
if (_updateVersion == null)
|
||||
return;
|
||||
|
||||
// Check if the update has already been finalized
|
||||
if (_updateFinalized)
|
||||
return;
|
||||
|
||||
// Launch the updater
|
||||
_manager.LaunchUpdater(_updateVersion, NeedRestart);
|
||||
_updateFinalized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
public enum AuthTokenType { User, Bot }
|
||||
|
||||
public class AuthToken
|
||||
{
|
||||
public AuthTokenType Type { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public AuthToken(AuthTokenType type, string value)
|
||||
{
|
||||
Type = type;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public AuthenticationHeaderValue GetAuthorizationHeader() => Type switch
|
||||
{
|
||||
AuthTokenType.Bot => new AuthenticationHeaderValue("Bot", Value),
|
||||
_ => new AuthenticationHeaderValue(Value)
|
||||
};
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Polly;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
public class DiscordClient
|
||||
{
|
||||
private readonly AuthToken _token;
|
||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
|
||||
|
||||
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
|
||||
|
||||
public DiscordClient(AuthToken token)
|
||||
{
|
||||
_token = token;
|
||||
|
||||
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
|
||||
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
|
||||
// finally wait the specified amount of time, based on how many requests have failed in a row.
|
||||
_httpRequestPolicy = Policy
|
||||
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(6,
|
||||
(i, result, ctx) =>
|
||||
{
|
||||
if (i <= 3)
|
||||
return TimeSpan.FromSeconds(2 * i);
|
||||
|
||||
if (i <= 5)
|
||||
return TimeSpan.FromSeconds(5 * i);
|
||||
|
||||
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
|
||||
},
|
||||
(response, timespan, retryCount, context) => Task.CompletedTask
|
||||
);
|
||||
}
|
||||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
||||
|
||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
});
|
||||
|
||||
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
|
||||
{
|
||||
using var response = await GetResponseAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(),
|
||||
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(),
|
||||
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(),
|
||||
_ => DiscordChatExporterException.FailedHttpRequest(response)
|
||||
};
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsJsonAsync();
|
||||
}
|
||||
|
||||
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(string url)
|
||||
{
|
||||
using var response = await GetResponseAsync(url);
|
||||
|
||||
return response.IsSuccessStatusCode
|
||||
? await response.Content.ReadAsJsonAsync()
|
||||
: (JsonElement?) null;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Guild> GetUserGuildsAsync()
|
||||
{
|
||||
yield return Guild.DirectMessages;
|
||||
|
||||
var afterId = "";
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath("users/@me/guilds")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("after", afterId)
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url);
|
||||
|
||||
var isEmpty = true;
|
||||
foreach (var guild in response.EnumerateArray().Select(Guild.Parse))
|
||||
{
|
||||
yield return guild;
|
||||
|
||||
afterId = guild.Id;
|
||||
isEmpty = false;
|
||||
}
|
||||
|
||||
if (isEmpty)
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Guild> GetGuildAsync(string guildId)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
return Guild.DirectMessages;
|
||||
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}");
|
||||
return Guild.Parse(response);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(string guildId)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
{
|
||||
var response = await GetJsonResponseAsync("users/@me/channels");
|
||||
foreach (var channelJson in response.EnumerateArray())
|
||||
yield return Channel.Parse(channelJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels");
|
||||
|
||||
var categories = response
|
||||
.EnumerateArray()
|
||||
.ToDictionary(
|
||||
j => j.GetProperty("id").GetString(),
|
||||
j => j.GetProperty("name").GetString()
|
||||
);
|
||||
|
||||
foreach (var channelJson in response.EnumerateArray())
|
||||
{
|
||||
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString();
|
||||
var category = !string.IsNullOrWhiteSpace(parentId)
|
||||
? categories.GetValueOrDefault(parentId)
|
||||
: null;
|
||||
|
||||
var channel = Channel.Parse(channelJson, category);
|
||||
|
||||
// Skip non-text channels
|
||||
if (!channel.IsTextChannel)
|
||||
continue;
|
||||
|
||||
yield return channel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Role> GetGuildRolesAsync(string guildId)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
yield break;
|
||||
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles");
|
||||
|
||||
foreach (var roleJson in response.EnumerateArray())
|
||||
yield return Role.Parse(roleJson);
|
||||
}
|
||||
|
||||
public async ValueTask<Member?> TryGetGuildMemberAsync(string guildId, User user)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
return Member.CreateForUser(user);
|
||||
|
||||
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{user.Id}");
|
||||
return response?.Pipe(Member.Parse);
|
||||
}
|
||||
|
||||
private async ValueTask<string> GetChannelCategoryAsync(string channelParentId)
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"channels/{channelParentId}");
|
||||
return response.GetProperty("name").GetString();
|
||||
}
|
||||
|
||||
public async ValueTask<Channel> GetChannelAsync(string channelId)
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"channels/{channelId}");
|
||||
|
||||
var parentId = response.GetPropertyOrNull("parent_id")?.GetString();
|
||||
var category = !string.IsNullOrWhiteSpace(parentId)
|
||||
? await GetChannelCategoryAsync(parentId)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response, category);
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(string channelId, DateTimeOffset? before = null)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "1")
|
||||
.SetQueryParameter("before", before?.ToSnowflake())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url);
|
||||
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
||||
string channelId,
|
||||
DateTimeOffset? after = null,
|
||||
DateTimeOffset? before = null,
|
||||
IProgress<double>? progress = null)
|
||||
{
|
||||
// Get the last message in the specified range.
|
||||
// This snapshots the boundaries, which means that messages posted after the exported started
|
||||
// will not appear in the output.
|
||||
// Additionally, it provides the date of the last message, which is used to calculate progress.
|
||||
var lastMessage = await TryGetLastMessageAsync(channelId, before);
|
||||
if (lastMessage == null || lastMessage.Timestamp < after)
|
||||
yield break;
|
||||
|
||||
// Keep track of first message in range in order to calculate progress
|
||||
var firstMessage = default(Message);
|
||||
var afterId = after?.ToSnowflake() ?? "0";
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("after", afterId)
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url);
|
||||
|
||||
var messages = response
|
||||
.EnumerateArray()
|
||||
.Select(Message.Parse)
|
||||
.Reverse() // reverse because messages appear newest first
|
||||
.ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
if (!messages.Any())
|
||||
yield break;
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
firstMessage ??= message;
|
||||
|
||||
// Ensure messages are in range (take into account that last message could have been deleted)
|
||||
if (message.Timestamp > lastMessage.Timestamp)
|
||||
yield break;
|
||||
|
||||
// Report progress based on the duration of parsed messages divided by total
|
||||
progress?.Report(
|
||||
(message.Timestamp - firstMessage.Timestamp) /
|
||||
(lastMessage.Timestamp - firstMessage.Timestamp)
|
||||
);
|
||||
|
||||
yield return message;
|
||||
afterId = message.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#attachment-object
|
||||
public partial class Attachment : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public int? Width { get; }
|
||||
|
||||
public int? Height { get; }
|
||||
|
||||
public bool IsImage =>
|
||||
ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsSpoiler =>
|
||||
IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
|
||||
public FileSize FileSize { get; }
|
||||
|
||||
public Attachment(string id, string url, string fileName, int? width, int? height, FileSize fileSize)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
FileName = fileName;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FileSize = fileSize;
|
||||
}
|
||||
|
||||
public override string ToString() => FileName;
|
||||
}
|
||||
|
||||
public partial class Attachment
|
||||
{
|
||||
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
|
||||
|
||||
public static Attachment Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var url = json.GetProperty("url").GetString();
|
||||
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
||||
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
||||
var fileName = json.GetProperty("filename").GetString();
|
||||
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
||||
|
||||
return new Attachment(id, url, fileName, width, height, fileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
// Order of enum fields needs to match the order in the docs.
|
||||
public enum ChannelType
|
||||
{
|
||||
GuildTextChat,
|
||||
DirectTextChat,
|
||||
GuildVoiceChat,
|
||||
DirectGroupTextChat,
|
||||
GuildCategory,
|
||||
GuildNews,
|
||||
GuildStore
|
||||
}
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object
|
||||
public partial class Channel : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public ChannelType Type { get; }
|
||||
|
||||
public bool IsTextChannel =>
|
||||
Type == ChannelType.GuildTextChat ||
|
||||
Type == ChannelType.DirectTextChat ||
|
||||
Type == ChannelType.DirectGroupTextChat ||
|
||||
Type == ChannelType.GuildNews ||
|
||||
Type == ChannelType.GuildStore;
|
||||
|
||||
public string GuildId { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string? Topic { get; }
|
||||
|
||||
public Channel(string id, ChannelType type, string guildId, string category, string name, string? topic)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
GuildId = guildId;
|
||||
Category = category;
|
||||
Name = name;
|
||||
Topic = topic;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
private static string GetDefaultCategory(ChannelType channelType) => channelType switch
|
||||
{
|
||||
ChannelType.GuildTextChat => "Text",
|
||||
ChannelType.DirectTextChat => "Private",
|
||||
ChannelType.DirectGroupTextChat => "Group",
|
||||
ChannelType.GuildNews => "News",
|
||||
ChannelType.GuildStore => "Store",
|
||||
_ => "Default"
|
||||
};
|
||||
|
||||
public static Channel Parse(JsonElement json, string? category = null)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetString();
|
||||
var topic = json.GetPropertyOrNull("topic")?.GetString();
|
||||
|
||||
var type = (ChannelType) json.GetProperty("type").GetInt32();
|
||||
|
||||
var name =
|
||||
json.GetPropertyOrNull("name")?.GetString() ??
|
||||
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
|
||||
id;
|
||||
|
||||
return new Channel(
|
||||
id,
|
||||
type,
|
||||
guildId ?? Guild.DirectMessages.Id,
|
||||
category ?? GetDefaultCategory(type),
|
||||
name,
|
||||
topic
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
{
|
||||
// Loosely based on https://github.com/omar/ByteSize (MIT license)
|
||||
public readonly partial struct FileSize
|
||||
{
|
||||
public long TotalBytes { get; }
|
||||
|
||||
public double TotalKiloBytes => TotalBytes / 1024.0;
|
||||
public double TotalMegaBytes => TotalKiloBytes / 1024.0;
|
||||
public double TotalGigaBytes => TotalMegaBytes / 1024.0;
|
||||
public double TotalTeraBytes => TotalGigaBytes / 1024.0;
|
||||
public double TotalPetaBytes => TotalTeraBytes / 1024.0;
|
||||
|
||||
public FileSize(long bytes) => TotalBytes = bytes;
|
||||
|
||||
private double GetLargestWholeNumberValue()
|
||||
{
|
||||
if (Math.Abs(TotalPetaBytes) >= 1)
|
||||
return TotalPetaBytes;
|
||||
|
||||
if (Math.Abs(TotalTeraBytes) >= 1)
|
||||
return TotalTeraBytes;
|
||||
|
||||
if (Math.Abs(TotalGigaBytes) >= 1)
|
||||
return TotalGigaBytes;
|
||||
|
||||
if (Math.Abs(TotalMegaBytes) >= 1)
|
||||
return TotalMegaBytes;
|
||||
|
||||
if (Math.Abs(TotalKiloBytes) >= 1)
|
||||
return TotalKiloBytes;
|
||||
|
||||
return TotalBytes;
|
||||
}
|
||||
|
||||
private string GetLargestWholeNumberSymbol()
|
||||
{
|
||||
if (Math.Abs(TotalPetaBytes) >= 1)
|
||||
return "PB";
|
||||
|
||||
if (Math.Abs(TotalTeraBytes) >= 1)
|
||||
return "TB";
|
||||
|
||||
if (Math.Abs(TotalGigaBytes) >= 1)
|
||||
return "GB";
|
||||
|
||||
if (Math.Abs(TotalMegaBytes) >= 1)
|
||||
return "MB";
|
||||
|
||||
if (Math.Abs(TotalKiloBytes) >= 1)
|
||||
return "KB";
|
||||
|
||||
return "B";
|
||||
}
|
||||
|
||||
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}";
|
||||
}
|
||||
|
||||
public partial struct FileSize
|
||||
{
|
||||
public static FileSize FromBytes(long bytes) => new FileSize(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
{
|
||||
public interface IHasId
|
||||
{
|
||||
string Id { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
{
|
||||
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
|
||||
{
|
||||
public bool Equals(IHasId? x, IHasId? y) => StringComparer.Ordinal.Equals(x?.Id, y?.Id);
|
||||
|
||||
public int GetHashCode(IHasId obj) => StringComparer.Ordinal.GetHashCode(obj.Id);
|
||||
}
|
||||
|
||||
public partial class IdBasedEqualityComparer
|
||||
{
|
||||
public static IdBasedEqualityComparer Instance { get; } = new IdBasedEqualityComparer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object
|
||||
public partial class Embed
|
||||
{
|
||||
public string? Title { get; }
|
||||
|
||||
public string? Url { get; }
|
||||
|
||||
public DateTimeOffset? Timestamp { get; }
|
||||
|
||||
public Color? Color { get; }
|
||||
|
||||
public EmbedAuthor? Author { get; }
|
||||
|
||||
public string? Description { get; }
|
||||
|
||||
public IReadOnlyList<EmbedField> Fields { get; }
|
||||
|
||||
public EmbedImage? Thumbnail { get; }
|
||||
|
||||
public EmbedImage? Image { get; }
|
||||
|
||||
public EmbedFooter? Footer { get; }
|
||||
|
||||
public Embed(
|
||||
string? title,
|
||||
string? url,
|
||||
DateTimeOffset? timestamp,
|
||||
Color? color,
|
||||
EmbedAuthor? author,
|
||||
string? description,
|
||||
IReadOnlyList<EmbedField> fields,
|
||||
EmbedImage? thumbnail,
|
||||
EmbedImage? image,
|
||||
EmbedFooter? footer)
|
||||
{
|
||||
Title = title;
|
||||
Url = url;
|
||||
Timestamp = timestamp;
|
||||
Color = color;
|
||||
Author = author;
|
||||
Description = description;
|
||||
Fields = fields;
|
||||
Thumbnail = thumbnail;
|
||||
Image = image;
|
||||
Footer = footer;
|
||||
}
|
||||
|
||||
public override string ToString() => Title ?? "<untitled embed>";
|
||||
}
|
||||
|
||||
public partial class Embed
|
||||
{
|
||||
public static Embed Parse(JsonElement json)
|
||||
{
|
||||
var title = json.GetPropertyOrNull("title")?.GetString();
|
||||
var description = json.GetPropertyOrNull("description")?.GetString();
|
||||
var url = json.GetPropertyOrNull("url")?.GetString();
|
||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
||||
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
||||
|
||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||
var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse);
|
||||
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
||||
|
||||
var fields =
|
||||
json.GetPropertyOrNull("fields")?.EnumerateArray().Select(EmbedField.Parse).ToArray() ??
|
||||
Array.Empty<EmbedField>();
|
||||
|
||||
return new Embed(
|
||||
title,
|
||||
url,
|
||||
timestamp,
|
||||
color,
|
||||
author,
|
||||
description,
|
||||
fields,
|
||||
thumbnail,
|
||||
image,
|
||||
footer
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||
public partial class EmbedAuthor
|
||||
{
|
||||
public string? Name { get; }
|
||||
|
||||
public string? Url { get; }
|
||||
|
||||
public string? IconUrl { get; }
|
||||
|
||||
public EmbedAuthor(string? name, string? url, string? iconUrl)
|
||||
{
|
||||
Name = name;
|
||||
Url = url;
|
||||
IconUrl = iconUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Name ?? "<unnamed author>";
|
||||
}
|
||||
|
||||
public partial class EmbedAuthor
|
||||
{
|
||||
public static EmbedAuthor Parse(JsonElement json)
|
||||
{
|
||||
var name = json.GetPropertyOrNull("name")?.GetString();
|
||||
var url = json.GetPropertyOrNull("url")?.GetString();
|
||||
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
|
||||
|
||||
return new EmbedAuthor(name, url, iconUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||
public partial class EmbedField
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public bool IsInline { get; }
|
||||
|
||||
public EmbedField(string name, string value, bool isInline)
|
||||
{
|
||||
Name = name;
|
||||
Value = value;
|
||||
IsInline = isInline;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Name} | {Value}";
|
||||
}
|
||||
|
||||
public partial class EmbedField
|
||||
{
|
||||
public static EmbedField Parse(JsonElement json)
|
||||
{
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var value = json.GetProperty("value").GetString();
|
||||
var isInline = json.GetPropertyOrNull("inline")?.GetBoolean() ?? false;
|
||||
|
||||
return new EmbedField(name, value, isInline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||
public partial class EmbedFooter
|
||||
{
|
||||
public string Text { get; }
|
||||
|
||||
public string? IconUrl { get; }
|
||||
|
||||
public EmbedFooter(string text, string? iconUrl)
|
||||
{
|
||||
Text = text;
|
||||
IconUrl = iconUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Text;
|
||||
}
|
||||
|
||||
public partial class EmbedFooter
|
||||
{
|
||||
public static EmbedFooter Parse(JsonElement json)
|
||||
{
|
||||
var text = json.GetProperty("text").GetString();
|
||||
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
|
||||
|
||||
return new EmbedFooter(text, iconUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||
public partial class EmbedImage
|
||||
{
|
||||
public string? Url { get; }
|
||||
|
||||
public int? Width { get; }
|
||||
|
||||
public int? Height { get; }
|
||||
|
||||
public EmbedImage(string? url, int? width, int? height)
|
||||
{
|
||||
Url = url;
|
||||
Height = height;
|
||||
Width = width;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class EmbedImage
|
||||
{
|
||||
public static EmbedImage Parse(JsonElement json)
|
||||
{
|
||||
var url = json.GetPropertyOrNull("url")?.GetString();
|
||||
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
||||
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
||||
|
||||
return new EmbedImage(url, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
||||
public partial class Emoji
|
||||
{
|
||||
public string? Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public bool IsAnimated { get; }
|
||||
|
||||
public string ImageUrl { get; }
|
||||
|
||||
public Emoji(string? id, string name, bool isAnimated)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IsAnimated = isAnimated;
|
||||
|
||||
ImageUrl = GetImageUrl(id, name, isAnimated);
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Emoji
|
||||
{
|
||||
private static IEnumerable<Rune> GetRunes(string emoji)
|
||||
{
|
||||
var lastIndex = 0;
|
||||
while (lastIndex < emoji.Length && Rune.TryGetRuneAt(emoji, lastIndex, out var rune))
|
||||
{
|
||||
// Skip variant selector rune
|
||||
if (rune.Value != 0xfe0f)
|
||||
yield return rune;
|
||||
|
||||
lastIndex += rune.Utf16SequenceLength;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetTwemojiName(IEnumerable<Rune> runes) =>
|
||||
runes.Select(r => r.Value.ToString("x")).JoinToString("-");
|
||||
|
||||
public static string GetImageUrl(string? id, string name, bool isAnimated)
|
||||
{
|
||||
// Custom emoji
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
// Animated
|
||||
if (isAnimated)
|
||||
return $"https://cdn.discordapp.com/emojis/{id}.gif";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||
}
|
||||
|
||||
// Standard emoji
|
||||
var emojiRunes = GetRunes(name).ToArray();
|
||||
var twemojiName = GetTwemojiName(emojiRunes);
|
||||
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
|
||||
}
|
||||
|
||||
public static Emoji Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetPropertyOrNull("id")?.GetString();
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
|
||||
|
||||
return new Emoji(id, name, isAnimated);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||
public partial class Guild : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string IconUrl { get; }
|
||||
|
||||
public Guild(string id, string name, string iconUrl)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IconUrl = iconUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public static Guild DirectMessages { get; } =
|
||||
new Guild("@me", "Direct Messages", GetDefaultIconUrl());
|
||||
|
||||
private static string GetDefaultIconUrl() =>
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
|
||||
private static string GetIconUrl(string id, string iconHash) =>
|
||||
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
|
||||
|
||||
public static Guild Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var iconHash = json.GetProperty("icon").GetString();
|
||||
|
||||
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
||||
? GetIconUrl(id, iconHash)
|
||||
: GetDefaultIconUrl();
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/guild#guild-member-object
|
||||
public partial class Member : IHasId
|
||||
{
|
||||
public string Id => User.Id;
|
||||
|
||||
public User User { get; }
|
||||
|
||||
public string Nick { get; }
|
||||
|
||||
public IReadOnlyList<string> RoleIds { get; }
|
||||
|
||||
public Member(User user, string? nick, IReadOnlyList<string> roleIds)
|
||||
{
|
||||
User = user;
|
||||
Nick = nick ?? user.Name;
|
||||
RoleIds = roleIds;
|
||||
}
|
||||
|
||||
public override string ToString() => Nick;
|
||||
}
|
||||
|
||||
public partial class Member
|
||||
{
|
||||
public static Member CreateForUser(User user) =>
|
||||
new Member(user, null, Array.Empty<string>());
|
||||
|
||||
public static Member Parse(JsonElement json)
|
||||
{
|
||||
var user = json.GetProperty("user").Pipe(User.Parse);
|
||||
var nick = json.GetPropertyOrNull("nick")?.GetString();
|
||||
|
||||
var roleIds =
|
||||
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ??
|
||||
Array.Empty<string>();
|
||||
|
||||
return new Member(
|
||||
user,
|
||||
nick,
|
||||
roleIds
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
public enum MessageType
|
||||
{
|
||||
Default,
|
||||
RecipientAdd,
|
||||
RecipientRemove,
|
||||
Call,
|
||||
ChannelNameChange,
|
||||
ChannelIconChange,
|
||||
ChannelPinnedMessage,
|
||||
GuildMemberJoin
|
||||
}
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object
|
||||
public partial class Message : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public MessageType Type { get; }
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public DateTimeOffset? EditedTimestamp { get; }
|
||||
|
||||
public bool IsPinned { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
public IReadOnlyList<Attachment> Attachments { get; }
|
||||
|
||||
public IReadOnlyList<Embed> Embeds { get; }
|
||||
|
||||
public IReadOnlyList<Reaction> Reactions { get; }
|
||||
|
||||
public IReadOnlyList<User> MentionedUsers { get; }
|
||||
|
||||
public Message(
|
||||
string id,
|
||||
MessageType type,
|
||||
User author,
|
||||
DateTimeOffset timestamp,
|
||||
DateTimeOffset? editedTimestamp,
|
||||
bool isPinned,
|
||||
string content,
|
||||
IReadOnlyList<Attachment> attachments,
|
||||
IReadOnlyList<Embed> embeds,
|
||||
IReadOnlyList<Reaction> reactions,
|
||||
IReadOnlyList<User> mentionedUsers)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
EditedTimestamp = editedTimestamp;
|
||||
IsPinned = isPinned;
|
||||
Content = content;
|
||||
Attachments = attachments;
|
||||
Embeds = embeds;
|
||||
Reactions = reactions;
|
||||
MentionedUsers = mentionedUsers;
|
||||
}
|
||||
|
||||
public override string ToString() => Content;
|
||||
}
|
||||
|
||||
public partial class Message
|
||||
{
|
||||
public static Message Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var author = json.GetProperty("author").Pipe(User.Parse);
|
||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||
var type = (MessageType) json.GetProperty("type").GetInt32();
|
||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
||||
|
||||
var content = type switch
|
||||
{
|
||||
MessageType.RecipientAdd => "Added a recipient.",
|
||||
MessageType.RecipientRemove => "Removed a recipient.",
|
||||
MessageType.Call => "Started a call.",
|
||||
MessageType.ChannelNameChange => "Changed the channel name.",
|
||||
MessageType.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageType.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageType.GuildMemberJoin => "Joined the server.",
|
||||
_ => json.GetPropertyOrNull("content")?.GetString() ?? ""
|
||||
};
|
||||
|
||||
var attachments =
|
||||
json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(Attachment.Parse).ToArray() ??
|
||||
Array.Empty<Attachment>();
|
||||
|
||||
var embeds =
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(Embed.Parse).ToArray() ??
|
||||
Array.Empty<Embed>();
|
||||
|
||||
var reactions =
|
||||
json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(Reaction.Parse).ToArray() ??
|
||||
Array.Empty<Reaction>();
|
||||
|
||||
var mentionedUsers =
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(User.Parse).ToArray() ??
|
||||
Array.Empty<User>();
|
||||
|
||||
return new Message(
|
||||
id,
|
||||
type,
|
||||
author,
|
||||
timestamp,
|
||||
editedTimestamp,
|
||||
isPinned,
|
||||
content,
|
||||
attachments,
|
||||
embeds,
|
||||
reactions,
|
||||
mentionedUsers
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#reaction-object
|
||||
public partial class Reaction
|
||||
{
|
||||
public Emoji Emoji { get; }
|
||||
|
||||
public int Count { get; }
|
||||
|
||||
public Reaction(Emoji emoji, int count)
|
||||
{
|
||||
Emoji = emoji;
|
||||
Count = count;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{Emoji} ({Count})";
|
||||
}
|
||||
|
||||
public partial class Reaction
|
||||
{
|
||||
public static Reaction Parse(JsonElement json)
|
||||
{
|
||||
var count = json.GetProperty("count").GetInt32();
|
||||
var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse);
|
||||
|
||||
return new Reaction(emoji, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/topics/permissions#role-object
|
||||
public partial class Role
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public int Position { get; }
|
||||
|
||||
public Color? Color { get; }
|
||||
|
||||
public Role(string id, string name, int position, Color? color)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Position = position;
|
||||
Color = color;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Role
|
||||
{
|
||||
public static Role Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var position = json.GetProperty("position").GetInt32();
|
||||
|
||||
var color = json.GetPropertyOrNull("color")?
|
||||
.GetInt32()
|
||||
.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha()
|
||||
.NullIf(c => c.ToRgb() <= 0);
|
||||
|
||||
return new Role(id, name, position, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/user#user-object
|
||||
public partial class User : IHasId
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public bool IsBot { get; }
|
||||
|
||||
public int Discriminator { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||
|
||||
public string AvatarUrl { get; }
|
||||
|
||||
public User(string id, bool isBot, int discriminator, string name, string avatarUrl)
|
||||
{
|
||||
Id = id;
|
||||
IsBot = isBot;
|
||||
Discriminator = discriminator;
|
||||
Name = name;
|
||||
AvatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => FullName;
|
||||
}
|
||||
|
||||
public partial class User
|
||||
{
|
||||
private static string GetDefaultAvatarUrl(int discriminator) =>
|
||||
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
||||
|
||||
private static string GetAvatarUrl(string id, string avatarHash)
|
||||
{
|
||||
// Animated
|
||||
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
|
||||
}
|
||||
|
||||
public static User Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString();
|
||||
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
|
||||
var name = json.GetProperty("username").GetString();
|
||||
var avatarHash = json.GetProperty("avatar").GetString();
|
||||
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
|
||||
|
||||
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
||||
? GetAvatarUrl(id, avatarHash)
|
||||
: GetDefaultAvatarUrl(discriminator);
|
||||
|
||||
return new User(id, isBot, discriminator, name, avatarUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Import Project="../DiscordChatExporter.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MiniRazor" Version="1.1.0" />
|
||||
<PackageReference Include="Polly" Version="7.2.1" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Core.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Dark.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Light.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\PreambleTemplate.cshtml" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\PostambleTemplate.cshtml" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\MessageGroupTemplate.cshtml" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exceptions
|
||||
{
|
||||
public partial class DiscordChatExporterException : Exception
|
||||
{
|
||||
public bool IsCritical { get; }
|
||||
|
||||
public DiscordChatExporterException(string message, bool isCritical = false)
|
||||
: base(message)
|
||||
{
|
||||
IsCritical = isCritical;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class DiscordChatExporterException
|
||||
{
|
||||
internal static DiscordChatExporterException FailedHttpRequest(HttpResponseMessage response)
|
||||
{
|
||||
var message = $@"
|
||||
Failed to perform an HTTP request.
|
||||
|
||||
{response.RequestMessage}
|
||||
|
||||
{response}";
|
||||
|
||||
return new DiscordChatExporterException(message.Trim(), true);
|
||||
}
|
||||
|
||||
internal static DiscordChatExporterException Unauthorized()
|
||||
{
|
||||
const string message = "Authentication token is invalid.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
|
||||
internal static DiscordChatExporterException Forbidden()
|
||||
{
|
||||
const string message = "Access is forbidden.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
|
||||
internal static DiscordChatExporterException NotFound()
|
||||
{
|
||||
const string message = "Requested resource does not exist.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
|
||||
internal static DiscordChatExporterException ChannelIsEmpty(string channel)
|
||||
{
|
||||
var message = $"Channel '{channel}' contains no messages for the specified period.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
public class ChannelExporter
|
||||
{
|
||||
private readonly DiscordClient _discord;
|
||||
|
||||
public ChannelExporter(DiscordClient discord) => _discord = discord;
|
||||
|
||||
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
|
||||
|
||||
public async ValueTask ExportChannelAsync(ExportRequest request, IProgress<double>? progress = null)
|
||||
{
|
||||
// Build context
|
||||
var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
|
||||
var contextChannels = await _discord.GetGuildChannelsAsync(request.Guild.Id);
|
||||
var contextRoles = await _discord.GetGuildRolesAsync(request.Guild.Id);
|
||||
|
||||
var context = new ExportContext(
|
||||
request,
|
||||
contextMembers,
|
||||
contextChannels,
|
||||
contextRoles
|
||||
);
|
||||
|
||||
// Export messages
|
||||
await using var messageExporter = new MessageExporter(context);
|
||||
|
||||
var exportedAnything = false;
|
||||
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
|
||||
await foreach (var message in _discord.GetMessagesAsync(request.Channel.Id, request.After, request.Before, progress))
|
||||
{
|
||||
// Resolve members for referenced users
|
||||
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
|
||||
{
|
||||
if (!encounteredUsers.Add(referencedUser))
|
||||
continue;
|
||||
|
||||
var member =
|
||||
await _discord.TryGetGuildMemberAsync(request.Guild.Id, referencedUser) ??
|
||||
Member.CreateForUser(referencedUser);
|
||||
|
||||
contextMembers.Add(member);
|
||||
}
|
||||
|
||||
// Export message
|
||||
await messageExporter.ExportMessageAsync(message);
|
||||
exportedAnything = true;
|
||||
}
|
||||
|
||||
// Throw if no messages were exported
|
||||
if (!exportedAnything)
|
||||
throw DiscordChatExporterException.ChannelIsEmpty(request.Channel.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user