From ba610201deabbdf6c250565a1b344c0261c25dde Mon Sep 17 00:00:00 2001 From: Ethan <47520067+Skelmis@users.noreply.github.com> Date: Thu, 4 Jan 2024 15:47:28 +1300 Subject: [PATCH] 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 +- suggestions/interaction_handler.py | 80 ++++++++++++++++++++++++++++++ suggestions/state.py | 5 ++ tests/conftest.py | 13 ++++- tests/test_interaction_handler.py | 60 ++++++++++++++++++++++ 5 files changed, 158 insertions(+), 3 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/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 8de4b46..a9e5f98 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__) @@ -59,6 +60,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 24b15b6..1759104 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ import suggestions from suggestions.clunk import ClunkLock, Clunk from tests.mocks import MockedSuggestionsMongoManager +from suggestions.interaction_handler import InteractionHandler @pytest.fixture @@ -17,7 +18,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("..") @@ -35,6 +36,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 @@ -53,3 +59,8 @@ async def clunk_lock(causar: Causar) -> ClunkLock: @pytest.fixture async def clunk(causar: Causar) -> Clunk: return Clunk(causar.bot.state) # type: ignore + + +@pytest.fixture +def interaction_handler() -> InteractionHandler: + return InteractionHandler(AsyncMock(), True, True) 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