Skip to content

Commit

Permalink
feat: add InteractionHandler class (#64)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Skelmis authored Jan 4, 2024
1 parent d88cc50 commit ba61020
Show file tree
Hide file tree
Showing 5 changed files with 158 additions and 3 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
name: "Pytest"
on:
pull_request:
branches: [ master ]
push:
branches: [ master ]
branches: master

jobs:
run_tests:
Expand Down
80 changes: 80 additions & 0 deletions suggestions/interaction_handler.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions suggestions/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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("..")

Expand All @@ -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


Expand All @@ -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)
60 changes: 60 additions & 0 deletions tests/test_interaction_handler.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit ba61020

Please sign in to comment.