From c4a85f820e42665717d4f6e2373f7efac634b015 Mon Sep 17 00:00:00 2001 From: skelmis Date: Sat, 24 Feb 2024 18:37:31 +1300 Subject: [PATCH 01/11] chore: initial commit --- suggestions/bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/suggestions/bot.py b/suggestions/bot.py index 594ffb4..fad449b 100644 --- a/suggestions/bot.py +++ b/suggestions/bot.py @@ -48,7 +48,7 @@ class SuggestionsBot(commands.AutoShardedInteractionBot, BotBase): def __init__(self, *args, **kwargs): - self.version: str = "Public Release 3.21" + self.version: str = "Public Release 3.22" self.main_guild_id: int = 601219766258106399 self.legacy_beta_role_id: int = 995588041991274547 self.automated_beta_role_id: int = 998173237282361425 From cee879ebde7c7cb325e64095fa7a128f92f672f2 Mon Sep 17 00:00:00 2001 From: Ethan <47520067+Skelmis@users.noreply.github.com> Date: Sat, 24 Feb 2024 18:40:54 +1300 Subject: [PATCH 02/11] Adds interaction handler class (#76) * chore: change readme * feat: add InteractionHandler class (#64) * feat: add InteractionHandler class * feat: add tests for the class * chore: run tests on all branches * chore: make actions nicer to remove duplicate runs * fix: tests * fix: tests --- .github/workflows/test.yml | 3 +- readme.md | 9 ++-- suggestions/interaction_handler.py | 80 ++++++++++++++++++++++++++++++ suggestions/state.py | 5 ++ tests/conftest.py | 9 +++- tests/test_interaction_handler.py | 60 ++++++++++++++++++++++ 6 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 suggestions/interaction_handler.py create mode 100644 tests/test_interaction_handler.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9842d5..02c22e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,8 @@ name: "Pytest" on: pull_request: - branches: [ master ] push: - branches: [ master ] + branches: master jobs: run_tests: diff --git a/readme.md b/readme.md index d23dfc4..6a02f84 100644 --- a/readme.md +++ b/readme.md @@ -1,7 +1,6 @@ -The Suggestions Rewrite +The Suggestions Bot --- -A simplistic bot written by @skelmis utilising Python in -order to get the suggestion's bot back on its feet while -also providing breathing room for @acollierr17 to further -work on the future of this bot. \ No newline at end of file +This bot represents the code base for [suggestions.gg](https://suggestions.gg). + +While it is open source we do not provide support for self-hosting. \ No newline at end of file diff --git a/suggestions/interaction_handler.py b/suggestions/interaction_handler.py new file mode 100644 index 0000000..34bcea9 --- /dev/null +++ b/suggestions/interaction_handler.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from suggestions import SuggestionsBot + + +class InteractionHandler: + """A generic interaction response class to allow for easier + testing and generification of interaction responses. + + This class also aims to move the custom add-ons out of + the underlying disnake classes to help promote easier + version upgrading in the future. + """ + + def __init__( + self, interaction: disnake.Interaction, ephemeral: bool, with_message: bool + ): + self.interaction: disnake.Interaction = interaction + self.ephemeral: bool = ephemeral + self.with_message: bool = with_message + self.is_deferred: bool = False + + # This is useful in error handling to stop + # getting discord "Interaction didn't respond" + # errors if we haven't yet sent anything + self.has_sent_something: bool = False + + async def send( + self, + content: str | None = None, + *, + embed: disnake.Embed | None = None, + file: disnake.File | None = None, + components: list | None = None, + ): + data = {} + if content is not None: + data["content"] = content + if embed is not None: + data["embed"] = embed + if file is not None: + data["file"] = file + if components is not None: + data["components"] = components + + if not data: + raise ValueError("Expected at-least one value to send.") + + await self.interaction.send(ephemeral=self.ephemeral, **data) + self.has_sent_something = True + + @classmethod + async def new_handler( + cls, + interaction: disnake.Interaction, + bot: SuggestionsBot, + *, + ephemeral: bool = True, + with_message: bool = True, + ) -> InteractionHandler: + """Generate a new instance and defer the interaction.""" + instance = cls(interaction, ephemeral, with_message) + await interaction.response.defer(ephemeral=ephemeral, with_message=with_message) + instance.is_deferred = True + + # Register this on the bot instance so other areas can + # request the interaction handler, such as error handlers + bot.state.interaction_handlers.add_entry(interaction.application_id, instance) + + return instance + + @classmethod + async def fetch_handler( + cls, application_id: int, bot: SuggestionsBot + ) -> InteractionHandler: + """Fetch a registered handler for the given interaction.""" + return bot.state.interaction_handlers.get_entry(application_id) diff --git a/suggestions/state.py b/suggestions/state.py index 6cf20ba..02c2dc1 100644 --- a/suggestions/state.py +++ b/suggestions/state.py @@ -22,6 +22,7 @@ from suggestions import SuggestionsBot from alaric import Document from suggestions.database import SuggestionsMongoManager + from suggestions.interaction_handler import InteractionHandler log = logging.getLogger(__name__) @@ -67,6 +68,10 @@ def __init__(self, database: SuggestionsMongoManager, bot: SuggestionsBot): self.existing_suggestion_ids: Set[str] = set() self._background_tasks: list[asyncio.Task] = [] + self.interaction_handlers: TimedCache[int, InteractionHandler] = TimedCache( + global_ttl=timedelta(minutes=20), lazy_eviction=False + ) + @property def is_closing(self) -> bool: return self._is_closing diff --git a/tests/conftest.py b/tests/conftest.py index d63f866..8c627f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import suggestions from tests.mocks import MockedSuggestionsMongoManager +from suggestions.interaction_handler import InteractionHandler @pytest.fixture @@ -15,7 +16,7 @@ async def mocked_database() -> MockedSuggestionsMongoManager: @pytest.fixture -async def causar(monkeypatch, mocked_database) -> Causar: +async def bot(monkeypatch): if "./suggestions" not in [x[0] for x in os.walk(".")]: monkeypatch.chdir("..") @@ -33,6 +34,11 @@ async def causar(monkeypatch, mocked_database) -> Causar: bot = await suggestions.create_bot(mocked_database) await bot.load_cogs() + return bot + + +@pytest.fixture +async def causar(bot, mocked_database) -> Causar: return Causar(bot) # type: ignore @@ -41,3 +47,4 @@ async def injection_metadata(causar: Causar) -> InjectionMetadata: return InjectionMetadata( guild_id=881118111967883295, channel_id=causar.faker.generate_snowflake() ) + diff --git a/tests/test_interaction_handler.py b/tests/test_interaction_handler.py new file mode 100644 index 0000000..681b077 --- /dev/null +++ b/tests/test_interaction_handler.py @@ -0,0 +1,60 @@ +from unittest.mock import AsyncMock + +import pytest +from bot_base import NonExistentEntry + +from suggestions.interaction_handler import InteractionHandler + + +async def test_send(interaction_handler: InteractionHandler): + assert interaction_handler.has_sent_something is False + + with pytest.raises(ValueError): + await interaction_handler.send() + + await interaction_handler.send("Hello world") + assert interaction_handler.has_sent_something is True + assert interaction_handler.interaction.send.assert_called_with( + content="Hello world", ephemeral=True + ) + + interaction_handler.interaction = AsyncMock() + await interaction_handler.send( + "Hello world", embed="Embed", file="File", components=["Test"] + ) + assert interaction_handler.interaction.send.assert_called_with( + content="Hello world", + ephemeral=True, + embed="Embed", + file="File", + components=["Test"], + ) + + +async def test_new_handler(bot): + assert bot.state.interaction_handlers.cache == {} + handler: InteractionHandler = await InteractionHandler.new_handler(AsyncMock(), bot) + assert bot.state.interaction_handlers.cache != {} + assert handler.has_sent_something is False + assert handler.is_deferred is True + + handler_2: InteractionHandler = await InteractionHandler.new_handler( + AsyncMock(), bot, ephemeral=False, with_message=False + ) + assert handler_2.ephemeral is False + assert handler_2.with_message is False + + +async def test_fetch_handler(bot): + application_id = 123456789 + with pytest.raises(NonExistentEntry): + await InteractionHandler.fetch_handler(application_id, bot) + + mock = AsyncMock() + mock.application_id = application_id + await InteractionHandler.new_handler(mock, bot, with_message=False) + handler: InteractionHandler = await InteractionHandler.fetch_handler( + application_id, bot + ) + assert handler.with_message is False + assert handler.ephemeral is True From 584009376f51621e524521ab8ef971a25de70698 Mon Sep 17 00:00:00 2001 From: Ethan <47520067+Skelmis@users.noreply.github.com> Date: Tue, 27 Feb 2024 18:43:23 +1300 Subject: [PATCH 03/11] fix: remove information disclosure via queue approval (#78) --- suggestions/objects/suggestion.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/suggestions/objects/suggestion.py b/suggestions/objects/suggestion.py index 06dcb0a..ea04c70 100644 --- a/suggestions/objects/suggestion.py +++ b/suggestions/objects/suggestion.py @@ -925,11 +925,16 @@ async def setup_initial_messages( log.debug("Created a thread on suggestion %s", self.suggestion_id) try: + suggestion_author = ( + f"<@{self.suggestion_author_id}>" + if comes_from_queue + else interaction.author.mention + ) embed: disnake.Embed = disnake.Embed( description=bot.get_locale( "SUGGEST_INNER_SUGGESTION_SENT", interaction.locale ).format( - interaction.author.mention, + suggestion_author, channel.mention, self.suggestion_id, ), From f0a236f287a9573b1bf77a8502ac65f3d7217853 Mon Sep 17 00:00:00 2001 From: skelmis Date: Tue, 27 Feb 2024 21:27:45 +1300 Subject: [PATCH 04/11] fix: suggestion message race condition --- suggestions/clunk2/edits.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/suggestions/clunk2/edits.py b/suggestions/clunk2/edits.py index 9149c21..0a35be2 100644 --- a/suggestions/clunk2/edits.py +++ b/suggestions/clunk2/edits.py @@ -39,10 +39,16 @@ async def update_suggestion_message( pending_edits.discard(suggestion.suggestion_id) return + # We do this to avoid a race condition where the suggestion may have + # had a value modified between when it was added to the edit queue + # and the time at which it was actually edited + up_to_date_suggestion = await bot.state.suggestions_db.find(suggestion) try: await MessageEditing( - bot, channel_id=suggestion.channel_id, message_id=suggestion.message_id - ).edit(embed=await suggestion.as_embed(bot)) + bot, + channel_id=up_to_date_suggestion.channel_id, + message_id=up_to_date_suggestion.message_id, + ).edit(embed=await up_to_date_suggestion.as_embed(bot)) except (disnake.HTTPException, disnake.NotFound): log.error("Failed to update suggestion %s", suggestion.suggestion_id) From 79a0be99eaaea785b20ae0380adcd41b761b926f Mon Sep 17 00:00:00 2001 From: Davi <68123395+Davi-the-Mudkip@users.noreply.github.com> Date: Fri, 1 Mar 2024 04:15:52 -0300 Subject: [PATCH 05/11] Update pt_BR.json to version 3.20 (#68) * Update pt_BR.json to version 3.20 * Update PULL_REQUEST_TEMPLATE.md * Suggestion queue update --------- Co-authored-by: Ethan <47520067+Skelmis@users.noreply.github.com> --- .github/PULL_REQUEST_TEMPLATE.md | 3 +-- suggestions/locales/pt_BR.json | 45 +++++++++++++++++++++++--------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5fbb6dd..70df6d3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,4 @@ ## Summary - ## Checklist @@ -14,4 +13,4 @@ - [ ] New errors have been updated on ``stats.suggestions.gg`` - [ ] Guild config method names aren't duplicated - [ ] New localizations have been added -- [ ] Documentation on ``docs.suggestions.gg`` has been updated \ No newline at end of file +- [ ] Documentation on ``docs.suggestions.gg`` has been updated diff --git a/suggestions/locales/pt_BR.json b/suggestions/locales/pt_BR.json index d058a04..d12cfb3 100644 --- a/suggestions/locales/pt_BR.json +++ b/suggestions/locales/pt_BR.json @@ -1,11 +1,12 @@ { "SUGGEST_NAME": "sugerir", "SUGGESTION_DESCRIPTION": "Crie uma nova sugestão.", + "SUGGESTION_ARG_SUGGESTION_NAME": "sugestão", "SUGGESTION_ARG_SUGGESTION_DESCRIPTION": "Sua sugestão.", "SUGGEST_ARG_IMAGE_NAME": "imagem", "SUGGESTION_ARG_IMAGE_DESCRIPTION": "Uma imagem para adicionar a sua sugestão. Atualmente, as imagens expiram após algumas semanas.", "SUGGEST_ARG_ANONYMOUSLY_NAME": "anonimamente", - "SUGGEST_ARG_ANONYMOUSLY_DESCRIPTION": "Envie sua sugestão anonimamente.", + "SUGGEST_ARG_ANONYMOUSLY_DESCRIPTION": "Envia sua sugestão anonimamente.", "APPROVE_NAME": "aprovar", "APPROVE_DESCRIPTION": "Aprove uma sugestão", "APPROVE_ARG_SUGGESTION_ID_NAME": "id_da_sugestão", @@ -23,7 +24,7 @@ "CLEAR_ARG_SUGGESTION_ID_NAME": "id_da_sugestão", "CLEAR_ARG_SUGGESTION_ID_DESCRIPTION": "O sID que você deseja remover", "CLEAR_ARG_RESPONSE_NAME": "resposta", - "CLEAR_ARG_RESPONSE_DESCRIPTION": "Uma resposta opcional sobre por que se removeu esta sugestão.", + "CLEAR_ARG_RESPONSE_DESCRIPTION": "Uma resposta opcional para adicionar a sua sugestão.", "STATS_NAME": "estatísticas", "STATS_DESCRIPTION": "Obtenha estatísticas do bot!", "INFO_NAME": "info", @@ -53,17 +54,24 @@ "SUGGEST_INNER_SUGGESTION_SENT": "Ei, {}. Sua sugestão foi enviada para {} para ser votada!\n\nPor favor, aguarde até ela ser aprovada ou rejeitada por um membro da staff.\n\nPara referência, seu ID de sugestão (sID) é {}.", "SUGGEST_INNER_SUGGESTION_SENT_FOOTER": "ID do servidor: {} | sID: {}", "SUGGEST_INNER_THANKS": "Obrigado por sua sugestão!", - "SUGGEST_INNER_SENT_TO_QUEUE": "Enviou-se sua sugestão à fila para processamento.", + "SUGGEST_INNER_SENT_TO_QUEUE": "Sua sugestão foi enviada à fila para processamento.", "SUGGEST_INNER_NO_ANONYMOUS_SUGGESTIONS": "Seu servidor não permite sugestões anônimas.", "SUGGEST_INNER_NO_IMAGES_IN_SUGGESTIONS": "Seu servidor não permite imagens em sugestões.", "CONFIG_ANONYMOUS_ENABLE_INNER_SUCCESS": "Habilitei sugestões anônimas para este servidor.", "CONFIG_ANONYMOUS_DISABLE_INNER_SUCCESS": "Desabilitei sugestões anônimas para este servidor.", "SUGGESTION_OBJECT_LOCK_THREAD": "Trancando este tópico já que a sugestão chegou a uma resolução.", - "CONFIG_CHANNEL_INNER_MESSAGE": "Configurei o canal de sugestões deste servidor em {}", - "CONFIG_LOGS_INNER_MESSAGE": "Configurei o canal de sugestões deste servidor em {}", + "CONFIG_CHANNEL_INNER_MESSAGE": "Configurei o canal de sugestões deste servidor como {}", + "CONFIG_LOGS_INNER_MESSAGE": "Configurei o canal de registro de sugestões deste servidor como {}", + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE": "Configurei o canal da fila de sugestões deste servidor como $CHANNEL", + "CONFIG_QUEUE_LOG_CHANNEL_INNER_MESSAGE": "Configurei o canal da fila de sugestões deste servidor como {}", + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE_REMOVED": "Desabilitei o envio de sugestões rejeitadas na fila para um canal.", "CONFIG_GET_INNER_BASE_EMBED_DESCRIPTION": "Configuração para {}\n", - "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_SET": "Canal de registros: <#{}>", + "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_SET": "Canal de registro de sugestões: <#{}>", "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_NOT_SET": "Não configurado", + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_SET": "Canal da fila de sugestões: <#{}>", + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_NOT_SET": "Não configurado", + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_SET": "Canal de rejeição na fila: <#{}>", + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_NOT_SET": "Não configurado", "CONFIG_GET_INNER_PARTIAL_SUGGESTION_CHANNEL_SET": "Canal de sugestões: <#{}>", "CONFIG_GET_INNER_PARTIAL_SUGGESTION_CHANNEL_NOT_SET": "Não configurado", "CONFIG_GET_INNER_PARTIAL_DM_RESPONSES_SET": "Vou", @@ -77,6 +85,8 @@ "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_SET": "pode", "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_NOT_SET": "não pode", "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_MESSAGE": "Este servidor {} ter sugestões anônimas.", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_SET": "Este servidor usa um canal como fila de sugestões.", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_NOT_SET": "Este servidor usa uma fila virtual de sugestões.", "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_SET": "Vou", "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_NOT_SET": "Não vou", "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_MESSAGE": "{} trancar automaticamente tópicos criados para sugestões.", @@ -84,7 +94,7 @@ "CONFIG_DM_DISABLE_INNER_MESSAGE": "Desabilitei mensagens diretas para este servidor.", "CONFIG_THREAD_ENABLE_INNER_MESSAGE": "Habilitei tópicos em novas sugestões para este servidor.", "CONFIG_THREAD_DISABLE_INNER_MESSAGE": "Desabilitei tópicos em novas sugestões para este servidor.", - "CONFIG_KEEPLOGS_ENABLE_INNER_MESSAGE": "Registros de sugestões agora serão mantidos no seu canal de sugestões.", + "CONFIG_KEEPLOGS_ENABLE_INNER_MESSAGE": "Registros de sugestões agora serão mantidos no seu canal de sugestões em vez de irem a outro canal.", "CONFIG_KEEPLOGS_DISABLE_INNER_MESSAGE": "Registros de sugestões agora serão movidas para seu canal de registros quando finalizadas", "CONFIG_AUTO_ARCHIVE_THREADS_ENABLE_INNER_MESSAGE": "Tópicos criados automaticamente para sugestões agora serão arquivados na resolução da sugestão.", "CONFIG_AUTO_ARCHIVE_THREADS_DISABLE_INNER_MESSAGE": "Tópicos criados automaticamente para sugestões não serão mais arquivados na resolução da sugestão.", @@ -101,16 +111,27 @@ "PAGINATION_INNER_NEXT_ITEM": "Vendo o próximo item na fila...", "PAGINATION_INNER_PREVIOUS_ITEM": "Vendo o item anterior na fila...", "PAGINATION_INNER_QUEUE_EXPIRED": "Esta fila expirou.", - "PAGINATION_INNER_QUEUE_CANCELLED": "Anulei esta fila de espera para você.", + "PAGINATION_INNER_QUEUE_CANCELLED": "Anulei esta fila para você.", "PAGINATION_INNER_QUEUE_ACCEPTED": "Aceitei essa sugestão da fila.", "PAGINATION_INNER_QUEUE_REJECTED": "Removi essa sugestão da fila.", - "QUEUE_VIEW_INNER_NOTHING_QUEUED": "Seu servidor não tem sugestões na fila.", - "QUEUE_VIEW_INNER_PRIOR_QUEUE": "Colocaram-se estas sugestões na fila antes que seu servidor desativasse a fila de sugestões.", - "QUEUE_INNER_USER_REJECTED": "Sua sugestão que estava na fila foi rejeitada.", + "QUEUE_VIEW_INNER_NOTHING_QUEUED": "Seu servidor não tem sugestões na fila virtual.", + "QUEUE_VIEW_INNER_PRIOR_QUEUE": "Estas sugestões estavam na fila antes que seu servidor desativasse a fila de sugestões.", + "QUEUE_INNER_USER_REJECTED": "Sua sugestão, que estava na fila, foi rejeitada. Para referência, incluí a sugestão rejeitada embaixo.", + "QUEUE_INNER_ALREADY_RESOLVED": "Esta sugestão da fila já foi resolvida.", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_SET": "pode", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_NOT_SET": "não pode", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_MESSAGE": "Este servidor {} ter imagens em sugestões.", "CONFIG_SUGGESTIONS_IMAGES_ENABLE_INNER_MESSAGE": "Todas as novas sugestões podem incluir imagens.", "CONFIG_SUGGESTIONS_IMAGES_DISABLE_INNER_MESSAGE": "Todas as novas sugestões não podem incluir imagens.", "VIEW_VOTERS_INNER_EMBED_TITLE": "Vendo votantes..." -} + "USER_BLOCKLIST_ADD_NAME": "adicionar", + "USER_BLOCKLIST_ADD_DESCRIPTION": "Remove a habilidade de um usuário de criar sugestões.", + "USER_BLOCKLIST_REMOVE_NAME": "remover", + "USER_BLOCKLIST_REMOVE_DESCRIPTION": "Readicionar a habilidade de um usuário de criar sugestões.", + "SUGGESTION_ID_NAME": "id_da_sugestão", + "SUGGESTION_ID_DESCRIPTION": "O ID de sugestão que você deseja referenciar.", + "USER_ID_NAME": "id_de_usuário", + "USER_ID_DESCRIPTION": "O ID do usuário no Discord." + "CONFIG_USE_PHYSICAL_QUEUE_ENABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila de canal.", + "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila virtual.", +} From 7de263aa02229c4e7a03b0443b0f6f2e0b04eb37 Mon Sep 17 00:00:00 2001 From: skelmis Date: Fri, 1 Mar 2024 20:17:31 +1300 Subject: [PATCH 06/11] fix: json file format --- suggestions/locales/pt_BR.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/suggestions/locales/pt_BR.json b/suggestions/locales/pt_BR.json index d12cfb3..000f344 100644 --- a/suggestions/locales/pt_BR.json +++ b/suggestions/locales/pt_BR.json @@ -123,7 +123,7 @@ "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_MESSAGE": "Este servidor {} ter imagens em sugestões.", "CONFIG_SUGGESTIONS_IMAGES_ENABLE_INNER_MESSAGE": "Todas as novas sugestões podem incluir imagens.", "CONFIG_SUGGESTIONS_IMAGES_DISABLE_INNER_MESSAGE": "Todas as novas sugestões não podem incluir imagens.", - "VIEW_VOTERS_INNER_EMBED_TITLE": "Vendo votantes..." + "VIEW_VOTERS_INNER_EMBED_TITLE": "Vendo votantes...", "USER_BLOCKLIST_ADD_NAME": "adicionar", "USER_BLOCKLIST_ADD_DESCRIPTION": "Remove a habilidade de um usuário de criar sugestões.", "USER_BLOCKLIST_REMOVE_NAME": "remover", @@ -131,7 +131,7 @@ "SUGGESTION_ID_NAME": "id_da_sugestão", "SUGGESTION_ID_DESCRIPTION": "O ID de sugestão que você deseja referenciar.", "USER_ID_NAME": "id_de_usuário", - "USER_ID_DESCRIPTION": "O ID do usuário no Discord." + "USER_ID_DESCRIPTION": "O ID do usuário no Discord.", "CONFIG_USE_PHYSICAL_QUEUE_ENABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila de canal.", - "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila virtual.", + "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE": "Se a fila de sugestões estiver habilitada, todas as novas sugestões irão a uma fila virtual." } From d38a3c7a22cc91e77d4c827dd363a111e8f89e24 Mon Sep 17 00:00:00 2001 From: Ethan <47520067+Skelmis@users.noreply.github.com> Date: Fri, 1 Mar 2024 21:38:04 +1300 Subject: [PATCH 07/11] fix: images 404'ing (#79) --- requirements.txt | 2 ++ suggestions/bot.py | 13 +++++++ suggestions/codes.py | 1 + suggestions/cogs/suggestion_cog.py | 33 ++++++++++------- suggestions/exceptions.py | 4 +++ suggestions/utility/__init__.py | 0 suggestions/utility/r2.py | 57 ++++++++++++++++++++++++++++++ 7 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 suggestions/utility/__init__.py create mode 100644 suggestions/utility/r2.py diff --git a/requirements.txt b/requirements.txt index b64820a..950813e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,3 +51,5 @@ typing_extensions==4.3.0 websockets==10.4 yarl==1.7.2 zonis==1.2.5 +types-aiobotocore==2.11.2 +aiobotocore==2.11.2 diff --git a/suggestions/bot.py b/suggestions/bot.py index fad449b..000fcd8 100644 --- a/suggestions/bot.py +++ b/suggestions/bot.py @@ -36,6 +36,7 @@ QueueImbalance, BlocklistedUser, PartialResponse, + InvalidFileType, ) from suggestions.http_error_parser import try_parse_http_error from suggestions.objects import Error, GuildConfig, UserConfig @@ -447,6 +448,18 @@ async def on_slash_command_error( ephemeral=True, ) + elif isinstance(exception, InvalidFileType): + return await interaction.send( + embed=self.error_embed( + "Invalid file type", + "The file you attempted to upload is not an accepted type.\n\n" + "If you believe this is an error please reach out to us via our support discord.", + error_code=ErrorCode.INVALID_FILE_TYPE, + error=error, + ), + ephemeral=True, + ) + elif isinstance(exception, ConfiguredChannelNoLongerExists): return await interaction.send( embed=self.error_embed( diff --git a/suggestions/codes.py b/suggestions/codes.py index ef7e0d4..12356b2 100644 --- a/suggestions/codes.py +++ b/suggestions/codes.py @@ -26,6 +26,7 @@ class ErrorCode(IntEnum): QUEUE_IMBALANCE = 20 MISSING_QUEUE_CHANNEL = 21 BLOCKLISTED_USER = 22 + INVALID_FILE_TYPE = 23 @classmethod def from_value(cls, value: int) -> ErrorCode: diff --git a/suggestions/cogs/suggestion_cog.py b/suggestions/cogs/suggestion_cog.py index 190185d..b98a0bd 100644 --- a/suggestions/cogs/suggestion_cog.py +++ b/suggestions/cogs/suggestion_cog.py @@ -1,21 +1,22 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Optional, cast +from typing import TYPE_CHECKING, Optional import cooldowns import disnake from commons.caching import NonExistentEntry from bot_base.wraps import WrappedChannel -from disnake import Guild, Localized +from disnake import Guild from disnake.ext import commands, components -from suggestions import checks, Stats, ErrorCode +from suggestions import checks, Stats from suggestions.clunk2 import update_suggestion_message from suggestions.cooldown_bucket import InteractionBucket from suggestions.exceptions import SuggestionTooLong, ErrorHandled -from suggestions.objects import Suggestion, GuildConfig, UserConfig, QueuedSuggestion +from suggestions.objects import Suggestion, GuildConfig, QueuedSuggestion from suggestions.objects.suggestion import SuggestionState +from suggestions.utility import r2 if TYPE_CHECKING: from alaric import Document @@ -188,15 +189,23 @@ async def suggest( ) raise ErrorHandled - image_url = image.url if isinstance(image, disnake.Attachment) else None - if image_url and not guild_config.can_have_images_in_suggestions: - await interaction.send( - self.bot.get_locale( - "SUGGEST_INNER_NO_IMAGES_IN_SUGGESTIONS", interaction.locale - ), - ephemeral=True, + image_url = None + if image is not None: + if not guild_config.can_have_images_in_suggestions: + await interaction.send( + self.bot.get_locale( + "SUGGEST_INNER_NO_IMAGES_IN_SUGGESTIONS", interaction.locale + ), + ephemeral=True, + ) + raise ErrorHandled + + image_url = await r2.upload_file_to_r2( + file_name=image.filename, + file_data=await image.read(use_cached=True), + guild_id=interaction.guild_id, + user_id=interaction.author.id, ) - raise ErrorHandled if guild_config.uses_suggestion_queue: await QueuedSuggestion.new( diff --git a/suggestions/exceptions.py b/suggestions/exceptions.py index 6669a63..ba3c1d4 100644 --- a/suggestions/exceptions.py +++ b/suggestions/exceptions.py @@ -53,3 +53,7 @@ class BlocklistedUser(CheckFailure): class PartialResponse(Exception): """A garven route returned a partial response when we require a full response""" + + +class InvalidFileType(disnake.DiscordException): + """The file you attempted to upload is not allowed.""" diff --git a/suggestions/utility/__init__.py b/suggestions/utility/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/suggestions/utility/r2.py b/suggestions/utility/r2.py new file mode 100644 index 0000000..1a886f6 --- /dev/null +++ b/suggestions/utility/r2.py @@ -0,0 +1,57 @@ +import hashlib +import logging +import mimetypes +import os +import secrets + +from aiobotocore.session import get_session + +from suggestions.exceptions import InvalidFileType + +log = logging.getLogger(__name__) + + +async def upload_file_to_r2( + *, + file_name: str, + file_data: bytes, + guild_id: int, + user_id: int, +) -> str: + """Upload a file to R2 and get the cdn url back""" + + session = get_session() + async with session.create_client( + "s3", + endpoint_url=os.environ["ENDPOINT"], + aws_access_key_id=os.environ["ACCESS_KEY"], + aws_secret_access_key=os.environ["SECRET_ACCESS_KEY"], + ) as client: + mimetype_guessed, _ = mimetypes.guess_type(file_name) + accepted_mimetypes: dict[str, set[str]] = { + "image/jpeg": {".jpeg", ".jpg"}, + "image/png": {".png"}, + "image/gif": {".gif"}, + "video/mp3": {".mp3"}, + "video/mp4": {".mp4"}, + "video/mpeg": {".mpeg"}, + "video/webm": {".webm"}, + "image/webp": {".webp"}, + "audio/webp": {".weba"}, + } + file_names = accepted_mimetypes.get(mimetype_guessed) + if file_names is None: + raise InvalidFileType + + for ext in file_names: + if file_name.endswith(ext): + break + else: + raise InvalidFileType + + file_key = hashlib.sha256(file_data + secrets.token_bytes(16)).hexdigest() + key = "{}/{}.{}".format(guild_id, file_key, ext) + await client.put_object(Bucket=os.environ["BUCKET"], Key=key, Body=file_data) + log.debug("User %s in guild %s uploaded an image", user_id, guild_id) + + return f"https://cdn.suggestions.bot/{key}" From acff1c12711b4d16a932549a95d12d5c0724bfdc Mon Sep 17 00:00:00 2001 From: Ethan <47520067+Skelmis@users.noreply.github.com> Date: Sun, 3 Mar 2024 22:24:42 +1300 Subject: [PATCH 08/11] feat: physical suggestions queue (#77) * fix: InteractionHandler tests * feat: move current suggestions queue to use interaction handler * feat: port existing queue rejection/approval to new generic method * fix: virtual queue approval and rejection system * feat: add initial physical queue * fix: messages being sent * fix: error message for missing queue channel * feat: add items to /config get * fix: tests * chore: make button styling more obvious * chore: rename physical queue to channel queue * chore: fix wording --- suggestions/bot.py | 51 +++- suggestions/codes.py | 4 +- suggestions/cogs/guild_config_cog.py | 185 +++++++++++- suggestions/cogs/suggestion_cog.py | 72 ++++- suggestions/cogs/suggestion_queue_cog.py | 257 +++------------- suggestions/core/__init__.py | 1 + suggestions/core/suggestions_queue.py | 370 +++++++++++++++++++++++ suggestions/exceptions.py | 13 + suggestions/interaction_handler.py | 45 ++- suggestions/locales/en_GB.json | 2 +- suggestions/locales/en_US.json | 18 +- suggestions/objects/guild_config.py | 9 + suggestions/objects/queued_suggestion.py | 73 ++++- suggestions/state.py | 32 +- suggestions/stats.py | 4 + tests/conftest.py | 6 +- tests/mocks/database.py | 2 +- tests/test_interaction_handler.py | 56 ++-- 18 files changed, 928 insertions(+), 272 deletions(-) create mode 100644 suggestions/core/__init__.py create mode 100644 suggestions/core/suggestions_queue.py diff --git a/suggestions/bot.py b/suggestions/bot.py index 000fcd8..ea87a85 100644 --- a/suggestions/bot.py +++ b/suggestions/bot.py @@ -36,9 +36,12 @@ QueueImbalance, BlocklistedUser, PartialResponse, + MissingQueueLogsChannel, + MissingPermissionsToAccessQueueChannel, InvalidFileType, ) from suggestions.http_error_parser import try_parse_http_error +from suggestions.interaction_handler import InteractionHandler from suggestions.objects import Error, GuildConfig, UserConfig from suggestions.stats import Stats, StatsEnum from suggestions.database import SuggestionsMongoManager @@ -240,11 +243,16 @@ async def process_commands(self, message: disnake.Message): await self.invoke(ctx) async def _push_slash_error_stats( - self, interaction: disnake.ApplicationCommandInteraction + self, + interaction: disnake.ApplicationCommandInteraction | disnake.MessageInteraction, ): - stat_type: Optional[StatsEnum] = StatsEnum.from_command_name( + name = ( interaction.application_command.qualified_name + if isinstance(interaction, disnake.ApplicationCommandInteraction) + else interaction.data["custom_id"].split(":")[0] # Button name ) + + stat_type: Optional[StatsEnum] = StatsEnum.from_command_name(name) if not stat_type: return @@ -380,6 +388,30 @@ async def on_slash_command_error( ephemeral=True, ) + elif isinstance(exception, MissingQueueLogsChannel): + return await interaction.send( + embed=self.error_embed( + "Missing Queue Logs Channel", + "This command requires a queue log channel to use.\n" + "Please contact an administrator and ask them to set one up " + "using the following command.\n`/config queue_channel`", + error_code=ErrorCode.MISSING_QUEUE_LOG_CHANNEL, + error=error, + ), + ephemeral=True, + ) + + elif isinstance(exception, MissingPermissionsToAccessQueueChannel): + return await interaction.send( + embed=self.error_embed( + title="Missing permissions within queue logs channel", + description="The bot does not have the required permissions in your queue channel. " + "Please contact an administrator and ask them to fix this.", + error=error, + error_code=ErrorCode.MISSING_PERMISSIONS_IN_QUEUE_CHANNEL, + ) + ) + elif isinstance(exception, commands.MissingPermissions): perms = ",".join(i for i in exception.missing_permissions) return await interaction.send( @@ -543,7 +575,12 @@ async def on_slash_command_error( ) return - if interaction.deferred_without_send: + ih: InteractionHandler = await InteractionHandler.fetch_handler( + interaction.id, self + ) + if interaction.deferred_without_send or ( + ih is not None and not ih.has_sent_something + ): gid = interaction.guild_id if interaction.guild_id else None # Fix "Bot is thinking" hanging on edge cases... await interaction.send( @@ -803,7 +840,7 @@ def get_locale(self, key: str, locale: Locale) -> str: return values[str(locale)] except KeyError: # Default to known translations if not set - return values["en-GB"] + return values.get("en-GB", values["en-US"]) @staticmethod def inject_locale_values( @@ -843,11 +880,15 @@ def inject_locale_values( def get_localized_string( self, key: str, - interaction: disnake.Interaction, + interaction: disnake.Interaction | InteractionHandler, *, extras: Optional[dict] = None, guild_config: Optional[GuildConfig] = None, ): + if isinstance(interaction, InteractionHandler): + # Support this so easier going forward + interaction = interaction.interaction + content = self.get_locale(key, interaction.locale) return self.inject_locale_values( content, interaction=interaction, guild_config=guild_config, extras=extras diff --git a/suggestions/codes.py b/suggestions/codes.py index 12356b2..7965274 100644 --- a/suggestions/codes.py +++ b/suggestions/codes.py @@ -26,7 +26,9 @@ class ErrorCode(IntEnum): QUEUE_IMBALANCE = 20 MISSING_QUEUE_CHANNEL = 21 BLOCKLISTED_USER = 22 - INVALID_FILE_TYPE = 23 + MISSING_QUEUE_LOG_CHANNEL = 23 + MISSING_PERMISSIONS_IN_QUEUE_CHANNEL = 24 + INVALID_FILE_TYPE = 25 @classmethod def from_value(cls, value: int) -> ErrorCode: diff --git a/suggestions/cogs/guild_config_cog.py b/suggestions/cogs/guild_config_cog.py index 58528a2..7b90c55 100644 --- a/suggestions/cogs/guild_config_cog.py +++ b/suggestions/cogs/guild_config_cog.py @@ -11,6 +11,7 @@ from suggestions import Stats from suggestions.cooldown_bucket import InteractionBucket from suggestions.exceptions import InvalidGuildConfigOption +from suggestions.interaction_handler import InteractionHandler from suggestions.objects import GuildConfig from suggestions.stats import StatsEnum @@ -97,6 +98,83 @@ async def logs( self.stats.type.GUILD_CONFIG_LOG_CHANNEL, ) + @config.sub_command() + async def queue_channel( + self, + interaction: disnake.GuildCommandInteraction, + channel: disnake.TextChannel, + ): + """Set your guilds physical suggestions queue channel.""" + ih: InteractionHandler = await InteractionHandler.new_handler(interaction) + guild_config: GuildConfig = await GuildConfig.from_id( + interaction.guild_id, self.state + ) + try: + message = await channel.send("This is a test message and can be ignored.") + await message.delete() + except disnake.Forbidden: + return await ih.send( + f"I do not have permissions to delete messages in {channel.mention}. " + f"Please give me permissions and run this command again.", + ) + + guild_config.queued_channel_id = channel.id + self.state.refresh_guild_config(guild_config) + await self.state.guild_config_db.upsert(guild_config, guild_config) + await ih.send( + self.bot.get_localized_string( + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE", + ih, + extras={"CHANNEL": channel.mention}, + ) + ) + log.debug( + "User %s changed physical queue channel to %s in guild %s", + interaction.author.id, + channel.id, + interaction.guild_id, + ) + await self.stats.log_stats( + interaction.author.id, + interaction.guild_id, + self.stats.type.GUILD_CONFIG_QUEUE_CHANNEL, + ) + + @config.sub_command() + async def queue_log_channel( + self, + interaction: disnake.GuildCommandInteraction, + channel: disnake.TextChannel = None, + ): + """Set your guilds suggestion queue log channel for rejected suggestions.""" + ih: InteractionHandler = await InteractionHandler.new_handler(interaction) + guild_config: GuildConfig = await GuildConfig.from_id( + interaction.guild_id, self.state + ) + guild_config.queued_log_channel_id = channel.id if channel else None + self.state.refresh_guild_config(guild_config) + await self.state.guild_config_db.upsert(guild_config, guild_config) + key = ( + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE_REMOVED" + if channel is None + else "CONFIG_QUEUE_LOG_CHANNEL_INNER_MESSAGE" + ) + msg = self.bot.get_locale(key, interaction.locale) + if channel is not None: + msg = msg.format(channel.mention) + await ih.send(msg) + log.debug( + "User %s changed rejected queue log channel to %s in guild %s", + interaction.author.id, + channel.id if channel is not None else None, + interaction.guild_id, + ) + await self.stats.log_stats( + interaction.author.id, + interaction.guild_id, + self.stats.type.GUILD_CONFIG_REJECTED_QUEUE_CHANNEL, + ) + @config.sub_command() async def get( self, @@ -114,6 +192,9 @@ async def get( "Suggestions queue", "Images in suggestions", "Anonymous resolutions", + "Using channel queue", + "Queue channel", + "Queue rejection channel", ], default=None, ), @@ -147,6 +228,33 @@ async def get( ) embed.description += log_channel + elif config == "Queue channel": + log_channel = ( + self.bot.get_locale( + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_SET", interaction.locale + ).format(guild_config.queued_channel_id) + if guild_config.queued_channel_id + else self.bot.get_locale( + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_NOT_SET", + interaction.locale, + ) + ) + embed.description += log_channel + + elif config == "Queue rejection channel": + log_channel = ( + self.bot.get_locale( + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_SET", + interaction.locale, + ).format(guild_config.queued_log_channel_id) + if guild_config.queued_log_channel_id + else self.bot.get_locale( + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_NOT_SET", + interaction.locale, + ) + ) + embed.description += log_channel + elif config == "Suggestions channel": suggestions_channel = ( self.bot.get_locale( @@ -265,6 +373,17 @@ async def get( embed.description += text + elif config == "Using channel queue": + locale_string = ( + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_NOT_SET" + if guild_config.virtual_suggestion_queue + else "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_SET" + ) + + text = self.bot.get_localized_string(locale_string, interaction) + + embed.description += text + else: raise InvalidGuildConfigOption @@ -300,6 +419,22 @@ async def send_full_config(self, interaction: disnake.GuildCommandInteraction): interaction.locale, ) ) + queue_channel = ( + f"<#{guild_config.queued_channel_id}>" + if guild_config.queued_channel_id + else self.bot.get_locale( + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_NOT_SET", + interaction.locale, + ) + ) + queue_rejection_channel = ( + f"<#{guild_config.queued_log_channel_id}>" + if guild_config.queued_log_channel_id + else self.bot.get_locale( + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_NOT_SET", + interaction.locale, + ) + ) dm_responses = ( self.bot.get_locale( "CONFIG_GET_INNER_PARTIAL_DM_RESPONSES_NOT_SET", interaction.locale @@ -345,6 +480,16 @@ async def send_full_config(self, interaction: disnake.GuildCommandInteraction): "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_MESSAGE", interaction.locale ).format(anon_text) + physical_queue = ( + self.bot.get_locale( + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_NOT_SET", interaction.locale + ) + if guild_config.virtual_suggestion_queue + else self.bot.get_locale( + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_SET", interaction.locale + ) + ) + image_text = ( self.bot.get_locale( "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_SET", interaction.locale @@ -394,7 +539,9 @@ async def send_full_config(self, interaction: disnake.GuildCommandInteraction): f"Log channel: {log_channel}\nDm responses: I {dm_responses} DM users on actions such as suggest\n" f"Suggestion threads: {threads}\nKeep Logs: {keep_logs}\nAnonymous suggestions: {anon}\n" f"Automatic thread archiving: {auto_archive_threads}\nSuggestions queue: {suggestions_queue}\n" - f"Images in suggestions: {images}\nAnonymous resolutions: {anonymous_resolutions}", + f"Channel queue: {physical_queue}\nImages in suggestions: {images}\n" + f"Anonymous resolutions: {anonymous_resolutions}\n" + f"Queue channel: {queue_channel}\nQueue rejection channel: {queue_rejection_channel}", color=self.bot.colors.embed_color, timestamp=self.bot.state.now, ).set_author(name=guild.name, icon_url=icon_url) @@ -642,6 +789,42 @@ async def anonymous_resolutions_disable( self.stats.type.GUILD_ANONYMOUS_RESOLUTIONS_DISABLE, ) + @config.sub_command_group() + async def use_channel_queue(self, interaction: disnake.GuildCommandInteraction): + pass + + @use_channel_queue.sub_command(name="enable") + async def use_physical_queue_enable( + self, interaction: disnake.GuildCommandInteraction + ): + """Set the queue to use a channel for queuing suggestions.""" + await self.modify_guild_config( + interaction, + "virtual_suggestion_queue", + False, + self.bot.get_localized_string( + "CONFIG_USE_PHYSICAL_QUEUE_ENABLE_INNER_MESSAGE", interaction + ), + "Enabled physical queue on suggestions for guild %s", + self.stats.type.GUILD_PHYSICAL_QUEUE_ENABLE, + ) + + @use_channel_queue.sub_command(name="disable") + async def use_physical_queue_disable( + self, interaction: disnake.GuildCommandInteraction + ): + """Use a virtual queue for suggestions in this guild.""" + await self.modify_guild_config( + interaction, + "virtual_suggestion_queue", + True, + self.bot.get_localized_string( + "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE", interaction + ), + "Disabled physical queue on suggestions for guild %s", + self.stats.type.GUILD_PHYSICAL_QUEUE_DISABLE, + ) + @config.sub_command_group() async def images_in_suggestions(self, interaction: disnake.GuildCommandInteraction): pass diff --git a/suggestions/cogs/suggestion_cog.py b/suggestions/cogs/suggestion_cog.py index b98a0bd..d2b38dd 100644 --- a/suggestions/cogs/suggestion_cog.py +++ b/suggestions/cogs/suggestion_cog.py @@ -7,14 +7,22 @@ import disnake from commons.caching import NonExistentEntry from bot_base.wraps import WrappedChannel -from disnake import Guild +from disnake import Guild, ButtonStyle from disnake.ext import commands, components from suggestions import checks, Stats from suggestions.clunk2 import update_suggestion_message from suggestions.cooldown_bucket import InteractionBucket -from suggestions.exceptions import SuggestionTooLong, ErrorHandled -from suggestions.objects import Suggestion, GuildConfig, QueuedSuggestion +from suggestions.core import SuggestionsQueue +from suggestions.exceptions import ( + SuggestionTooLong, + ErrorHandled, + MissingPermissionsToAccessQueueChannel, + MissingQueueLogsChannel, +) +from suggestions.interaction_handler import InteractionHandler +from suggestions.objects import Suggestion, GuildConfig, UserConfig, QueuedSuggestion + from suggestions.objects.suggestion import SuggestionState from suggestions.utility import r2 @@ -32,6 +40,8 @@ def __init__(self, bot: SuggestionsBot): self.stats: Stats = self.bot.stats self.suggestions_db: Document = self.bot.db.suggestions + self.qs_core: SuggestionsQueue = SuggestionsQueue(bot) + @components.button_listener() async def suggestion_up_vote( self, inter: disnake.MessageInteraction, *, suggestion_id: str @@ -150,6 +160,28 @@ async def suggestion_down_vote( await update_suggestion_message(suggestion=suggestion, bot=self.bot) # log.debug("Member %s down voted suggestion %s", member_id, suggestion_id) + @components.button_listener() + async def queue_approve(self, inter: disnake.MessageInteraction): + ih = await InteractionHandler.new_handler(inter) + qs = await QueuedSuggestion.from_message_id( + inter.message.id, inter.message.channel.id, self.state + ) + await self.qs_core.resolve_queued_suggestion( + ih, queued_suggestion=qs, was_approved=True + ) + await ih.send(translation_key="PAGINATION_INNER_QUEUE_ACCEPTED") + + @components.button_listener() + async def queue_reject(self, inter: disnake.MessageInteraction): + ih = await InteractionHandler.new_handler(inter) + qs = await QueuedSuggestion.from_message_id( + inter.message.id, inter.message.channel.id, self.state + ) + await self.qs_core.resolve_queued_suggestion( + ih, queued_suggestion=qs, was_approved=False + ) + await ih.send(translation_key="PAGINATION_INNER_QUEUE_REJECTED") + @commands.slash_command( dm_permission=False, ) @@ -208,7 +240,7 @@ async def suggest( ) if guild_config.uses_suggestion_queue: - await QueuedSuggestion.new( + qs = await QueuedSuggestion.new( suggestion=suggestion, guild_id=interaction.guild_id, state=self.state, @@ -216,6 +248,38 @@ async def suggest( image_url=image_url, is_anonymous=anonymously, ) + if not guild_config.virtual_suggestion_queue: + # Need to send to a channel + if guild_config.queued_channel_id is None: + raise MissingQueueLogsChannel + + try: + queue_channel = await self.bot.state.fetch_channel( + guild_config.queued_channel_id + ) + except disnake.Forbidden as e: + raise MissingPermissionsToAccessQueueChannel from e + + qs_embed: disnake.Embed = await qs.as_embed(self.bot) + msg = await queue_channel.send( + embed=qs_embed, + components=[ + disnake.ui.Button( + label="Approve queued suggestion", + custom_id=await self.queue_approve.build_custom_id(), + style=ButtonStyle.green, + ), + disnake.ui.Button( + label="Reject queued suggestion", + custom_id=await self.queue_reject.build_custom_id(), + style=ButtonStyle.danger, + ), + ], + ) + qs.message_id = msg.id + qs.channel_id = msg.channel.id + await self.bot.db.queued_suggestions.upsert(qs, qs) + log.debug( "User %s created new queued suggestion in guild %s", interaction.author.id, diff --git a/suggestions/cogs/suggestion_queue_cog.py b/suggestions/cogs/suggestion_queue_cog.py index 5f29c86..66c7a8f 100644 --- a/suggestions/cogs/suggestion_queue_cog.py +++ b/suggestions/cogs/suggestion_queue_cog.py @@ -16,7 +16,9 @@ from suggestions import checks from suggestions.cooldown_bucket import InteractionBucket +from suggestions.core import SuggestionsQueue from suggestions.exceptions import ErrorHandled +from suggestions.interaction_handler import InteractionHandler from suggestions.objects import GuildConfig, UserConfig, QueuedSuggestion from suggestions.qs_paginator import QueuedSuggestionsPaginator @@ -30,166 +32,49 @@ class SuggestionsQueueCog(commands.Cog): def __init__(self, bot): - self.bot: SuggestionsBot = bot - self.state = self.bot.state - self.queued_suggestions_db: Document = self.bot.db.queued_suggestions - self.paginator_objects: TimedCache = TimedCache( - global_ttl=timedelta(minutes=15), - lazy_eviction=False, - ttl_from_last_access=True, - ) - - async def get_paginator_for( - self, paginator_id: str, interaction: disnake.Interaction - ) -> QueuedSuggestionsPaginator: - try: - return self.paginator_objects.get_entry(paginator_id) - except NonExistentEntry: - await interaction.send( - self.bot.get_localized_string( - "PAGINATION_INNER_SESSION_EXPIRED", interaction - ), - ephemeral=True, - ) - raise ErrorHandled + self.bot = bot + self.core: SuggestionsQueue = SuggestionsQueue(bot) @components.button_listener() async def next_button(self, inter: disnake.MessageInteraction, *, pid: str): - await inter.response.defer(ephemeral=True, with_message=True) - paginator = await self.get_paginator_for(pid, inter) - paginator.current_page += 1 - await paginator.original_interaction.edit_original_message( - embed=await paginator.format_page() - ) - await inter.send( - self.bot.get_localized_string("PAGINATION_INNER_NEXT_ITEM", inter), - ephemeral=True, - ) + await self.core.next_button(await InteractionHandler.new_handler(inter), pid) @components.button_listener() async def previous_button(self, inter: disnake.MessageInteraction, *, pid: str): - await inter.response.defer(ephemeral=True, with_message=True) - paginator = await self.get_paginator_for(pid, inter) - paginator.current_page -= 1 - await paginator.original_interaction.edit_original_message( - embed=await paginator.format_page() - ) - await inter.send( - self.bot.get_localized_string("PAGINATION_INNER_PREVIOUS_ITEM", inter), - ephemeral=True, + await self.core.previous_button( + await InteractionHandler.new_handler(inter), pid ) @components.button_listener() async def stop_button(self, inter: disnake.MessageInteraction, *, pid: str): - await inter.response.defer(ephemeral=True, with_message=True) - paginator = await self.get_paginator_for(pid, inter) - self.paginator_objects.delete_entry(pid) - await paginator.original_interaction.edit_original_message( - components=[], - embeds=[], - content=self.bot.get_localized_string( - "PAGINATION_INNER_QUEUE_EXPIRED", inter - ), - ) - await inter.send( - self.bot.get_localized_string("PAGINATION_INNER_QUEUE_CANCELLED", inter), - ephemeral=True, - ) + await self.core.stop_button(await InteractionHandler.new_handler(inter), pid) @components.button_listener() - async def approve_button(self, inter: disnake.MessageInteraction, *, pid: str): - await inter.response.defer(ephemeral=True, with_message=True) - paginator = await self.get_paginator_for(pid, inter) - current_suggestion: QueuedSuggestion = ( - await paginator.get_current_queued_suggestion() + async def virtual_approve_button( + self, inter: disnake.MessageInteraction, *, pid: str + ): + await self.core.virtual_approve_button( + await InteractionHandler.new_handler(inter), pid ) - suggestion: None = None - try: - await paginator.remove_current_page() - suggestion: Suggestion = await current_suggestion.resolve( - was_approved=True, state=self.bot.state, resolved_by=inter.author.id - ) - guild_config: GuildConfig = await GuildConfig.from_id( - inter.guild_id, self.state - ) - icon_url = await Guild.try_fetch_icon_url(inter.guild_id, self.state) - guild = self.state.guild_cache.get_entry(inter.guild_id) - await suggestion.setup_initial_messages( - guild_config=guild_config, - interaction=inter, - state=self.state, - bot=self.bot, - cog=self.bot.get_cog("SuggestionsCog"), - guild=guild, - icon_url=icon_url, - comes_from_queue=True, - ) - except: - # Throw it back in the queue on error - current_suggestion.resolved_by = None - current_suggestion.resolved_at = None - current_suggestion.still_in_queue = True - await self.bot.state.queued_suggestions_db.update( - current_suggestion, current_suggestion - ) - - if suggestion is not None: - await self.bot.state.suggestions_db.delete(suggestion) - raise - - await inter.send( - self.bot.get_localized_string("PAGINATION_INNER_QUEUE_ACCEPTED", inter), - ephemeral=True, + @components.button_listener() + async def virtual_reject_button( + self, inter: disnake.MessageInteraction, *, pid: str + ): + await self.core.virtual_reject_button( + await InteractionHandler.new_handler(inter), pid ) @components.button_listener() - async def reject_button(self, inter: disnake.MessageInteraction, *, pid: str): - await inter.response.defer(ephemeral=True, with_message=True) - paginator = await self.get_paginator_for(pid, inter) - current_suggestion = await paginator.get_current_queued_suggestion() - await paginator.remove_current_page() - await current_suggestion.resolve( - was_approved=False, state=self.bot.state, resolved_by=inter.author.id - ) - try: - guild_config: GuildConfig = await GuildConfig.from_id( - inter.guild_id, self.state - ) - icon_url = await Guild.try_fetch_icon_url(inter.guild_id, self.state) - guild = self.state.guild_cache.get_entry(inter.guild_id) - embed: disnake.Embed = disnake.Embed( - description=self.bot.get_localized_string( - "QUEUE_INNER_USER_REJECTED", inter - ), - colour=self.bot.colors.embed_color, - timestamp=self.state.now, - ) - embed.set_author( - name=guild.name, - icon_url=icon_url, - ) - embed.set_footer(text=f"Guild ID {inter.guild_id}") - user = await self.bot.get_or_fetch_user( - current_suggestion.suggestion_author_id - ) - user_config: UserConfig = await UserConfig.from_id( - current_suggestion.suggestion_author_id, self.bot.state - ) - if ( - not user_config.dm_messages_disabled - and not guild_config.dm_messages_disabled - ): - await user.send(embed=embed) - except disnake.HTTPException: - log.debug( - "Failed to DM %s regarding their queued suggestion", - current_suggestion.suggestion_author_id, - ) + async def accept_queued_suggestion(self, inter: disnake.MessageInteraction): + if inter.message is None: + raise ValueError("Unhandled exception, expected a message") - await inter.send( - self.bot.get_localized_string("PAGINATION_INNER_QUEUE_REJECTED", inter), - ephemeral=True, + await self.core.accept_queued_suggestion( + await InteractionHandler.new_handler(inter), + inter.message.id, + inter.message.channel.id, + self.accept_queued_suggestion, ) @commands.slash_command( @@ -204,91 +89,19 @@ async def queue(self, interaction: disnake.GuildCommandInteraction): @queue.sub_command() async def info(self, interaction: disnake.GuildCommandInteraction): """View information about this guilds suggestions queue.""" - await interaction.response.defer(ephemeral=True, with_message=True) - guild_config: GuildConfig = await GuildConfig.from_id( - interaction.guild_id, self.state - ) - count: int = await self.queued_suggestions_db.count( - AQ(AND(EQ("guild_id", interaction.guild_id), EQ("still_in_queue", True))) - ) - icon_url = await Guild.try_fetch_icon_url(interaction.guild_id, self.state) - guild = self.state.guild_cache.get_entry(interaction.guild_id) - embed = disnake.Embed( - title="Queue Info", - timestamp=self.bot.state.now, - description=f"`{count}` suggestions currently in queue.\n" - f"New suggestions will {'' if guild_config.uses_suggestion_queue else 'not'} be " - f"sent to the suggestions queue.", - colour=self.bot.colors.embed_color, - ) - embed.set_author( - name=guild.name, - icon_url=icon_url, - ) - await interaction.send(embed=embed, ephemeral=True) + await self.core.info(await InteractionHandler.new_handler(interaction)) @queue.sub_command() async def view(self, interaction: disnake.GuildCommandInteraction): """View this guilds suggestions queue.""" - await interaction.response.defer(ephemeral=True, with_message=True) - guild_config: GuildConfig = await GuildConfig.from_id( - interaction.guild_id, self.state - ) - data: list = await self.queued_suggestions_db.find_many( - AQ(AND(EQ("guild_id", interaction.guild_id), EQ("still_in_queue", True))), - projections=Projection(SHOW("_id")), - try_convert=False, - ) - if not data: - return await interaction.send( - self.bot.get_localized_string( - "QUEUE_VIEW_INNER_NOTHING_QUEUED", interaction - ), - ephemeral=True, - ) - - content = None - if not guild_config.uses_suggestion_queue: - content = self.bot.get_localized_string( - "QUEUE_VIEW_INNER_PRIOR_QUEUE", interaction - ) - - paginator = QueuedSuggestionsPaginator( - bot=self.bot, data=[d["_id"] for d in data], inter=interaction - ) - pid = self.bot.state.get_new_sq_paginator_id() - await interaction.send( - content=content, - ephemeral=True, - embed=await paginator.format_page(), - components=[ - disnake.ui.ActionRow( - disnake.ui.Button( - emoji="\N{BLACK LEFT-POINTING TRIANGLE}\ufe0f", - custom_id=await self.previous_button.build_custom_id(pid=pid), - ), - disnake.ui.Button( - emoji="\N{BLACK SQUARE FOR STOP}\ufe0f", - custom_id=await self.stop_button.build_custom_id(pid=pid), - ), - disnake.ui.Button( - emoji="\N{BLACK RIGHT-POINTING TRIANGLE}\ufe0f", - custom_id=await self.next_button.build_custom_id(pid=pid), - ), - ), - disnake.ui.ActionRow( - disnake.ui.Button( - emoji=await self.bot.suggestion_emojis.default_up_vote(), - custom_id=await self.approve_button.build_custom_id(pid=pid), - ), - disnake.ui.Button( - emoji=await self.bot.suggestion_emojis.default_down_vote(), - custom_id=await self.reject_button.build_custom_id(pid=pid), - ), - ), - ], + await self.core.view( + await InteractionHandler.new_handler(interaction), + self.previous_button, + self.next_button, + self.stop_button, + self.virtual_approve_button, + self.virtual_reject_button, ) - self.paginator_objects.add_entry(pid, paginator) def setup(bot): diff --git a/suggestions/core/__init__.py b/suggestions/core/__init__.py new file mode 100644 index 0000000..035c03d --- /dev/null +++ b/suggestions/core/__init__.py @@ -0,0 +1 @@ +from .suggestions_queue import SuggestionsQueue diff --git a/suggestions/core/suggestions_queue.py b/suggestions/core/suggestions_queue.py new file mode 100644 index 0000000..0bbab2a --- /dev/null +++ b/suggestions/core/suggestions_queue.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import functools +import logging +from datetime import timedelta +from typing import TYPE_CHECKING, Callable + +import disnake +from alaric import AQ +from alaric.comparison import EQ, Exists +from alaric.logical import AND +from alaric.meta import Negate +from alaric.projections import Projection, SHOW +from commons.caching import NonExistentEntry, TimedCache +from disnake import Guild + +from suggestions.exceptions import ErrorHandled, MissingQueueLogsChannel +from suggestions.interaction_handler import InteractionHandler +from suggestions.objects import GuildConfig, UserConfig, QueuedSuggestion +from suggestions.qs_paginator import QueuedSuggestionsPaginator + +if TYPE_CHECKING: + from alaric import Document + from suggestions import SuggestionsBot + from suggestions.objects import Suggestion + +log = logging.getLogger(__name__) + + +def wrap_with_error_handler(): + def decorator(func: Callable): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + await args[0].bot.on_slash_command_error(args[1].interaction, e) + + return wrapper + + return decorator + + +class SuggestionsQueue: + """ + Approach to suggestions queue. + + If it gets put in the virtual queue, it's always in said queue. + If its put in a channel, it's always in the channel. + Although we do track it under the same db table, this just + saves needing to transition everything between them + """ + + def __init__(self, bot): + self.bot: SuggestionsBot = bot + self.paginator_objects: TimedCache = TimedCache( + global_ttl=timedelta(minutes=15), + lazy_eviction=False, + ttl_from_last_access=True, + ) + + @property + def queued_suggestions_db(self) -> Document: + return self.bot.db.queued_suggestions + + @property + def state(self): + return self.bot.state + + async def get_paginator_for( + self, paginator_id: str, ih: InteractionHandler + ) -> QueuedSuggestionsPaginator: + try: + return self.paginator_objects.get_entry(paginator_id) + except NonExistentEntry: + await ih.send(translation_key="PAGINATION_INNER_SESSION_EXPIRED") + raise ErrorHandled + + async def next_button(self, ih: InteractionHandler, pid: str): + paginator = await self.get_paginator_for(pid, ih) + paginator.current_page += 1 + await paginator.original_interaction.edit_original_message( + embed=await paginator.format_page() + ) + await ih.send(translation_key="PAGINATION_INNER_NEXT_ITEM") + + async def previous_button(self, ih: InteractionHandler, pid: str): + paginator = await self.get_paginator_for(pid, ih) + paginator.current_page -= 1 + await paginator.original_interaction.edit_original_message( + embed=await paginator.format_page() + ) + await ih.send(translation_key="PAGINATION_INNER_PREVIOUS_ITEM") + + async def stop_button(self, ih: InteractionHandler, pid: str): + paginator = await self.get_paginator_for(pid, ih) + self.paginator_objects.delete_entry(pid) + await paginator.original_interaction.edit_original_message( + components=[], + embeds=[], + content=self.bot.get_localized_string( + "PAGINATION_INNER_QUEUE_EXPIRED", ih.interaction + ), + ) + await ih.send(translation_key="PAGINATION_INNER_QUEUE_CANCELLED") + + async def resolve_queued_suggestion( + self, + ih: InteractionHandler, + queued_suggestion: QueuedSuggestion, + *, + was_approved: bool, + ): + """Resolve a queued item, doing all the relevant actions""" + guild_id = ih.interaction.guild_id + suggestion: Suggestion | None = None + try: + guild_config: GuildConfig = await GuildConfig.from_id(guild_id, self.state) + + # If sent to channel queue, delete it + if not queued_suggestion.is_in_virtual_queue: + chan = await self.state.fetch_channel(queued_suggestion.channel_id) + msg = await chan.fetch_message(queued_suggestion.message_id) + await msg.delete() + + # Send the message to the relevant channel if required + if was_approved: + # Send this message through to the guilds suggestion channel + suggestion = await queued_suggestion.convert_to_suggestion( + self.bot.state + ) + icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) + guild = await self.state.fetch_guild(guild_id) + await suggestion.setup_initial_messages( + guild_config=guild_config, + interaction=ih.interaction, + state=self.state, + bot=self.bot, + cog=self.bot.get_cog("SuggestionsCog"), + guild=guild, + icon_url=icon_url, + comes_from_queue=True, + ) + # We dont send the user a message here because + # setup_initial_messages does this for us + else: + # We may need to send this rejected suggestion to a logs channel + if guild_config.queued_log_channel_id: + embed: disnake.Embed = await queued_suggestion.as_embed(self.bot) + channel: disnake.TextChannel = await self.bot.state.fetch_channel( + guild_config.queued_log_channel_id + ) + try: + await channel.send(embed=embed) + except disnake.Forbidden as e: + raise MissingQueueLogsChannel from e + + # message the user the outcome + user = await self.bot.state.fetch_user( + queued_suggestion.suggestion_author_id + ) + user_config: UserConfig = await UserConfig.from_id( + queued_suggestion.suggestion_author_id, self.bot.state + ) + icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) + guild = self.state.guild_cache.get_entry(guild_id) + if ( + user_config.dm_messages_disabled + or guild_config.dm_messages_disabled + ): + # Set up not to message users + return + + embed: disnake.Embed = disnake.Embed( + description=self.bot.get_localized_string( + "QUEUE_INNER_USER_REJECTED", ih + ), + colour=self.bot.colors.embed_color, + timestamp=self.state.now, + ) + embed.set_author( + name=guild.name, + icon_url=icon_url, + ) + embed.set_footer(text=f"Guild ID {guild_id}") + await user.send( + embeds=[embed, await queued_suggestion.as_embed(self.bot)] + ) + except: + # Don't remove from queue on failure + if suggestion is not None: + await self.bot.state.suggestions_db.delete(suggestion) + + if suggestion.message_id is not None: + channel: disnake.TextChannel = await self.state.fetch_channel( + suggestion.channel_id + ) + message = await channel.fetch_message(suggestion.message_id) + await message.delete() + + # Re-raise for the bot handler + raise + else: + queued_suggestion.resolved_by = ih.interaction.author.id + queued_suggestion.resolved_at = self.bot.state.now + queued_suggestion.still_in_queue = False + await self.bot.state.queued_suggestions_db.update( + queued_suggestion, queued_suggestion + ) + + @wrap_with_error_handler() + async def virtual_approve_button(self, ih: InteractionHandler, pid: str): + paginator = await self.get_paginator_for(pid, ih) + current_suggestion: QueuedSuggestion = ( + await paginator.get_current_queued_suggestion() + ) + await self.resolve_queued_suggestion( + ih, queued_suggestion=current_suggestion, was_approved=True + ) + await paginator.remove_current_page() + await ih.send(translation_key="PAGINATION_INNER_QUEUE_ACCEPTED") + + @wrap_with_error_handler() + async def virtual_reject_button(self, ih: InteractionHandler, pid: str): + paginator = await self.get_paginator_for(pid, ih) + current_suggestion = await paginator.get_current_queued_suggestion() + await self.resolve_queued_suggestion( + ih, queued_suggestion=current_suggestion, was_approved=False + ) + await paginator.remove_current_page() + await ih.send(translation_key="PAGINATION_INNER_QUEUE_REJECTED") + + async def info(self, ih: InteractionHandler): + guild_id = ih.interaction.guild_id + guild_config: GuildConfig = await GuildConfig.from_id(guild_id, self.state) + count: int = await self.queued_suggestions_db.count( + AQ(AND(EQ("guild_id", guild_id), EQ("still_in_queue", True))) + ) + icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) + guild = self.state.guild_cache.get_entry(guild_id) + embed = disnake.Embed( + title="Queue Info", + timestamp=self.bot.state.now, + description=f"`{count}` suggestions currently in queue.\n" + f"New suggestions will {'' if guild_config.uses_suggestion_queue else 'not'} be " + f"sent to the suggestions queue.", + colour=self.bot.colors.embed_color, + ) + embed.set_author( + name=guild.name, + icon_url=icon_url, + ) + await ih.send(embed=embed) + + async def view( + self, + ih: InteractionHandler, + previous_button, + next_button, + stop_button, + approve_button, + reject_button, + ): + """View this guilds suggestions queue.""" + guild_id = ih.interaction.guild_id + guild_config: GuildConfig = await GuildConfig.from_id(guild_id, self.state) + data: list = await self.queued_suggestions_db.find_many( + AQ( + AND( + EQ("guild_id", guild_id), + EQ("still_in_queue", True), + Negate(Exists("message_id")), + ) + ), + projections=Projection(SHOW("_id")), + try_convert=False, + ) + if not data: + return await ih.send(translation_key="QUEUE_VIEW_INNER_NOTHING_QUEUED") + + content = None + if not guild_config.uses_suggestion_queue: + content = self.bot.get_localized_string( + "QUEUE_VIEW_INNER_PRIOR_QUEUE", ih.interaction + ) + + paginator = QueuedSuggestionsPaginator( + bot=self.bot, data=[d["_id"] for d in data], inter=ih.interaction + ) + pid = self.bot.state.get_new_sq_paginator_id() + await ih.interaction.send( + content=content, + ephemeral=True, + embed=await paginator.format_page(), + components=[ + disnake.ui.ActionRow( + disnake.ui.Button( + emoji="\N{BLACK LEFT-POINTING TRIANGLE}\ufe0f", + custom_id=await previous_button.build_custom_id(pid=pid), + ), + disnake.ui.Button( + emoji="\N{BLACK SQUARE FOR STOP}\ufe0f", + custom_id=await stop_button.build_custom_id(pid=pid), + ), + disnake.ui.Button( + emoji="\N{BLACK RIGHT-POINTING TRIANGLE}\ufe0f", + custom_id=await next_button.build_custom_id(pid=pid), + ), + ), + disnake.ui.ActionRow( + disnake.ui.Button( + emoji=await self.bot.suggestion_emojis.default_up_vote(), + custom_id=await approve_button.build_custom_id(pid=pid), + ), + disnake.ui.Button( + emoji=await self.bot.suggestion_emojis.default_down_vote(), + custom_id=await reject_button.build_custom_id(pid=pid), + ), + ), + ], + ) + self.paginator_objects.add_entry(pid, paginator) + + async def accept_queued_suggestion( + self, ih: InteractionHandler, message_id: int, channel_id: int, button + ): + current_suggestion: QueuedSuggestion = await QueuedSuggestion.from_message_id( + message_id, channel_id, self.state + ) + if current_suggestion.is_resolved: + return await ih.send(translation_key="QUEUE_INNER_ALREADY_RESOLVED") + + # By here we need to do something about resolving it + suggestion: None = None + guild_id = ih.interaction.guild_id + try: + await paginator.remove_current_page() + suggestion: Suggestion = await current_suggestion.resolve( + was_approved=True, + state=self.bot.state, + resolved_by=ih.interaction.author.id, + ) + guild_config: GuildConfig = await GuildConfig.from_id(guild_id, self.state) + icon_url = await Guild.try_fetch_icon_url(guild_id, self.state) + guild = self.state.guild_cache.get_entry(guild_id) + await suggestion.setup_initial_messages( + guild_config=guild_config, + interaction=ih.interaction, + state=self.state, + bot=self.bot, + cog=self.bot.get_cog("SuggestionsCog"), + guild=guild, + icon_url=icon_url, + comes_from_queue=True, + ) + except: + # Throw it back in the queue on error + current_suggestion.resolved_by = None + current_suggestion.resolved_at = None + current_suggestion.still_in_queue = True + await self.bot.state.queued_suggestions_db.update( + current_suggestion, current_suggestion + ) + + if suggestion is not None: + await self.bot.state.suggestions_db.delete(suggestion) + + raise + + await ih.send(translation_key="PAGINATION_INNER_QUEUE_ACCEPTED") diff --git a/suggestions/exceptions.py b/suggestions/exceptions.py index ba3c1d4..96a85e5 100644 --- a/suggestions/exceptions.py +++ b/suggestions/exceptions.py @@ -19,6 +19,14 @@ class MissingLogsChannel(CheckFailure): """This command requires a logs channel to run.""" +class MissingQueueLogsChannel(CheckFailure): + """This command requires a queue logs channel to run.""" + + +class MissingPermissionsToAccessQueueChannel(disnake.DiscordException): + """The bot does not have permissions to interact with the queue channel.""" + + class ErrorHandled(disnake.DiscordException): """This tells error handlers the error was already handled, and can be ignored.""" @@ -54,6 +62,11 @@ class BlocklistedUser(CheckFailure): class PartialResponse(Exception): """A garven route returned a partial response when we require a full response""" + +class ConflictingHandlerInformation(disnake.DiscordException): + """Raised when an InteractionHandler class gets both content and a translation key""" + class InvalidFileType(disnake.DiscordException): """The file you attempted to upload is not allowed.""" + diff --git a/suggestions/interaction_handler.py b/suggestions/interaction_handler.py index 34bcea9..caabd86 100644 --- a/suggestions/interaction_handler.py +++ b/suggestions/interaction_handler.py @@ -1,6 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import cast, TYPE_CHECKING + +import disnake +from commons.caching import NonExistentEntry + +from suggestions.exceptions import ConflictingHandlerInformation if TYPE_CHECKING: from suggestions import SuggestionsBot @@ -16,9 +21,16 @@ class InteractionHandler: """ def __init__( - self, interaction: disnake.Interaction, ephemeral: bool, with_message: bool + self, + interaction: disnake.Interaction + | disnake.GuildCommandInteraction + | disnake.MessageInteraction, + ephemeral: bool, + with_message: bool, ): - self.interaction: disnake.Interaction = interaction + self.interaction: disnake.Interaction | disnake.GuildCommandInteraction | disnake.MessageInteraction = ( + interaction + ) self.ephemeral: bool = ephemeral self.with_message: bool = with_message self.is_deferred: bool = False @@ -28,6 +40,10 @@ def __init__( # errors if we haven't yet sent anything self.has_sent_something: bool = False + @property + def bot(self) -> SuggestionsBot: + return self.interaction.client # type: ignore + async def send( self, content: str | None = None, @@ -35,7 +51,14 @@ async def send( embed: disnake.Embed | None = None, file: disnake.File | None = None, components: list | None = None, + translation_key: str | None = None, ): + if translation_key is not None: + if content is not None: + raise ConflictingHandlerInformation + + content = self.bot.get_localized_string(translation_key, self.interaction) + data = {} if content is not None: data["content"] = content @@ -49,14 +72,14 @@ async def send( if not data: raise ValueError("Expected at-least one value to send.") - await self.interaction.send(ephemeral=self.ephemeral, **data) + value = await self.interaction.send(ephemeral=self.ephemeral, **data) self.has_sent_something = True + return value @classmethod async def new_handler( cls, interaction: disnake.Interaction, - bot: SuggestionsBot, *, ephemeral: bool = True, with_message: bool = True, @@ -68,13 +91,19 @@ async def new_handler( # Register this on the bot instance so other areas can # request the interaction handler, such as error handlers - bot.state.interaction_handlers.add_entry(interaction.application_id, instance) + bot = interaction.client + if TYPE_CHECKING: + bot = cast(SuggestionsBot, bot) + bot.state.interaction_handlers.add_entry(interaction.id, instance) return instance @classmethod async def fetch_handler( cls, application_id: int, bot: SuggestionsBot - ) -> InteractionHandler: + ) -> InteractionHandler | None: """Fetch a registered handler for the given interaction.""" - return bot.state.interaction_handlers.get_entry(application_id) + try: + return bot.state.interaction_handlers.get_entry(application_id) + except NonExistentEntry: + return None diff --git a/suggestions/locales/en_GB.json b/suggestions/locales/en_GB.json index 401c717..ad9470c 100644 --- a/suggestions/locales/en_GB.json +++ b/suggestions/locales/en_GB.json @@ -107,7 +107,7 @@ "PAGINATION_INNER_QUEUE_REJECTED": "I have removed that suggestion from the queue.", "QUEUE_VIEW_INNER_NOTHING_QUEUED": "Your guild has no suggestions in the queue.", "QUEUE_VIEW_INNER_PRIOR_QUEUE": "These suggestions were queued before your guild disabled the suggestions queue.", - "QUEUE_INNER_USER_REJECTED": "Your queued suggestion was rejected.", + "QUEUE_INNER_USER_REJECTED": "Your queued suggestion was rejected. For your reference, I have included the rejected suggestion below.", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_SET": "can", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_NOT_SET": "cannot", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_MESSAGE": "This guild {} have images in suggestions.", diff --git a/suggestions/locales/en_US.json b/suggestions/locales/en_US.json index 4d3cbc5..52b457e 100644 --- a/suggestions/locales/en_US.json +++ b/suggestions/locales/en_US.json @@ -62,9 +62,16 @@ "SUGGESTION_OBJECT_LOCK_THREAD": "Locking this thread as the suggestion has reached a resolution.", "CONFIG_CHANNEL_INNER_MESSAGE": "I have set this guilds suggestion channel to {}", "CONFIG_LOGS_INNER_MESSAGE": "I have set this guilds log channel to {}", + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE": "I have set this guilds suggestion queue channel to $CHANNEL", + "CONFIG_QUEUE_LOG_CHANNEL_INNER_MESSAGE": "I have set this guilds suggestion queue channel to {}", + "CONFIG_QUEUE_CHANNEL_INNER_MESSAGE_REMOVED": "I have disabled sending rejected queue suggestions to a channel.", "CONFIG_GET_INNER_BASE_EMBED_DESCRIPTION": "Configuration for {}\n", "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_SET": "Log channel: <#{}>", "CONFIG_GET_INNER_PARTIAL_LOG_CHANNEL_NOT_SET": "Not set", + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_SET": "Queue channel: <#{}>", + "CONFIG_GET_INNER_PARTIAL_QUEUE_LOG_CHANNEL_NOT_SET": "Not set", + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_SET": "Queue rejection channel: <#{}>", + "CONFIG_GET_INNER_PARTIAL_QUEUE_REJECTION_LOG_CHANNEL_NOT_SET": "Not set", "CONFIG_GET_INNER_PARTIAL_SUGGESTION_CHANNEL_SET": "Suggestion channel: <#{}>", "CONFIG_GET_INNER_PARTIAL_SUGGESTION_CHANNEL_NOT_SET": "Not set", "CONFIG_GET_INNER_PARTIAL_DM_RESPONSES_SET": "will", @@ -78,6 +85,8 @@ "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_SET": "can", "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_NOT_SET": "cannot", "CONFIG_GET_INNER_ANONYMOUS_SUGGESTIONS_MESSAGE": "This guild {} have anonymous suggestions.", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_SET": "This guild uses a channel based suggestions queue.", + "CONFIG_GET_INNER_USES_PHYSICAL_QUEUE_NOT_SET": "This guild uses a virtual suggestions queue.", "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_SET": "will", "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_NOT_SET": "will not", "CONFIG_GET_INNER_AUTO_ARCHIVE_THREADS_MESSAGE": "I {} automatically archive threads created for suggestions.", @@ -105,9 +114,10 @@ "PAGINATION_INNER_QUEUE_CANCELLED": "I have cancelled this queue for you.", "PAGINATION_INNER_QUEUE_ACCEPTED": "I have accepted that suggestion from the queue.", "PAGINATION_INNER_QUEUE_REJECTED": "I have removed that suggestion from the queue.", - "QUEUE_VIEW_INNER_NOTHING_QUEUED": "Your guild has no suggestions in the queue.", + "QUEUE_VIEW_INNER_NOTHING_QUEUED": "Your guild has no suggestions in the virtual queue.", "QUEUE_VIEW_INNER_PRIOR_QUEUE": "These suggestions were queued before your guild disabled the suggestions queue.", - "QUEUE_INNER_USER_REJECTED": "Your queued suggestion was rejected.", + "QUEUE_INNER_USER_REJECTED": "Your queued suggestion was rejected. For your reference, I have included the rejected suggestion below.", + "QUEUE_INNER_ALREADY_RESOLVED": "This queued suggestion has already been resolved.", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_SET": "can", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_NOT_SET": "cannot", "CONFIG_GET_INNER_IMAGES_IN_SUGGESTIONS_MESSAGE": "This guild {} have images in suggestions.", @@ -121,5 +131,7 @@ "SUGGESTION_ID_NAME": "suggestion_id", "SUGGESTION_ID_DESCRIPTION": "The suggestions ID you wish to reference.", "USER_ID_NAME": "user_id", - "USER_ID_DESCRIPTION": "The users discord id." + "USER_ID_DESCRIPTION": "The users discord id.", + "CONFIG_USE_PHYSICAL_QUEUE_ENABLE_INNER_MESSAGE": "If the suggestions queue is enabled, all new suggestions will go to a channel queue.", + "CONFIG_USE_PHYSICAL_QUEUE_DISABLE_INNER_MESSAGE": "If the suggestions queue is enabled, all new suggestions will go to a virtual queue." } diff --git a/suggestions/objects/guild_config.py b/suggestions/objects/guild_config.py index 1f1b803..027596e 100644 --- a/suggestions/objects/guild_config.py +++ b/suggestions/objects/guild_config.py @@ -20,11 +20,14 @@ def __init__( keep_logs: bool = False, dm_messages_disabled: bool = False, log_channel_id: Optional[int] = None, + queued_channel_id: Optional[int] = None, + queued_log_channel_id: Optional[int] = None, threads_for_suggestions: bool = True, suggestions_channel_id: Optional[int] = None, can_have_anonymous_suggestions: bool = False, auto_archive_threads: bool = False, uses_suggestion_queue: bool = False, + virtual_suggestion_queue: bool = True, can_have_images_in_suggestions: bool = True, anonymous_resolutions: bool = False, blocked_users: Optional[list[int]] = None, @@ -33,10 +36,13 @@ def __init__( self._id: int = _id self.keep_logs: bool = keep_logs self.log_channel_id: Optional[int] = log_channel_id + self.queued_channel_id: Optional[int] = queued_channel_id + self.queued_log_channel_id: Optional[int] = queued_log_channel_id self.auto_archive_threads: bool = auto_archive_threads self.dm_messages_disabled: bool = dm_messages_disabled self.anonymous_resolutions: bool = anonymous_resolutions self.uses_suggestion_queue: bool = uses_suggestion_queue + self.virtual_suggestion_queue: bool = virtual_suggestion_queue self.threads_for_suggestions: bool = threads_for_suggestions self.suggestions_channel_id: Optional[int] = suggestions_channel_id self.can_have_anonymous_suggestions: bool = can_have_anonymous_suggestions @@ -89,11 +95,14 @@ def as_dict(self) -> Dict: "keep_logs": self.keep_logs, "blocked_users": list(self.blocked_users), "log_channel_id": self.log_channel_id, + "queued_channel_id": self.queued_channel_id, + "queued_log_channel_id": self.queued_log_channel_id, "auto_archive_threads": self.auto_archive_threads, "dm_messages_disabled": self.dm_messages_disabled, "suggestions_channel_id": self.suggestions_channel_id, "uses_suggestion_queue": self.uses_suggestion_queue, "anonymous_resolutions": self.anonymous_resolutions, + "virtual_suggestion_queue": self.virtual_suggestion_queue, "threads_for_suggestions": self.threads_for_suggestions, "can_have_anonymous_suggestions": self.can_have_anonymous_suggestions, "can_have_images_in_suggestions": self.can_have_images_in_suggestions, diff --git a/suggestions/objects/queued_suggestion.py b/suggestions/objects/queued_suggestion.py index 1006138..67212a6 100644 --- a/suggestions/objects/queued_suggestion.py +++ b/suggestions/objects/queued_suggestion.py @@ -4,9 +4,12 @@ import logging from typing import Optional, TYPE_CHECKING, overload +from alaric import AQ +from alaric.comparison import EQ +from alaric.logical import AND from disnake import Embed -from suggestions.exceptions import UnhandledError +from suggestions.exceptions import UnhandledError, SuggestionNotFound from suggestions.objects import Suggestion if TYPE_CHECKING: @@ -31,13 +34,17 @@ def __init__( resolution_note: Optional[str] = None, resolved_at: Optional[datetime.datetime] = None, related_suggestion_id: Optional[str] = None, + message_id: Optional[int] = None, + channel_id: Optional[int] = None, ): self._id: str = _id self.guild_id: int = guild_id self.suggestion: str = suggestion self.is_anonymous: bool = is_anonymous - self.still_in_queue: bool = still_in_queue self.image_url: Optional[str] = image_url + self.still_in_queue: bool = still_in_queue + self.channel_id: Optional[int] = channel_id + self.message_id: Optional[int] = message_id self.resolved_by: Optional[int] = resolved_by self.created_at: datetime.datetime = created_at self.suggestion_author_id: int = suggestion_author_id @@ -49,6 +56,56 @@ def __init__( # this field will be the id of the created suggestion self.related_suggestion_id: Optional[str] = related_suggestion_id + @property + def is_resolved(self) -> bool: + return self.resolved_by is not None + + @property + def is_in_virtual_queue(self) -> bool: + return self.message_id is None + + @classmethod + async def from_message_id( + cls, message_id: int, channel_id: int, state: State + ) -> QueuedSuggestion: + """Return a suggestion from its sent message. + + Useful for message commands. + + Parameters + ---------- + message_id : int + The message id + channel_id : int + The channel id + state : State + Our internal state + + Returns + ------- + QueuedSuggestion + The found suggestion + + Raises + ------ + SuggestionNotFound + No suggestion exists for this data + """ + suggestion: QueuedSuggestion | None = await state.queued_suggestions_db.find( + AQ( + AND( + EQ("message_id", message_id), + EQ("channel_id", channel_id), + ) + ) + ) + if not suggestion: + raise SuggestionNotFound( + f"This message does not look like a suggestions message." + ) + + return suggestion + @classmethod async def new( cls, @@ -91,7 +148,9 @@ async def new( is_anonymous=is_anonymous, ) await state.queued_suggestions_db.insert(suggestion) - return suggestion + + # Try to populate id on returned object + return await state.queued_suggestions_db.find(suggestion.as_dict()) def as_filter(self) -> dict: if not self._id: @@ -127,6 +186,10 @@ def as_dict(self) -> dict: if self.related_suggestion_id: data["related_suggestion_id"] = self.related_suggestion_id + if self.message_id is not None: + data["message_id"] = self.message_id + data["channel_id"] = self.channel_id + return data async def as_embed(self, bot: SuggestionsBot) -> Embed: @@ -144,7 +207,9 @@ async def as_embed(self, bot: SuggestionsBot) -> Embed: ) if not self.is_anonymous: embed.set_thumbnail(user.display_avatar) - embed.set_footer(text=f"Submitter ID: {self.suggestion_author_id}") + embed.set_footer( + text=f"Queued suggestion | Submitter ID: {self.suggestion_author_id}" + ) if self.image_url: embed.set_image(self.image_url) diff --git a/suggestions/state.py b/suggestions/state.py index 02c2dc1..cd24e41 100644 --- a/suggestions/state.py +++ b/suggestions/state.py @@ -6,7 +6,7 @@ import random import string from datetime import timedelta -from typing import TYPE_CHECKING, List, Dict, Set +from typing import TYPE_CHECKING, List, Dict, Set, Any import disnake from alaric import AQ @@ -15,6 +15,8 @@ from alaric.meta import Negate from alaric.projections import PROJECTION, SHOW from commons.caching import NonExistentEntry, TimedCache +from disnake import Thread +from disnake.abc import GuildChannel, PrivateChannel from suggestions.objects import GuildConfig, UserConfig @@ -51,6 +53,9 @@ def __init__(self, database: SuggestionsMongoManager, bot: SuggestionsBot): lazy_eviction=False, ttl_from_last_access=True, ) + self.object_cache: TimedCache[int, Any] = TimedCache( + global_ttl=timedelta(hours=1), lazy_eviction=False + ) self.guild_configs: TimedCache = TimedCache( global_ttl=timedelta(minutes=30), @@ -249,6 +254,31 @@ async def load(self): for entry in error_ids: self.existing_error_ids.add(entry["_id"]) + async def fetch_channel(self, channel_id: int) -> disnake.TextChannel: + try: + return self.object_cache.get_entry(channel_id) + except NonExistentEntry: + chan = await self.bot.fetch_channel(channel_id) + self.object_cache.add_entry(channel_id, chan) + return chan # type: ignore + + async def fetch_user(self, user_id: int) -> disnake.User: + try: + return self.object_cache.get_entry(user_id) + except NonExistentEntry: + user = await self.bot.fetch_user(user_id) + self.object_cache.add_entry(user_id, user) + return user + + async def fetch_guild(self, guild_id: int) -> disnake.Guild: + # Need guild cache instead of object as used else where + try: + return self.guild_cache.get_entry(guild_id) + except NonExistentEntry: + guild = await self.bot.fetch_guild(guild_id) + self.guild_cache.add_entry(guild_id, guild) + return guild + async def evict_caches(self): """Cleans the caches every 10 minutes""" while not self.is_closing: diff --git a/suggestions/stats.py b/suggestions/stats.py index 28ab7be..b3c5d75 100644 --- a/suggestions/stats.py +++ b/suggestions/stats.py @@ -31,6 +31,8 @@ class StatsEnum(Enum): MEMBER_DM_ENABLE = "member_dm_enable" MEMBER_DM_DISABLE = "member_dm_disable" GUILD_CONFIG_LOG_CHANNEL = "guild_config_log_channel" + GUILD_CONFIG_QUEUE_CHANNEL = "guild_config_queue_channel" + GUILD_CONFIG_REJECTED_QUEUE_CHANNEL = "guild_config_rejected_queue_channel" GUILD_CONFIG_SUGGEST_CHANNEL = "guild_config_suggest_channel" GUILD_CONFIG_GET = "guild_config_get" GUILD_DM_ENABLE = "guild_dm_enable" @@ -45,6 +47,8 @@ class StatsEnum(Enum): GUILD_ANONYMOUS_RESOLUTIONS_DISABLE = "guild_anonymous_resolutions_disable" GUILD_IMAGES_IN_SUGGESTIONS_ENABLE = "guild_images_in_suggestions_enable" GUILD_IMAGES_IN_SUGGESTIONS_DISABLE = "guild_images_in_suggestions_disable" + GUILD_PHYSICAL_QUEUE_ENABLE = "guild_physical_queue_enable" + GUILD_PHYSICAL_QUEUE_DISABLE = "guild_physical_queue_disable" GUILD_KEEPLOGS_ENABLE = "guild_keeplogs_enable" GUILD_KEEPLOGS_DISABLE = "guild_keeplogs_disable" GUILD_ANONYMOUS_ENABLE = "guild_anonymous_enable" diff --git a/tests/conftest.py b/tests/conftest.py index 8c627f7..c95f42e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,7 +16,7 @@ async def mocked_database() -> MockedSuggestionsMongoManager: @pytest.fixture -async def bot(monkeypatch): +async def bot(monkeypatch, mocked_database): if "./suggestions" not in [x[0] for x in os.walk(".")]: monkeypatch.chdir("..") @@ -48,3 +48,7 @@ async def injection_metadata(causar: Causar) -> InjectionMetadata: guild_id=881118111967883295, channel_id=causar.faker.generate_snowflake() ) + +@pytest.fixture +async def interaction_handler(bot) -> InteractionHandler: + return InteractionHandler(AsyncMock(), True, True) diff --git a/tests/mocks/database.py b/tests/mocks/database.py index 7336a03..f47527e 100644 --- a/tests/mocks/database.py +++ b/tests/mocks/database.py @@ -13,7 +13,7 @@ class MockedSuggestionsMongoManager: def __init__(self): - self.database_name = "suggestions-rewrite" + self.database_name = "suggestions-rewrite-testing" self.__mongo = AsyncMongoMockClient() self.db = self.__mongo[self.database_name] diff --git a/tests/test_interaction_handler.py b/tests/test_interaction_handler.py index 681b077..e24a515 100644 --- a/tests/test_interaction_handler.py +++ b/tests/test_interaction_handler.py @@ -1,8 +1,10 @@ -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, call import pytest -from bot_base import NonExistentEntry +from commons.caching import NonExistentEntry +from suggestions import SuggestionsBot +from suggestions.exceptions import ConflictingHandlerInformation from suggestions.interaction_handler import InteractionHandler @@ -14,47 +16,61 @@ async def test_send(interaction_handler: InteractionHandler): await interaction_handler.send("Hello world") assert interaction_handler.has_sent_something is True - assert interaction_handler.interaction.send.assert_called_with( - content="Hello world", ephemeral=True - ) + assert interaction_handler.interaction.send.call_count == 1 + assert interaction_handler.interaction.send.mock_calls == [ + call.send(ephemeral=True, content="Hello world") + ] interaction_handler.interaction = AsyncMock() await interaction_handler.send( "Hello world", embed="Embed", file="File", components=["Test"] ) - assert interaction_handler.interaction.send.assert_called_with( - content="Hello world", - ephemeral=True, - embed="Embed", - file="File", - components=["Test"], - ) + assert interaction_handler.interaction.send.call_count == 1 + assert interaction_handler.interaction.send.mock_calls == [ + call.send( + content="Hello world", + ephemeral=True, + embed="Embed", + file="File", + components=["Test"], + ), + ] -async def test_new_handler(bot): +async def test_new_handler(bot: SuggestionsBot): + mock = AsyncMock() + mock.client = bot + mock.id = 1 assert bot.state.interaction_handlers.cache == {} - handler: InteractionHandler = await InteractionHandler.new_handler(AsyncMock(), bot) + handler: InteractionHandler = await InteractionHandler.new_handler(mock) assert bot.state.interaction_handlers.cache != {} assert handler.has_sent_something is False assert handler.is_deferred is True + mock.id = 2 handler_2: InteractionHandler = await InteractionHandler.new_handler( - AsyncMock(), bot, ephemeral=False, with_message=False + mock, ephemeral=False, with_message=False ) assert handler_2.ephemeral is False assert handler_2.with_message is False -async def test_fetch_handler(bot): +async def test_fetch_handler(bot: SuggestionsBot): application_id = 123456789 - with pytest.raises(NonExistentEntry): - await InteractionHandler.fetch_handler(application_id, bot) + r_1 = await InteractionHandler.fetch_handler(application_id, bot) + assert r_1 is None mock = AsyncMock() - mock.application_id = application_id - await InteractionHandler.new_handler(mock, bot, with_message=False) + mock.client = bot + mock.id = application_id + await InteractionHandler.new_handler(mock, with_message=False) handler: InteractionHandler = await InteractionHandler.fetch_handler( application_id, bot ) assert handler.with_message is False assert handler.ephemeral is True + + +async def test_dual_raises(interaction_handler: InteractionHandler): + with pytest.raises(ConflictingHandlerInformation): + await interaction_handler.send("Test", translation_key="Blah") From e083d20492d7c8c258f5d5664f029de1339cd794 Mon Sep 17 00:00:00 2001 From: Ethan <47520067+Skelmis@users.noreply.github.com> Date: Thu, 7 Mar 2024 22:53:17 +1300 Subject: [PATCH 09/11] feat: move to logoo logging (#80) * feat: start logoo integration * chore: remove un-needed metadata * feat: migrate to Logoo * chore: shush httpx logs --- main.py | 16 +++ requirements.txt | 1 + suggestions/bot.py | 34 ++++- suggestions/clunk2/edits.py | 28 +++- suggestions/cogs/blacklist_cog.py | 27 +++- suggestions/cogs/guild_config_cog.py | 46 ++++-- suggestions/cogs/help_guild_cog.py | 4 +- suggestions/cogs/suggestion_cog.py | 100 +++++++------ suggestions/cogs/suggestion_queue_cog.py | 20 +-- .../cogs/suggestions_message_commands.py | 18 ++- suggestions/cogs/user_config_cog.py | 22 ++- suggestions/cogs/view_voters_cog.py | 7 +- suggestions/garven.py | 4 +- suggestions/objects/guild_config.py | 16 ++- suggestions/objects/queued_suggestion.py | 8 +- suggestions/objects/stats/member_stats.py | 7 +- suggestions/objects/suggestion.py | 133 +++++++++++++++--- suggestions/objects/user_config.py | 19 ++- suggestions/qs_paginator.py | 12 +- suggestions/scheduler.py | 5 +- suggestions/state.py | 35 +++-- suggestions/utility/r2.py | 16 ++- 22 files changed, 432 insertions(+), 146 deletions(-) diff --git a/main.py b/main.py index bfbe81b..06ce9eb 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ import alaric from alaric import Cursor from dotenv import load_dotenv +from logoo import PrimaryLogger import suggestions @@ -29,6 +30,9 @@ shard_logger = logging.getLogger("disnake.shard") shard_logger.setLevel(logging.WARNING) +httpx_logger = logging.getLogger("httpx") +httpx_logger.setLevel(logging.WARNING) + suggestions_logger = logging.getLogger("suggestions") suggestions_logger.setLevel(logging.DEBUG) member_stats_logger = logging.getLogger("suggestions.objects.stats.member_stats") @@ -40,6 +44,18 @@ async def run_bot(): log = logging.getLogger(__name__) bot = await suggestions.create_bot() + logger: PrimaryLogger = PrimaryLogger( + __name__, + base_url="https://logs.suggestions.gg", + org="default", + stream="prod_bot" if bot.is_prod else "test_bot", + username=os.environ["LOGOO_USER"], + password=os.environ["LOGOO_PASSWORD"], + poll_time=15, + global_metadata={"cluster": bot.cluster_id}, + ) + await logger.start_consumer() + # Make sure we don't shutdown due to a previous shutdown request cursor: Cursor = ( Cursor.from_document(bot.db.cluster_shutdown_requests) diff --git a/requirements.txt b/requirements.txt index 950813e..ce587aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,3 +53,4 @@ yarl==1.7.2 zonis==1.2.5 types-aiobotocore==2.11.2 aiobotocore==2.11.2 +logoo==1.1.0 \ No newline at end of file diff --git a/suggestions/bot.py b/suggestions/bot.py index ea87a85..6590be3 100644 --- a/suggestions/bot.py +++ b/suggestions/bot.py @@ -18,9 +18,10 @@ from alaric import Cursor from bot_base.wraps import WrappedChannel from cooldowns import CallableOnCooldown -from disnake import Locale, LocalizationKeyError, GatewayParams +from disnake import Locale, LocalizationKeyError from disnake.ext import commands from bot_base import BotBase, BotContext, PrefixNotFound +from logoo import Logger from suggestions import State, Colors, Emojis, ErrorCode, Garven from suggestions.exceptions import ( @@ -48,6 +49,7 @@ from suggestions.zonis_routes import ZonisRoutes log = logging.getLogger(__name__) +logger = Logger(__name__) class SuggestionsBot(commands.AutoShardedInteractionBot, BotBase): @@ -189,7 +191,7 @@ def error_embed( text=f"Error code {error_code.value} | Cluster ID {self.cluster_id}" ) - log.debug("Encountered %s", error_code.name) + logger.debug("Encountered %s", error_code.name) elif error: embed.set_footer(text=f"Error ID {error.id}") @@ -529,6 +531,14 @@ async def on_slash_command_error( elif isinstance(exception, disnake.NotFound): log.debug("disnake.NotFound: %s", exception.text) + logger.debug( + "disnake.NotFound: %s", + exception.text, + extra_metadata={ + "guild_id": interaction.guild_id, + "author_id": interaction.author.id, + }, + ) gid = interaction.guild_id if interaction.guild_id else None await interaction.send( embed=self.error_embed( @@ -544,6 +554,14 @@ async def on_slash_command_error( elif isinstance(exception, disnake.Forbidden): log.debug("disnake.Forbidden: %s", exception.text) + logger.debug( + "disnake.Forbidden: %s", + exception.text, + extra_metadata={ + "guild_id": interaction.guild_id, + "author_id": interaction.author.id, + }, + ) await interaction.send( embed=self.error_embed( exception.text, @@ -573,6 +591,9 @@ async def on_slash_command_error( log.debug( "disnake.HTTPException: Interaction has already been acknowledged" ) + logger.debug( + "disnake.HTTPException: Interaction has already been acknowledged" + ) return ih: InteractionHandler = await InteractionHandler.fetch_handler( @@ -793,7 +814,7 @@ async def process_update_bot_listings(): json=body, ) as r: if r.status != 200: - log.warning("%s", r.text) + logger.warning("%s", r.text) log.debug("Updated bot listings") await self.sleep_with_condition( @@ -939,6 +960,9 @@ async def inner(): await self.sleep_with_condition(60, lambda: self.state.is_closing) except (aiohttp.ClientConnectorError, ConnectionRefusedError): log.warning("push_status failed to connect, retrying in 10 seconds") + logger.warning( + "push_status failed to connect, retrying in 10 seconds" + ) await self.sleep_with_condition(10, lambda: self.state.is_closing) except Exception as e: if not self.is_prod: @@ -950,6 +974,10 @@ async def inner(): "Status update failed: %s", tb, ) + logger.error( + "Status update failed: %s", + tb, + ) await self.garven.notify_devs( title="Status page ping error", description=tb, diff --git a/suggestions/clunk2/edits.py b/suggestions/clunk2/edits.py index 0a35be2..e172b69 100644 --- a/suggestions/clunk2/edits.py +++ b/suggestions/clunk2/edits.py @@ -1,10 +1,10 @@ from __future__ import annotations import asyncio -import logging from typing import TYPE_CHECKING import disnake +from logoo import Logger from suggestions.low_level import MessageEditing from suggestions.objects import Suggestion @@ -12,7 +12,7 @@ if TYPE_CHECKING: from suggestions import SuggestionsBot -log = logging.getLogger(__name__) +logger = Logger(__name__) pending_edits: set[str] = set() @@ -24,17 +24,28 @@ async def update_suggestion_message( time_after: float = 10, ): if suggestion.suggestion_id in pending_edits: - log.debug("Ignoring already existing item %s", suggestion.suggestion_id) + logger.debug( + "Ignoring already existing item %s", + suggestion.suggestion_id, + extra_metadata={ + "guild_id": suggestion.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, + ) return pending_edits.add(suggestion.suggestion_id) await asyncio.sleep(time_after) if suggestion.channel_id is None or suggestion.message_id is None: - log.debug( + logger.debug( "Suggestion %s had a NoneType by the time it was to be edited channel_id=%s, message_id=%s", suggestion.suggestion_id, suggestion.channel_id, suggestion.message_id, + extra_metadata={ + "guild_id": suggestion.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, ) pending_edits.discard(suggestion.suggestion_id) return @@ -50,6 +61,13 @@ async def update_suggestion_message( message_id=up_to_date_suggestion.message_id, ).edit(embed=await up_to_date_suggestion.as_embed(bot)) except (disnake.HTTPException, disnake.NotFound): - log.error("Failed to update suggestion %s", suggestion.suggestion_id) + logger.error( + "Failed to update suggestion %s", + suggestion.suggestion_id, + extra_metadata={ + "guild_id": suggestion.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, + ) pending_edits.discard(suggestion.suggestion_id) diff --git a/suggestions/cogs/blacklist_cog.py b/suggestions/cogs/blacklist_cog.py index 8c7c408..70ef7f6 100644 --- a/suggestions/cogs/blacklist_cog.py +++ b/suggestions/cogs/blacklist_cog.py @@ -1,18 +1,18 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING import disnake from commons.caching import NonExistentEntry from disnake.ext import commands +from logoo import Logger from suggestions.objects import GuildConfig, Suggestion if TYPE_CHECKING: from suggestions import State, SuggestionsBot -log = logging.getLogger(__name__) +logger = Logger(__name__) class BlacklistCog(commands.Cog): @@ -61,6 +61,16 @@ async def add( "They will be unable to create suggestions in the future.", ephemeral=True, ) + logger.debug( + "User %s added %s to the blocklist for guild %s", + interaction.author.id, + suggestion.suggestion_author_id, + interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, + ) @blocklist.sub_command() async def remove( @@ -102,6 +112,16 @@ async def remove( guild_config.blocked_users.discard(user_id) await self.bot.db.guild_configs.upsert(guild_config, guild_config) await interaction.send("I have un-blocklisted that user for you.") + logger.debug( + "User %s removed %s from the blocklist for guild %s", + interaction.author.id, + user_id, + interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, + ) @add.autocomplete("suggestion_id") @remove.autocomplete("suggestion_id") @@ -120,9 +140,10 @@ async def get_sid_for( ) else: if not values: - log.debug( + logger.debug( "Values was found, but empty in guild %s thus populating", interaction.guild_id, + extra_metadata={"guild_id": interaction.guild_id}, ) values: list[str] = await self.state.populate_sid_cache( interaction.guild_id diff --git a/suggestions/cogs/guild_config_cog.py b/suggestions/cogs/guild_config_cog.py index 7b90c55..4bc122f 100644 --- a/suggestions/cogs/guild_config_cog.py +++ b/suggestions/cogs/guild_config_cog.py @@ -1,12 +1,12 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING import cooldowns import disnake from disnake import Guild from disnake.ext import commands +from logoo import Logger from suggestions import Stats from suggestions.cooldown_bucket import InteractionBucket @@ -18,7 +18,7 @@ if TYPE_CHECKING: from suggestions import SuggestionsBot, State -log = logging.getLogger(__name__) +logger = Logger(__name__) class GuildConfigCog(commands.Cog): @@ -55,11 +55,15 @@ async def channel( ).format(channel.mention), ephemeral=True, ) - log.debug( + logger.debug( "User %s changed suggestions channel to %s in guild %s", interaction.author.id, channel.id, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -86,11 +90,15 @@ async def logs( ), ephemeral=True, ) - log.debug( + logger.debug( "User %s changed logs channel to %s in guild %s", interaction.author.id, channel.id, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -128,11 +136,15 @@ async def queue_channel( extras={"CHANNEL": channel.mention}, ) ) - log.debug( + logger.debug( "User %s changed physical queue channel to %s in guild %s", interaction.author.id, channel.id, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -163,11 +175,15 @@ async def queue_log_channel( if channel is not None: msg = msg.format(channel.mention) await ih.send(msg) - log.debug( + logger.debug( "User %s changed rejected queue log channel to %s in guild %s", interaction.author.id, channel.id if channel is not None else None, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -388,11 +404,15 @@ async def get( raise InvalidGuildConfigOption await interaction.send(embed=embed, ephemeral=True) - log.debug( + logger.debug( "User %s viewed the %s config in guild %s", interaction.author.id, config, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -546,10 +566,14 @@ async def send_full_config(self, interaction: disnake.GuildCommandInteraction): timestamp=self.bot.state.now, ).set_author(name=guild.name, icon_url=icon_url) await interaction.send(embed=embed, ephemeral=True) - log.debug( + logger.debug( "User %s viewed the global config in guild %s", interaction.author.id, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -879,9 +903,13 @@ async def modify_guild_config( user_message, ephemeral=True, ) - log.debug( + logger.debug( log_message, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, diff --git a/suggestions/cogs/help_guild_cog.py b/suggestions/cogs/help_guild_cog.py index a65bba6..e2c89a7 100644 --- a/suggestions/cogs/help_guild_cog.py +++ b/suggestions/cogs/help_guild_cog.py @@ -2,7 +2,6 @@ import datetime import io -import logging from typing import TYPE_CHECKING, Optional import disnake @@ -11,6 +10,7 @@ from disnake.ext import commands from disnake.utils import format_dt from humanize import naturaldate +from logoo import Logger from suggestions import ErrorCode from suggestions.objects import Error @@ -18,7 +18,7 @@ if TYPE_CHECKING: from suggestions import SuggestionsBot, State -log = logging.getLogger(__name__) +logger = Logger(__name__) class HelpGuildCog(commands.Cog): diff --git a/suggestions/cogs/suggestion_cog.py b/suggestions/cogs/suggestion_cog.py index d2b38dd..98ab47c 100644 --- a/suggestions/cogs/suggestion_cog.py +++ b/suggestions/cogs/suggestion_cog.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING, Optional import cooldowns @@ -9,6 +8,7 @@ from bot_base.wraps import WrappedChannel from disnake import Guild, ButtonStyle from disnake.ext import commands, components +from logoo import Logger from suggestions import checks, Stats from suggestions.clunk2 import update_suggestion_message @@ -30,7 +30,7 @@ from alaric import Document from suggestions import SuggestionsBot, State -log = logging.getLogger(__name__) +logger = Logger(__name__) class SuggestionsCog(commands.Cog): @@ -82,11 +82,10 @@ async def suggestion_up_vote( ), ephemeral=True, ) - # log.debug( - # "Member %s modified their vote on %s to an up vote", - # member_id, - # suggestion_id, - # ) + logger.debug( + f"Member {member_id} modified their vote on {suggestion_id} to a up vote", + extra_metadata={"suggestion_id": suggestion_id}, + ) else: suggestion.up_voted_by.add(member_id) await self.state.suggestions_db.upsert(suggestion, suggestion) @@ -97,6 +96,10 @@ async def suggestion_up_vote( ), ephemeral=True, ) + logger.debug( + f"Member {member_id} up voted {suggestion_id}", + extra_metadata={"suggestion_id": suggestion_id}, + ) await update_suggestion_message(suggestion=suggestion, bot=self.bot) @@ -141,11 +144,10 @@ async def suggestion_down_vote( ), ephemeral=True, ) - # log.debug( - # "Member %s modified their vote on %s to a down vote", - # member_id, - # suggestion_id, - # ) + logger.debug( + f"Member {member_id} modified their vote on {suggestion_id} to a down vote", + extra_metadata={"suggestion_id": suggestion_id}, + ) else: suggestion.down_voted_by.add(member_id) await self.state.suggestions_db.upsert(suggestion, suggestion) @@ -156,9 +158,12 @@ async def suggestion_down_vote( ), ephemeral=True, ) + logger.debug( + f"Member {member_id} down voted {suggestion_id}", + extra_metadata={"suggestion_id": suggestion_id}, + ) await update_suggestion_message(suggestion=suggestion, bot=self.bot) - # log.debug("Member %s down voted suggestion %s", member_id, suggestion_id) @components.button_listener() async def queue_approve(self, inter: disnake.MessageInteraction): @@ -280,10 +285,13 @@ async def suggest( qs.channel_id = msg.channel.id await self.bot.db.queued_suggestions.upsert(qs, qs) - log.debug( - "User %s created new queued suggestion in guild %s", - interaction.author.id, - interaction.guild_id, + logger.debug( + f"User {interaction.author.id} created new queued" + f" suggestion in guild {interaction.guild_id}", + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + }, ) return await interaction.send( ephemeral=True, @@ -314,11 +322,14 @@ async def suggest( icon_url=icon_url, ) - log.debug( - "User %s created new suggestion %s in guild %s", - interaction.author.id, - suggestion.suggestion_id, - interaction.guild_id, + logger.debug( + f"User {interaction.author.id} created new suggestion " + f"{suggestion.suggestion_id} in guild {interaction.guild_id}", + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -370,11 +381,14 @@ async def approve( ), ephemeral=True, ) - log.debug( - "User %s approved suggestion %s in guild %s", - interaction.author.id, - suggestion.suggestion_id, - interaction.guild_id, + logger.debug( + f"User {interaction.author.id} approved suggestion " + f"{suggestion.suggestion_id} in guild {interaction.guild_id}", + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -430,11 +444,14 @@ async def reject( ), ephemeral=True, ) - log.debug( - "User %s rejected suggestion %s in guild %s", - interaction.author.id, - suggestion.suggestion_id, - interaction.guild_id, + logger.debug( + f"User {interaction.author} rejected suggestion {suggestion.suggestion_id} " + f"in guild {interaction.guild_id}", + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -492,11 +509,14 @@ async def clear( ), ephemeral=True, ) - log.debug( - "User %s cleared suggestion %s in guild %s", - interaction.user.id, - suggestion_id, - interaction.guild_id, + logger.debug( + f"User {interaction.user.id} cleared suggestion" + f" {suggestion_id} in guild {interaction.guild_id}", + extra_metadata={ + "author_id": interaction.author.id, + "guild_id": interaction.guild_id, + "suggestion_id": suggestion.suggestion_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -523,9 +543,9 @@ async def get_sid_for( ) else: if not values: - log.debug( - "Values was found, but empty in guild %s thus populating", - interaction.guild_id, + logger.debug( + f"Values was found, but empty in guild {interaction.guild_id} thus populating", + extra_metadata={"guild_id": interaction.guild_id}, ) values: list[str] = await self.state.populate_sid_cache( interaction.guild_id diff --git a/suggestions/cogs/suggestion_queue_cog.py b/suggestions/cogs/suggestion_queue_cog.py index 66c7a8f..5bbe3ab 100644 --- a/suggestions/cogs/suggestion_queue_cog.py +++ b/suggestions/cogs/suggestion_queue_cog.py @@ -1,33 +1,17 @@ from __future__ import annotations -import logging -from datetime import timedelta -from typing import TYPE_CHECKING - import cooldowns import disnake -from alaric import AQ -from alaric.comparison import EQ -from alaric.logical import AND -from alaric.projections import Projection, SHOW -from commons.caching import NonExistentEntry, TimedCache -from disnake import Guild from disnake.ext import commands, components +from logoo import Logger from suggestions import checks from suggestions.cooldown_bucket import InteractionBucket from suggestions.core import SuggestionsQueue -from suggestions.exceptions import ErrorHandled from suggestions.interaction_handler import InteractionHandler -from suggestions.objects import GuildConfig, UserConfig, QueuedSuggestion -from suggestions.qs_paginator import QueuedSuggestionsPaginator -if TYPE_CHECKING: - from alaric import Document - from suggestions import SuggestionsBot - from suggestions.objects import Suggestion -log = logging.getLogger(__name__) +logger = Logger(__name__) class SuggestionsQueueCog(commands.Cog): diff --git a/suggestions/cogs/suggestions_message_commands.py b/suggestions/cogs/suggestions_message_commands.py index 4887ca0..7affd6a 100644 --- a/suggestions/cogs/suggestions_message_commands.py +++ b/suggestions/cogs/suggestions_message_commands.py @@ -1,11 +1,11 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING import cooldowns import disnake from disnake.ext import commands +from logoo import Logger from suggestions import checks from suggestions.cooldown_bucket import InteractionBucket @@ -15,7 +15,7 @@ if TYPE_CHECKING: from suggestions import SuggestionsBot, State, Stats -log = logging.getLogger(__name__) +logger = Logger(__name__) # noinspection DuplicatedCode @@ -54,11 +54,16 @@ async def approve_suggestion(self, interaction: disnake.GuildCommandInteraction) ), ephemeral=True, ) - log.debug( + logger.debug( "User %s approved suggestion %s in guild %s by message command", interaction.author.id, suggestion.suggestion_id, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "suggestion_id": suggestion.suggestion_id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, @@ -93,11 +98,16 @@ async def reject_suggestion(self, interaction: disnake.GuildCommandInteraction): ), ephemeral=True, ) - log.debug( + logger.debug( "User %s rejected suggestion %s in guild %s by message command", interaction.author.id, suggestion.suggestion_id, interaction.guild_id, + extra_metadata={ + "author_id": interaction.author.id, + "suggestion_id": suggestion.suggestion_id, + "guild_id": interaction.guild_id, + }, ) await self.stats.log_stats( interaction.author.id, diff --git a/suggestions/cogs/user_config_cog.py b/suggestions/cogs/user_config_cog.py index 5ac8c10..2af1423 100644 --- a/suggestions/cogs/user_config_cog.py +++ b/suggestions/cogs/user_config_cog.py @@ -1,11 +1,11 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING import cooldowns import disnake from disnake.ext import commands +from logoo import Logger from suggestions.cooldown_bucket import InteractionBucket from suggestions.objects import UserConfig @@ -13,7 +13,7 @@ if TYPE_CHECKING: from suggestions import SuggestionsBot, State, Stats -log = logging.getLogger(__name__) +logger = Logger(__name__) class UserConfigCog(commands.Cog): @@ -36,7 +36,11 @@ async def enable(self, interaction: disnake.CommandInteraction): user_config.dm_messages_disabled = False await self.bot.db.user_configs.upsert(user_config, user_config) await interaction.send("I have enabled DM messages for you.", ephemeral=True) - log.debug("Enabled DM messages for member %s", interaction.author.id) + logger.debug( + "Enabled DM messages for member %s", + interaction.author.id, + extra_metadata={"author_id": interaction.author.id}, + ) await self.stats.log_stats( interaction.author.id, interaction.guild_id, @@ -52,7 +56,11 @@ async def disable(self, interaction: disnake.CommandInteraction): user_config.dm_messages_disabled = True await self.bot.db.user_configs.upsert(user_config, user_config) await interaction.send("I have disabled DM messages for you.", ephemeral=True) - log.debug("Disabled DM messages for member %s", interaction.author.id) + logger.debug( + "Disabled DM messages for member %s", + interaction.author.id, + extra_metadata={"author_id": interaction.author.id}, + ) await self.stats.log_stats( interaction.author.id, interaction.guild_id, @@ -69,7 +77,11 @@ async def view(self, interaction: disnake.CommandInteraction): await interaction.send( f"I {text} DM you on actions such as suggest.", ephemeral=True ) - log.debug("User %s viewed their DM configuration", interaction.author.id) + logger.debug( + "User %s viewed their DM configuration", + interaction.author.id, + extra_metadata={"author_id": interaction.author.id}, + ) await self.stats.log_stats( interaction.author.id, interaction.guild_id, diff --git a/suggestions/cogs/view_voters_cog.py b/suggestions/cogs/view_voters_cog.py index 7ec3f38..5f7571b 100644 --- a/suggestions/cogs/view_voters_cog.py +++ b/suggestions/cogs/view_voters_cog.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING, Type import cooldowns @@ -8,6 +7,7 @@ from commons.caching import NonExistentEntry from disnake.ext import commands from bot_base.paginators.disnake_paginator import DisnakePaginator +from logoo import Logger from suggestions import Colors from suggestions.cooldown_bucket import InteractionBucket @@ -18,7 +18,7 @@ from alaric import Document from suggestions import SuggestionsBot, State -log = logging.getLogger(__name__) +logger = Logger(__name__) class VoterPaginator(DisnakePaginator): @@ -273,9 +273,10 @@ async def get_sid_for( ) else: if not values: - log.debug( + logger.debug( "Values was found, but empty in guild %s thus populating", interaction.guild_id, + extra_metadata={"guild_id": interaction.guild_id}, ) values: list[str] = await self.state.populate_view_voters_cache( interaction.guild_id diff --git a/suggestions/garven.py b/suggestions/garven.py index bb04931..a74bf83 100644 --- a/suggestions/garven.py +++ b/suggestions/garven.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING import aiohttp +from logoo import Logger from suggestions.exceptions import PartialResponse @@ -12,6 +13,7 @@ from suggestions import SuggestionsBot log = logging.getLogger(__name__) +logger = Logger(__name__) class Garven: @@ -57,7 +59,7 @@ async def notify_devs(self, *, title: str, description: str, sender: str): }, ) as resp: if resp.status != 204: - log.error( + logger.error( "Error when attempting to notify devs\n\t- %s", await resp.text(), ) diff --git a/suggestions/objects/guild_config.py b/suggestions/objects/guild_config.py index 027596e..0ec6b55 100644 --- a/suggestions/objects/guild_config.py +++ b/suggestions/objects/guild_config.py @@ -1,16 +1,16 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING, Dict, Optional from alaric import AQ from alaric.comparison import EQ from commons.caching import NonExistentEntry +from logoo import Logger if TYPE_CHECKING: from suggestions import State -log = logging.getLogger(__name__) +logger = Logger(__name__) class GuildConfig: @@ -74,7 +74,11 @@ async def from_id(cls, guild_id: int, state: State) -> GuildConfig: """ try: gc = state.guild_configs.get_entry(guild_id) - log.debug("Found cached GuildConfig for guild %s", guild_id) + logger.debug( + "Found cached GuildConfig for guild %s", + guild_id, + extra_metadata={"guild_id": guild_id}, + ) return gc except NonExistentEntry: pass @@ -83,7 +87,11 @@ async def from_id(cls, guild_id: int, state: State) -> GuildConfig: AQ(EQ("_id", guild_id)) ) if not guild_config: - log.info("Created new GuildConfig for %s", guild_id) + logger.info( + "Created new GuildConfig for %s", + guild_id, + extra_metadata={"guild_id": guild_id}, + ) guild_config = cls(_id=guild_id) state.refresh_guild_config(guild_config) diff --git a/suggestions/objects/queued_suggestion.py b/suggestions/objects/queued_suggestion.py index 67212a6..a84606d 100644 --- a/suggestions/objects/queued_suggestion.py +++ b/suggestions/objects/queued_suggestion.py @@ -1,13 +1,13 @@ from __future__ import annotations import datetime -import logging -from typing import Optional, TYPE_CHECKING, overload +from typing import Optional, TYPE_CHECKING from alaric import AQ from alaric.comparison import EQ from alaric.logical import AND from disnake import Embed +from logoo import Logger from suggestions.exceptions import UnhandledError, SuggestionNotFound from suggestions.objects import Suggestion @@ -15,7 +15,7 @@ if TYPE_CHECKING: from suggestions import State, SuggestionsBot -log = logging.getLogger(__name__) +logger = Logger(__name__) class QueuedSuggestion: @@ -224,7 +224,7 @@ async def convert_to_suggestion(self, state: State) -> Suggestion: # the database and is then disregarded # 2. When we get here the object should have been retrieved # via the queue system so the object should have an attached id - log.critical("QueuedSuggestion(%s) does not have an id", self.as_dict()) + logger.critical("QueuedSuggestion(%s) does not have an id", self.as_dict()) raise UnhandledError( f"QueuedSuggestion({self.as_dict()}) does not have an id" ) diff --git a/suggestions/objects/stats/member_stats.py b/suggestions/objects/stats/member_stats.py index fed9334..52e9925 100644 --- a/suggestions/objects/stats/member_stats.py +++ b/suggestions/objects/stats/member_stats.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from datetime import datetime from typing import TYPE_CHECKING, Dict, List, Optional @@ -8,13 +7,14 @@ from alaric.comparison import EQ from alaric.logical import AND from commons.caching import NonExistentEntry +from logoo import Logger from .member_command_stats import MemberCommandStats if TYPE_CHECKING: from suggestions import State, Stats -log = logging.getLogger(__name__) +logger = Logger(__name__) class MemberStats: @@ -112,10 +112,11 @@ async def from_id(cls, member_id: int, guild_id: int, state: State) -> MemberSta stats.refresh_member_stats(member_stats) return member_stats - log.debug( + logger.debug( "Getting fresh MemberStats object for %s in guild %s", member_id, guild_id, + extra_metadata={"guild_id": guild_id, "author_id": member_id}, ) instance = cls(member_id, guild_id) stats.refresh_member_stats(instance) diff --git a/suggestions/objects/suggestion.py b/suggestions/objects/suggestion.py index ea04c70..5b75382 100644 --- a/suggestions/objects/suggestion.py +++ b/suggestions/objects/suggestion.py @@ -1,7 +1,6 @@ from __future__ import annotations import datetime -import logging from enum import Enum from typing import TYPE_CHECKING, Literal, Union, Optional, cast @@ -12,6 +11,7 @@ from bot_base.wraps import WrappedChannel from disnake import Embed, Guild from disnake.ext import commands +from logoo import Logger from suggestions import ErrorCode from suggestions.exceptions import ErrorHandled, SuggestionNotFound @@ -21,7 +21,7 @@ if TYPE_CHECKING: from suggestions import SuggestionsBot, State, Colors -log = logging.getLogger(__name__) +logger = Logger(__name__) class SuggestionState(Enum): @@ -285,8 +285,10 @@ async def from_id( ) if suggestion.guild_id != guild_id: - log.critical( - "Someone in guild %s looked up a suggestion not from their guild" + logger.critical( + "Someone in guild %s looked up a suggestion not from their guild", + guild_id, + extra_metadata={"guild_id": guild_id, "suggestion_id": suggestion_id}, ) raise SuggestionNotFound( f"No suggestion found with the id {suggestion_id} in this guild" @@ -606,7 +608,11 @@ async def save_reaction_results( self._total_down_votes = reaction.count - 1 if self.total_up_votes is None or self.total_down_votes is None: - log.error("Failed to find our emojis on suggestion %s", self.suggestion_id) + logger.error( + "Failed to find our emojis on suggestion %s", + self.suggestion_id, + extra_metadata={"suggestion_id": self.suggestion_id}, + ) await bot.db.suggestions.update(self, self) @@ -615,20 +621,29 @@ async def try_notify_user_of_decision(self, bot: SuggestionsBot): self.suggestion_author_id, bot.state ) if user_config.dm_messages_disabled: - log.debug( + logger.debug( "User %s has dm messages disabled, failed to notify change to suggestion %s", self.suggestion_author_id, self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "author_id": self.suggestion_author_id, + }, ) return guild_config: GuildConfig = await GuildConfig.from_id(self.guild_id, bot.state) if guild_config.dm_messages_disabled: - log.debug( + logger.debug( "Guild %s has dm messages disabled, failed to notify user %s regarding changes to suggestion %s", self.guild_id, self.suggestion_author_id, self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "author_id": self.suggestion_author_id, + "guild_id": self.guild_id, + }, ) return @@ -660,7 +675,14 @@ async def try_notify_user_of_decision(self, bot: SuggestionsBot): try: await user.send(embed=embed) except disnake.HTTPException: - log.debug("Failed to dm %s to tell them about their suggestion", user.id) + logger.debug( + "Failed to dm %s to tell them about their suggestion", + user.id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "author_id": user.id, + }, + ) async def create_thread(self, message: disnake.Message): """Create a thread for this suggestion""" @@ -677,11 +699,15 @@ async def update_vote_count( interaction: disnake.Interaction, ): if self.channel_id is None or self.message_id is None: - log.error( + logger.error( "update_vote_count received a null value for SID %s, " "channel_id=%s, message_id=%s", self.channel_id, self.message_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "author_id": self.suggestion_author_id, + }, ) return @@ -767,35 +793,54 @@ async def archive_thread_if_required( """Attempts to archive the attached thread if the feature is enabled.""" if not guild_config.auto_archive_threads: # Guild does not want thread archived - log.debug("Guild %s does not want threads archived", guild_config.guild_id) + logger.debug( + "Guild %s does not want threads archived", + guild_config.guild_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, + ) return channel: WrappedChannel = await bot.get_or_fetch_channel(self.channel_id) message: disnake.Message = await channel.fetch_message(self.message_id) if not message.thread: # Suggestion has no created thread - log.debug( + logger.debug( "No thread for suggestion %s, should have one: %s", self.suggestion_id, "yes" if guild_config.threads_for_suggestions else "no", + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, ) return if message.thread.owner_id != bot.user.id: # I did not create this thread - log.debug( + logger.debug( "Thread on suggestion %s is owned by %s", self.suggestion_id, message.thread.owner_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, ) return if message.thread.archived or message.thread.locked: # Thread is already archived or # locked so no need to redo the action - log.debug( + logger.debug( "Thread on suggestion %s is already archived or locked", self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, ) return @@ -803,7 +848,14 @@ async def archive_thread_if_required( bot.get_locale("SUGGESTION_OBJECT_LOCK_THREAD", locale), ) await message.thread.edit(locked=True, archived=True) - log.debug("Locked thread for suggestion %s", self.suggestion_id) + logger.debug( + "Locked thread for suggestion %s", + self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, + ) async def resolve( self, @@ -814,7 +866,14 @@ async def resolve( resolution_type: SuggestionState, resolution_note: Optional[str] = None, ): - log.debug("Attempting to resolve suggestion %s", self.suggestion_id) + logger.debug( + "Attempting to resolve suggestion %s", + self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, + ) self.anonymous_resolution = guild_config.anonymous_resolutions # https://github.com/suggestionsbot/suggestions-bot/issues/36 if resolution_type is SuggestionState.approved: @@ -822,10 +881,14 @@ async def resolve( elif resolution_type is SuggestionState.rejected: await self.mark_rejected_by(state, interaction.author.id, resolution_note) else: - log.error( + logger.error( "Resolving suggestion %s received a resolution_type of %s", self.suggestion_id, resolution_type, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, ) await interaction.send( embed=bot.error_embed( @@ -882,7 +945,14 @@ async def setup_initial_messages( ), ], ) - log.debug("Sent suggestion %s to channel", self.suggestion_id) + logger.debug( + "Sent suggestion %s to channel", + self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, + ) except disnake.Forbidden as e: state.remove_sid_from_cache(interaction.guild_id, self.suggestion_id) await state.suggestions_db.delete(self.as_filter()) @@ -896,9 +966,13 @@ async def setup_initial_messages( try: await self.create_thread(message) except disnake.HTTPException: - log.debug( + logger.debug( "Failed to create a thread on suggestion %s", self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, ) did_delete = await self.try_delete( bot=bot, interaction=interaction, silently=True @@ -922,7 +996,14 @@ async def setup_initial_messages( raise ErrorHandled else: - log.debug("Created a thread on suggestion %s", self.suggestion_id) + logger.debug( + "Created a thread on suggestion %s", + self.suggestion_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + }, + ) try: suggestion_author = ( @@ -964,9 +1045,14 @@ async def setup_initial_messages( or guild_config.dm_messages_disabled ): # Nothing we can do - log.debug( + logger.debug( "Failed to DM %s regarding their suggestion being created from queue", self.suggestion_author_id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + "author_id": self.suggestion_author_id, + }, ) return @@ -987,7 +1073,12 @@ async def setup_initial_messages( ) await interaction.author.send(embed=embed) except disnake.HTTPException: - log.debug( + logger.debug( "Failed to DM %s regarding their suggestion", interaction.author.id, + extra_metadata={ + "suggestion_id": self.suggestion_id, + "guild_id": self.guild_id, + "author_id": interaction.author.id, + }, ) diff --git a/suggestions/objects/user_config.py b/suggestions/objects/user_config.py index 580f556..4c647a0 100644 --- a/suggestions/objects/user_config.py +++ b/suggestions/objects/user_config.py @@ -1,16 +1,16 @@ from __future__ import annotations -import logging from typing import Dict, TYPE_CHECKING, Optional from alaric import AQ from alaric.comparison import EQ from commons.caching import NonExistentEntry +from logoo import Logger if TYPE_CHECKING: from suggestions import State -log = logging.getLogger(__name__) +logger = Logger(__name__) class UserConfig: @@ -24,7 +24,11 @@ def __init__(self, _id: int, dm_messages_disabled: bool = False): async def from_id(cls, user_id: int, state: State): try: uc = state.user_configs.get_entry(user_id) - log.debug("Found cached UserConfig for user %s", user_id) + logger.debug( + "Found cached UserConfig for user %s", + user_id, + extra_metadata={"author_id": user_id}, + ) return uc except NonExistentEntry: pass @@ -33,12 +37,17 @@ async def from_id(cls, user_id: int, state: State): AQ(EQ("_id", user_id)) ) if not user_config: - log.info("Created new UserConfig for %s", user_id) + logger.info( + "Created new UserConfig for %s", + user_id, + extra_metadata={"author_id": user_id}, + ) user_config = cls(_id=user_id) else: - log.debug( + logger.debug( "Fetched UserConfig from database for %s", user_id, + extra_metadata={"author_id": user_id}, ) state.refresh_user_config(user_config) diff --git a/suggestions/qs_paginator.py b/suggestions/qs_paginator.py index d8a7894..49e9470 100644 --- a/suggestions/qs_paginator.py +++ b/suggestions/qs_paginator.py @@ -1,12 +1,12 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING import disnake from alaric import AQ from alaric.comparison import EQ from alaric.logical import AND +from logoo import Logger from suggestions.exceptions import QueueImbalance from suggestions.objects import QueuedSuggestion @@ -15,7 +15,7 @@ from suggestions import SuggestionsBot -log = logging.getLogger(__name__) +logger = Logger(__name__) class QueuedSuggestionsPaginator: @@ -78,7 +78,13 @@ async def format_page(self) -> disnake.Embed: suggestion: QueuedSuggestion = await self.get_current_queued_suggestion() except QueueImbalance: await self.remove_current_page() - log.debug("Hit QueueImbalance") + logger.warning( + "Hit QueueImbalance", + extra_metadata={ + "author_id": self.original_interaction.author.id, + "guild_id": self.original_interaction.guild_id, + }, + ) else: embed: disnake.Embed = await suggestion.as_embed(self.bot) if suggestion.is_anonymous: diff --git a/suggestions/scheduler.py b/suggestions/scheduler.py index 2ea259b..0552aa6 100644 --- a/suggestions/scheduler.py +++ b/suggestions/scheduler.py @@ -1,7 +1,10 @@ import asyncio import logging +from logoo import Logger + log = logging.getLogger(__name__) +logger = Logger(__name__) # Modified from https://stackoverflow.com/a/55185488 @@ -25,7 +28,7 @@ async def inner_task(caller, *args, **kwargs): await asyncio.sleep(sleep_between_tries) - log.debug( + logger.debug( "exception_aware_scheduler for %s has run out of retries", str(caller) ) diff --git a/suggestions/state.py b/suggestions/state.py index cd24e41..86705cc 100644 --- a/suggestions/state.py +++ b/suggestions/state.py @@ -15,8 +15,7 @@ from alaric.meta import Negate from alaric.projections import PROJECTION, SHOW from commons.caching import NonExistentEntry, TimedCache -from disnake import Thread -from disnake.abc import GuildChannel, PrivateChannel +from logoo import Logger from suggestions.objects import GuildConfig, UserConfig @@ -27,6 +26,7 @@ from suggestions.interaction_handler import InteractionHandler log = logging.getLogger(__name__) +logger = Logger(__name__) class State: @@ -92,7 +92,7 @@ def get_new_error_id(self) -> str: error_id = "".join( random.choices(string.ascii_lowercase + string.digits, k=8) ) - log.critical("Encountered an existing error id") + logger.critical("Encountered an existing error id") self.existing_error_ids.add(error_id) return error_id @@ -103,7 +103,7 @@ def get_new_sq_paginator_id(self) -> str: pag_id = "".join( random.choices(string.ascii_lowercase + string.digits, k=8) ) - log.critical("Encountered an existing paginator id") + logger.critical("Encountered an existing paginator id") self.existing_paginator_ids.add(pag_id) return pag_id @@ -117,7 +117,7 @@ def get_new_suggestion_id(self) -> str: suggestion_id = "".join( random.choices(string.ascii_lowercase + string.digits, k=8) ) - log.critical("Encountered an existing SID") + logger.critical("Encountered an existing SID") self.existing_suggestion_ids.add(suggestion_id) return suggestion_id @@ -178,7 +178,11 @@ async def populate_sid_cache(self, guild_id: int) -> list: ) data: List[str] = [d["_id"] for d in data] self.autocomplete_cache.add_entry(guild_id, data, override=True) - log.debug("Populated sid cache for guild %s", guild_id) + logger.debug( + "Populated sid cache for guild %s", + guild_id, + extra_metadata={"guild_id": guild_id}, + ) return data async def populate_view_voters_cache(self, guild_id: int) -> list: @@ -190,7 +194,11 @@ async def populate_view_voters_cache(self, guild_id: int) -> list: ) data: List[str] = [d["_id"] for d in data] self.view_voters_cache.add_entry(guild_id, data, override=True) - log.debug("Populated view voter cache for guild %s", guild_id) + logger.debug( + "Populated view voter cache for guild %s", + guild_id, + extra_metadata={"guild_id": guild_id}, + ) return data def add_sid_to_cache(self, guild_id: int, suggestion_id: str) -> None: @@ -208,7 +216,12 @@ def add_sid_to_cache(self, guild_id: int, suggestion_id: str) -> None: finally: self.autocomplete_cache.add_entry(guild_id, current_values, override=True) - log.debug("Added sid %s to cache for guild %s", suggestion_id, guild_id) + logger.debug( + "Added sid %s to cache for guild %s", + suggestion_id, + guild_id, + extra_metadata={"guild_id": guild_id, "suggestion_id": suggestion_id}, + ) def remove_sid_from_cache(self, guild_id: int, suggestion_id: str) -> None: """Removes a suggestion from the cache when it's state is no longer open.""" @@ -227,10 +240,14 @@ def remove_sid_from_cache(self, guild_id: int, suggestion_id: str) -> None: current_values, override=True, ) - log.debug( + logger.debug( "Removed sid %s from the cache for guild %s", suggestion_id, guild_id, + extra_metadata={ + "guild_id": guild_id, + "suggestion_id": suggestion_id, + }, ) async def load(self): diff --git a/suggestions/utility/r2.py b/suggestions/utility/r2.py index 1a886f6..f48c775 100644 --- a/suggestions/utility/r2.py +++ b/suggestions/utility/r2.py @@ -1,14 +1,14 @@ import hashlib -import logging import mimetypes import os import secrets from aiobotocore.session import get_session +from logoo import Logger from suggestions.exceptions import InvalidFileType -log = logging.getLogger(__name__) +logger = Logger(__name__) async def upload_file_to_r2( @@ -52,6 +52,16 @@ async def upload_file_to_r2( file_key = hashlib.sha256(file_data + secrets.token_bytes(16)).hexdigest() key = "{}/{}.{}".format(guild_id, file_key, ext) await client.put_object(Bucket=os.environ["BUCKET"], Key=key, Body=file_data) - log.debug("User %s in guild %s uploaded an image", user_id, guild_id) + logger.debug( + "User %s in guild %s uploaded an image", + user_id, + guild_id, + extra_metadata={ + "author_id": user_id, + "guild_id": guild_id, + "original_image_name": file_name, + "uploaded_to": key, + }, + ) return f"https://cdn.suggestions.bot/{key}" From 758e8f118599d080d7100046671583e38b5aa054 Mon Sep 17 00:00:00 2001 From: skelmis Date: Thu, 7 Mar 2024 22:55:15 +1300 Subject: [PATCH 10/11] feat: add bot_version to logs --- main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index 06ce9eb..ae991c5 100644 --- a/main.py +++ b/main.py @@ -52,7 +52,10 @@ async def run_bot(): username=os.environ["LOGOO_USER"], password=os.environ["LOGOO_PASSWORD"], poll_time=15, - global_metadata={"cluster": bot.cluster_id}, + global_metadata={ + "cluster": bot.cluster_id, + "bot_version": bot.version, + }, ) await logger.start_consumer() From b9eaa9f5f406ef8014c14c32906ed4d85eb00c7f Mon Sep 17 00:00:00 2001 From: skelmis Date: Thu, 7 Mar 2024 23:51:35 +1300 Subject: [PATCH 11/11] chore: bump logoo version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ce587aa..79b1519 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,4 +53,4 @@ yarl==1.7.2 zonis==1.2.5 types-aiobotocore==2.11.2 aiobotocore==2.11.2 -logoo==1.1.0 \ No newline at end of file +logoo==1.2.0 \ No newline at end of file