diff --git a/.gitignore b/.gitignore index 3fd86ac..2d7152a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,6 @@ parts/ sdist/ var/ wheels/ -pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg @@ -50,6 +49,7 @@ coverage.xml *.py,cover .hypothesis/ .pytest_cache/ +cover/ # Translations *.mo @@ -72,6 +72,7 @@ instance/ docs/_build/ # PyBuilder +.pybuilder/ target/ # Jupyter Notebook @@ -82,7 +83,9 @@ profile_default/ ipython_config.py # pyenv -.python-version +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -91,7 +94,22 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# pyflow +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff @@ -102,7 +120,6 @@ celerybeat.pid *.sage.py # Environments -.idea .env .venv env/ @@ -129,77 +146,21 @@ dmypy.json # Pyre type checker .pyre/ +# pytype static type analyzer +.pytype/ -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# VSCode +.vscode/ # Project specific ignores bot.py diff --git a/README.md b/README.md index e2153a1..3e67ea7 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,11 @@ from discord import Attachment class MyAttachmentHandler(AttachmentHandler): + def __init__(self, *args, **kwargs): + # Your initialization code here + # in your case we just create the cloud client + self.cloud_client = CloudClient() + async def process_asset(self, attachment: Attachment): # Your upload logic here, in our example we just upload the asset to the cloud @@ -258,14 +263,14 @@ class MyAttachmentHandler(AttachmentHandler): # now we can generate the asset url from the identifier asset_url = await self.cloud_client.get_share_url(asset_id, shared_with="everyone") - # and set the url attribute of the attachment to the generated url - attachment.url = asset_url + # and set the proxy url attribute of the attachment to the generated url + attachment.proxy_url = asset_url return attachment ``` Note -1. The `process_asset` method should return the attachment object with the url attribute set to the generated url. +1. The `process_asset` method should return the attachment object with the proxy_url attribute set to the generated url. 2. The `process_asset` method should be an async method, as it is likely that you have to do some async operations like fetching the content of the attachment or uploading it to the cloud. 3. You are free to add other methods in your class, and call them from `process_asset` if you need to do some diff --git a/chat_exporter/chat_exporter.py b/chat_exporter/chat_exporter.py index e1b2028..bfc0877 100644 --- a/chat_exporter/chat_exporter.py +++ b/chat_exporter/chat_exporter.py @@ -36,6 +36,7 @@ async def quick_export( after=None, support_dev=True, bot=bot, + attachment_handler=None ).export() ).html diff --git a/chat_exporter/construct/assets/attachment.py b/chat_exporter/construct/assets/attachment.py index 4efd610..c936630 100644 --- a/chat_exporter/construct/assets/attachment.py +++ b/chat_exporter/construct/assets/attachment.py @@ -47,7 +47,7 @@ async def audio(self): self.attachments = await fill_out(self.guild, audio_attachment, [ ("ATTACH_ICON", file_icon, PARSE_MODE_NONE), - ("ATTACH_URL", self.attachments.url, PARSE_MODE_NONE), + ("ATTACH_URL", self.attachments.proxy_url, PARSE_MODE_NONE), ("ATTACH_BYTES", str(file_size), PARSE_MODE_NONE), ("ATTACH_AUDIO", self.attachments.proxy_url, PARSE_MODE_NONE), ("ATTACH_FILE", str(self.attachments.filename), PARSE_MODE_NONE) @@ -60,7 +60,7 @@ async def file(self): self.attachments = await fill_out(self.guild, msg_attachment, [ ("ATTACH_ICON", file_icon, PARSE_MODE_NONE), - ("ATTACH_URL", self.attachments.url, PARSE_MODE_NONE), + ("ATTACH_URL", self.attachments.proxy_url, PARSE_MODE_NONE), ("ATTACH_BYTES", str(file_size), PARSE_MODE_NONE), ("ATTACH_FILE", str(self.attachments.filename), PARSE_MODE_NONE) ]) @@ -88,7 +88,7 @@ async def get_file_icon(self) -> str: "arj", "pkg", "z" ) - extension = self.attachments.url.rsplit('.', 1)[1] + extension = self.attachments.proxy_url.rsplit('.', 1)[1] if extension in acrobat_types: return DiscordUtils.file_attachment_acrobat elif extension in webcode_types: diff --git a/chat_exporter/construct/assets/component.py b/chat_exporter/construct/assets/component.py index 3d75054..50af978 100644 --- a/chat_exporter/construct/assets/component.py +++ b/chat_exporter/construct/assets/component.py @@ -44,18 +44,26 @@ async def build_component(self, c): Component.menu_div_id += 1 async def build_button(self, c): - url = c.url if c.url else "" - label = c.label if c.label else "" + if c.url: + url = str(c.url) + target = " target='_blank'" + icon = str(DiscordUtils.button_external_link) + else: + url = "javascript:;" + target = "" + icon = "" + + label = str(c.label) if c.label else "" style = self.styles[str(c.style).split(".")[1]] - icon = DiscordUtils.button_external_link if url else "" emoji = str(c.emoji) if c.emoji else "" self.buttons += await fill_out(self.guild, component_button, [ ("DISABLED", "chatlog__component-disabled" if c.disabled else "", PARSE_MODE_NONE), - ("URL", str(url), PARSE_MODE_NONE), - ("LABEL", str(label), PARSE_MODE_MARKDOWN), - ("EMOJI", str(emoji), PARSE_MODE_EMOJI), - ("ICON", str(icon), PARSE_MODE_NONE), + ("URL", url, PARSE_MODE_NONE), + ("LABEL", label, PARSE_MODE_MARKDOWN), + ("EMOJI", emoji, PARSE_MODE_EMOJI), + ("ICON", icon, PARSE_MODE_NONE), + ("TARGET", target, PARSE_MODE_NONE), ("STYLE", style, PARSE_MODE_NONE) ]) diff --git a/chat_exporter/construct/attachment_handler.py b/chat_exporter/construct/attachment_handler.py index 64e75c4..bf6de50 100644 --- a/chat_exporter/construct/attachment_handler.py +++ b/chat_exporter/construct/attachment_handler.py @@ -2,6 +2,8 @@ import io import pathlib from typing import Union +import urllib.parse + import aiohttp from chat_exporter.ext.discord_import import discord @@ -33,7 +35,7 @@ async def process_asset(self, attachment: discord.Attachment) -> discord.Attachm :param attachment: discord.Attachment :return: str """ - file_name = f"{int(datetime.datetime.utcnow().timestamp())}_{attachment.filename}".replace(' ', '%20') + file_name = urllib.parse.quote_plus(f"{datetime.datetime.utcnow().timestamp()}_{attachment.filename}") asset_path = self.base_path / file_name await attachment.save(asset_path) file_url = f"{self.url_base}/{file_name}" diff --git a/chat_exporter/construct/message.py b/chat_exporter/construct/message.py index b0733d0..7bd3565 100644 --- a/chat_exporter/construct/message.py +++ b/chat_exporter/construct/message.py @@ -58,6 +58,8 @@ class MessageConstruct: attachments: str = "" time_format: str = "" + interaction: str = "" + def __init__( self, message: discord.Message, @@ -179,15 +181,21 @@ async def build_reference(self): is_bot = _gather_user_bot(message.author) user_colour = await self._gather_user_colour(message.author) - if not message.content and not message.interaction: + if not message.content and not getattr(message, 'interaction_metadata', None) and not getattr(message, 'interaction', None): message.content = "Click to see attachment" - elif not message.content and message.interaction: + elif not message.content and ((hasattr(message, 'interaction_metadata') and message.interaction_metadata) or message.interaction): message.content = "Click to see command" icon = "" - if not message.interaction and (message.embeds or message.attachments): + def get_interaction_status(interaction_message): + if hasattr(interaction_message, 'interaction_metadata'): + return interaction_message.interaction_metadata + return interaction_message.interaction + + interaction_status = get_interaction_status(message) + if not interaction_status and (message.embeds or message.attachments): icon = DiscordUtils.reference_attachment_icon - elif message.interaction: + elif interaction_status: icon = DiscordUtils.interaction_command_icon _, message_edited_at = self.set_time(message) @@ -210,24 +218,35 @@ async def build_reference(self): ]) async def build_interaction(self): - if not self.message.interaction: - self.message.interaction = "" + if hasattr(self.message, 'interaction_metadata'): + if not self.message.interaction_metadata: + self.interaction = "" + return + command = "a slash command" + user = self.message.interaction_metadata.user + interaction_id = self.message.interaction_metadata.id + elif self.message.interaction: + command = f"/{self.message.interaction.name}" + user = self.message.interaction.user + interaction_id = self.message.interaction.id + else: + self.interaction = "" return - user: Union[discord.Member, discord.User] = self.message.interaction.user is_bot = _gather_user_bot(user) user_colour = await self._gather_user_colour(user) avatar_url = user.display_avatar if user.display_avatar else DiscordUtils.default_avatar - self.message.interaction = await fill_out(self.guild, message_interaction, [ + + self.interaction = await fill_out(self.guild, message_interaction, [ ("AVATAR_URL", str(avatar_url), PARSE_MODE_NONE), ("BOT_TAG", is_bot, PARSE_MODE_NONE), ("NAME_TAG", await discriminator(user.name, user.discriminator), PARSE_MODE_NONE), ("NAME", str(html.escape(user.display_name))), + ("COMMAND", str(command), PARSE_MODE_NONE), ("USER_COLOUR", user_colour, PARSE_MODE_NONE), ("FILLER", "used ", PARSE_MODE_NONE), - ("COMMAND", "/" + self.message.interaction.name, PARSE_MODE_NONE), ("USER_ID", str(user.id), PARSE_MODE_NONE), - ("INTERACTION_ID", str(self.message.interaction.id), PARSE_MODE_NONE), + ("INTERACTION_ID", str(interaction_id), PARSE_MODE_NONE), ]) async def build_sticker(self): @@ -279,7 +298,7 @@ async def build_message_template(self): ("COMPONENTS", self.components, PARSE_MODE_NONE), ("EMOJI", self.reactions, PARSE_MODE_NONE), ("TIMESTAMP", self.message_created_at, PARSE_MODE_NONE), - ("TIME", self.message_created_at.split()[-1], PARSE_MODE_NONE), + ("TIME", self.message_created_at.split(maxsplit=4)[4], PARSE_MODE_NONE), ]) return self.message_html @@ -287,7 +306,7 @@ async def build_message_template(self): def _generate_message_divider_check(self): return bool( self.previous_message is None or self.message.reference != "" or - self.previous_message.type is not discord.MessageType.default or self.message.interaction != "" or + self.previous_message.type is not discord.MessageType.default or self.interaction != "" or self.previous_message.author.id != self.message.author.id or self.message.webhook_id is not None or self.message.created_at > (self.previous_message.created_at + timedelta(minutes=4)) ) @@ -305,18 +324,21 @@ async def generate_message_divider(self, channel_audit=False): is_bot = _gather_user_bot(self.message.author) avatar_url = self.message.author.display_avatar if self.message.author.display_avatar else DiscordUtils.default_avatar - if self.message.reference != "" or self.message.interaction: + if self.message.reference != "" or self.interaction: followup_symbol = "
" time = self.message.created_at if not self.message.created_at.tzinfo: time = timezone("UTC").localize(time) - default_timestamp = time.astimezone(timezone(self.pytz_timezone)).strftime("%d-%m-%Y %H:%M") + if self.military_time: + default_timestamp = time.astimezone(timezone(self.pytz_timezone)).strftime("%d-%m-%Y %H:%M") + else: + default_timestamp = time.astimezone(timezone(self.pytz_timezone)).strftime("%d-%m-%Y %I:%M %p") self.message_html += await fill_out(self.guild, start_message, [ ("REFERENCE_SYMBOL", followup_symbol, PARSE_MODE_NONE), - ("REFERENCE", self.message.reference if self.message.reference else self.message.interaction, + ("REFERENCE", self.message.reference if self.message.reference else self.interaction, PARSE_MODE_NONE), ("AVATAR_URL", str(avatar_url), PARSE_MODE_NONE), ("NAME_TAG", await discriminator(self.message.author.name, self.message.author.discriminator), PARSE_MODE_NONE), @@ -432,9 +454,6 @@ def to_local_time_str(self, time): local_time = time.astimezone(timezone(self.pytz_timezone)) - if self.military_time: - return local_time.strftime(self.time_format) - return local_time.strftime(self.time_format) diff --git a/chat_exporter/construct/transcript.py b/chat_exporter/construct/transcript.py index 60ea8a8..526a449 100644 --- a/chat_exporter/construct/transcript.py +++ b/chat_exporter/construct/transcript.py @@ -76,7 +76,10 @@ async def export_transcript(self, message_html: str, meta_data: str): guild_name = html.escape(self.channel.guild.name) timezone = pytz.timezone(self.pytz_timezone) - time_now = datetime.datetime.now(timezone).strftime("%e %B %Y at %T (%Z)") + if self.military_time: + time_now = datetime.datetime.now(timezone).strftime("%e %B %Y at %H:%M:%S (%Z)") + else: + time_now = datetime.datetime.now(timezone).strftime("%e %B %Y at %I:%M:%S %p (%Z)") meta_data_html: str = "" for data in meta_data: @@ -105,7 +108,10 @@ async def export_transcript(self, message_html: str, meta_data: str): ("MESSAGE_COUNT", str(meta_data[int(data)][4])) ]) - channel_creation_time = self.channel.created_at.astimezone(timezone).strftime("%b %d, %Y (%T)") + if self.military_time: + channel_creation_time = self.channel.created_at.astimezone(timezone).strftime("%b %d, %Y (%H:%M:%S)") + else: + channel_creation_time = self.channel.created_at.astimezone(timezone).strftime("%b %d, %Y (%I:%M:%S %p)") raw_channel_topic = ( self.channel.topic if isinstance(self.channel, discord.TextChannel) and self.channel.topic else "" @@ -136,7 +142,13 @@ async def export_transcript(self, message_html: str, meta_data: str): _fancy_time = "" if self.fancy_times: + if self.military_time: + time_format = "HH:mm" + else: + time_format = "hh:mm A" + _fancy_time = await fill_out(self.channel.guild, fancy_time, [ + ("TIME_FORMAT", time_format, PARSE_MODE_NONE), ("TIMEZONE", str(self.pytz_timezone), PARSE_MODE_NONE) ]) diff --git a/chat_exporter/ext/cache.py b/chat_exporter/ext/cache.py index 2fb96ce..8889ac5 100644 --- a/chat_exporter/ext/cache.py +++ b/chat_exporter/ext/cache.py @@ -1,5 +1,5 @@ from functools import wraps -from typing import Any +from typing import Any, Dict, Tuple _internal_cache: dict = {} @@ -24,7 +24,7 @@ def clear_cache(): def cache(): def decorator(func): - def _make_key(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + def _make_key(args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> str: def _true_repr(o): if o.__class__.__repr__ is object.__repr__: # this is how MessageConstruct can retain diff --git a/chat_exporter/ext/html_generator.py b/chat_exporter/ext/html_generator.py index e28beb6..d4c4515 100644 --- a/chat_exporter/ext/html_generator.py +++ b/chat_exporter/ext/html_generator.py @@ -35,7 +35,7 @@ async def fill_out(guild, base, replacements): elif mode == PARSE_MODE_EMOJI: v = await ParseMarkdown(v).special_emoji_flow() - base = base.replace("{{" + k + "}}", v) + base = base.replace("{{" + k + "}}", v.strip()) return base diff --git a/chat_exporter/html/attachment/audio.html b/chat_exporter/html/attachment/audio.html index 1271a76..fc52a65 100644 --- a/chat_exporter/html/attachment/audio.html +++ b/chat_exporter/html/attachment/audio.html @@ -9,7 +9,7 @@ -