diff --git a/nextcore/common/dispatcher.py b/nextcore/common/dispatcher.py index 2efc96171..f3a50600e 100644 --- a/nextcore/common/dispatcher.py +++ b/nextcore/common/dispatcher.py @@ -21,7 +21,7 @@ from __future__ import annotations -from asyncio import CancelledError, Future, create_task +from asyncio import CancelledError, Future, Task, create_task, gather from collections import defaultdict from logging import getLogger from typing import TYPE_CHECKING, Generic, Hashable, TypeVar, cast, overload @@ -423,7 +423,7 @@ async def wait_for( return result # type: ignore [return-value] # Dispatching - async def dispatch(self, event_name: EventNameT, *args: Any) -> None: + async def dispatch(self, event_name: EventNameT, *args: Any, wait: bool = False) -> None: """Dispatch a event **Example usage:** @@ -438,25 +438,35 @@ async def dispatch(self, event_name: EventNameT, *args: Any) -> None: The event name to dispatch to. args: The event arguments. This will be passed to the listeners. + wait: + Wait for all listeners to complete. """ logger.debug("Dispatching event %s", event_name) + tasks: list[Task[None]] = [] + # Event handlers # Tasks are used here as some event handler/check might take a long time. for handler in self._global_event_handlers: logger.debug("Dispatching to a global handler") - create_task(self._run_global_event_handler(handler, event_name, *args)) + tasks.append(create_task(self._run_global_event_handler(handler, event_name, *args))) for handler in self._event_handlers.get(event_name, []): logger.debug("Dispatching to a local handler") - create_task(self._run_event_handler(handler, event_name, *args)) + tasks.append(create_task(self._run_event_handler(handler, event_name, *args))) # Wait for handlers for check, future in self._wait_for_handlers.get(event_name, []): logger.debug("Dispatching to a wait_for handler") - create_task(self._run_wait_for_handler(check, future, event_name, *args)) + tasks.append(create_task(self._run_wait_for_handler(check, future, event_name, *args))) for check, future in self._global_wait_for_handlers: logger.debug("Dispatching to a global wait_for handler") - create_task(self._run_global_wait_for_handler(check, future, event_name, *args)) + tasks.append(create_task(self._run_global_wait_for_handler(check, future, event_name, *args))) + + # Optional waiting + logger.debug("Dispatching via %s tasks", len(tasks)) + + if wait: + await gather(*tasks) async def _run_event_handler(self, callback: EventCallback, event_name: EventNameT, *args: Any) -> None: """Run event with exception handlers""" diff --git a/nextcore/endpoint/__init__.py b/nextcore/endpoint/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nextcore/endpoint/adapters/abc.py b/nextcore/endpoint/adapters/abc.py new file mode 100644 index 000000000..56ee8e17e --- /dev/null +++ b/nextcore/endpoint/adapters/abc.py @@ -0,0 +1,44 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..controller import InteractionController + +class AbstractAdapter(ABC): + @abstractmethod + def set_interaction_controller(self, controller: InteractionController | None) -> None: + """Set the interaction controller + + This should register the controller and all events relating to interactions should be passed to the controller. + + Parameters + ---------- + controller: + The controller to set. If this is :data:`None` remove the controller. + + If a controller is already set, remove it. + """ + ... diff --git a/nextcore/endpoint/adapters/aiohttp/adapter.py b/nextcore/endpoint/adapters/aiohttp/adapter.py new file mode 100644 index 000000000..d37cb5fcd --- /dev/null +++ b/nextcore/endpoint/adapters/aiohttp/adapter.py @@ -0,0 +1,94 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from __future__ import annotations +from typing import TYPE_CHECKING +from aiohttp.web_app import Application +from logging import getLogger + +from aiohttp.web_response import json_response, Response + +from ...interactions.request import InteractionRequest +from ..abc import AbstractAdapter + +if TYPE_CHECKING: + from ...interactions.controller import InteractionController + from aiohttp.web_request import Request + from aiohttp.web_response import StreamResponse + from typing import Final + +logger = getLogger(__name__) + +class AIOHTTPAdapter(AbstractAdapter): + """The AIOHTTP endpoint adapter + """ + + def __init__(self) -> None: + self.app: Application = Application() + + # Controllers + self._interaction_controller: InteractionController | None = None + + self._add_routes() + + def _add_routes(self): + """This is automatically called by :meth:`__init__`! + + The point of this being a seperate function is that it can be overriden by a sub-class. + + .. note:: + This counts as the public API, so any major changes to this will require a major version bump. + """ + + self.app.router.add_post("/interaction", self.handle_interaction_request) + + # Controller registrations + def set_interaction_controller(self, controller: InteractionController | None) -> None: + """Set the interaction controller + + This should register the controller and all events relating to interactions should be passed to the controller. + + Parameters + ---------- + controller: + The controller to set. If this is :data:`None` remove the controller. + + If a controller is already set, remove it. + """ + self._interaction_controller = controller + + async def handle_interaction_request(self, request: Request) -> StreamResponse: + if self._interaction_controller is None: + return json_response({"detail": "No interaction controller registered"}) + + raw_body = await request.read() + + try: + body = raw_body.decode() + except ValueError: + return json_response({"detail": "Request body is not UTF-8 encoded"}, status=400) + + interaction_request = InteractionRequest(request.headers, body) + + interaction_response = await self._interaction_controller.handle_interaction_request(interaction_request) + + return Response(body=interaction_response.body, status=interaction_response.status_code, headers={"Content-Type": "application/json"}) + diff --git a/nextcore/endpoint/adapters/fastapi/adapter.py b/nextcore/endpoint/adapters/fastapi/adapter.py new file mode 100644 index 000000000..e8858d8a3 --- /dev/null +++ b/nextcore/endpoint/adapters/fastapi/adapter.py @@ -0,0 +1,42 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from __future__ import annotations + +from fastapi import FastAPI +from typing import TYPE_CHECKING +from .models.interactions.error import Error as ErrorModel + +if TYPE_CHECKING: + from typing import Final + from fastapi import Request, Response + +class FastAPIAdapter: + """The FastAPI endpoint adapter. + """ + def __init__(self) -> None: + self.app: Final[FastAPI] = FastAPI() + + # Register handlers + self.app.add_api_route("/interaction", self.on_interaction_request, responses={200: ErrorModel}) + + async def on_interaction_request(self, request: Request) -> Response: + ... diff --git a/nextcore/endpoint/adapters/fastapi/models/interactions/error.py b/nextcore/endpoint/adapters/fastapi/models/interactions/error.py new file mode 100644 index 000000000..a2facb6b7 --- /dev/null +++ b/nextcore/endpoint/adapters/fastapi/models/interactions/error.py @@ -0,0 +1,4 @@ +from pydantic import BaseModel + +class Error(BaseModel): + detail: str diff --git a/nextcore/endpoint/adapters/openapi.py b/nextcore/endpoint/adapters/openapi.py new file mode 100644 index 000000000..e86c215b0 --- /dev/null +++ b/nextcore/endpoint/adapters/openapi.py @@ -0,0 +1,21 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + diff --git a/nextcore/endpoint/interactions/__init__.py b/nextcore/endpoint/interactions/__init__.py new file mode 100644 index 000000000..005dbf5db --- /dev/null +++ b/nextcore/endpoint/interactions/__init__.py @@ -0,0 +1,20 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. diff --git a/nextcore/endpoint/interactions/controller.py b/nextcore/endpoint/interactions/controller.py new file mode 100644 index 000000000..d86b2f6a5 --- /dev/null +++ b/nextcore/endpoint/interactions/controller.py @@ -0,0 +1,165 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from __future__ import annotations +from logging import getLogger +from typing import TYPE_CHECKING + +from nextcore.common.dispatcher import Dispatcher +from ...common.maybe_coro import maybe_coro +from ...common.json import json_dumps, json_loads +from .request import InteractionRequest +from .response import InteractionResponse +from .request_verifier import RequestVerifier +from .errors import RequestVerificationError +from .interaction import Interaction + +if TYPE_CHECKING: + from typing import Final, Callable, Awaitable, Union, Literal + from typing_extensions import TypeAlias + + from discord_typings import InteractionData + + RequestCheck: TypeAlias = Callable[[InteractionRequest], Union[Awaitable[None], None]] + +logger = getLogger(__name__) + +# TODO: The controller name is stupid. +class InteractionController: + """A class for handling endpoint interactions + + .. warn:: + Endpoint interactions are weird. + + While endpoint interactions are easier to scale, gateway interactions are easier to use and seem more stable. + """ + def __init__(self, public_key: str | bytes) -> None: + self.dispatcher: Final[Dispatcher[Literal["raw_request", "check_failed", "check_error"]]] = Dispatcher() + self.interaction_dispatcher: Final[Dispatcher[int]] = Dispatcher() + + # Request verification + self.request_verifier: Final[RequestVerifier] = RequestVerifier(public_key) + self.request_checks: list[RequestCheck] = [self.check_has_required_headers, self.check_has_valid_signature] + + self.add_default_interaction_handlers() + + def add_default_interaction_handlers(self): + self.interaction_dispatcher.add_listener(self.handle_interaction_ping, 1) # TODO: Extract magic value to a enum + + async def handle_interaction_request(self, request: InteractionRequest) -> InteractionResponse: + """Callback for handling a interaction request + + .. note:: + This is meant to be called by a web-framework adapter. + + Parameters + ---------- + request: + The HTTP request that was received for this interaction + + Returns + ------- + InteractionResponse: + What to respond with. + + """ + await self.dispatcher.dispatch("raw_request", request) + try: + await self.verify_request(request) + except RequestVerificationError as error: + await self.dispatcher.dispatch("check_failed", error.reason) + return InteractionResponse(401, error.reason) + except Exception as error: + await self.dispatcher.dispatch("check_error", error) + logger.exception("Error occured while handling interaction") + error_response = {"detail": f"Internal check error. Check the {__name__} logger for details"} + return InteractionResponse(500, json_dumps(error_response)) + + data: InteractionData = json_loads(request.body) + + interaction = Interaction(request, data) + await self.interaction_dispatcher.dispatch(data["type"], interaction, wait=True) + + if interaction.response is None: + error_response = {"detail": "Interaction.response was never set!"} + logger.error("No interaction.response was set!") + return InteractionResponse(500, json_dumps(error_response)) + + return InteractionResponse(200, json_dumps(interaction.response)) + + async def verify_request(self, request: InteractionRequest) -> None: + """Try all request checks + + Parameters + ---------- + request: + The request made + Raises + ------ + RequestVerificationError: + The request checks did not succeed, so you should not let it execute. + The reason it did not succeed is also in the error. + Exception: + A error in one of the checks was raised. In this case, + """ + for check in self.request_checks: + await maybe_coro(check, request) + + # Built-in checks + def check_has_required_headers(self, request: InteractionRequest) -> None: + """A request check that makes sure the signature and timestamp headers are present + + .. note:: + This check is added by default to :attr:`request_checks` + .. warning:: + This must be included before :meth:`check_has_valid_signature` unless you have something else similar. + + If not, a server error will happen. + + The headers that are checked for are: + - ``X-Signature-Ed25519`` + - ``X-Signature-Timestamp`` + """ + required_headers = ["X-Signature-Ed25519", "X-Signature-Timestamp"] + + for header in required_headers: + if header not in request.headers: + raise RequestVerificationError(f"Missing the \"{header}\" header") + + def check_has_valid_signature(self, request: InteractionRequest) -> None: + """A request check that makes sure the request is signed by your applications public key. + + .. note:: + This check is added by default to :attr:`request_checks` + .. warning:: + This or something similar must check the request or Discord will reject adding the interactions endpoint url. + """ + signature = request.headers["X-Signature-Ed25519"] + timestamp = request.headers["X-Signature-Timestamp"] + is_from_discord = self.request_verifier.is_valid(signature, timestamp, request.body) + + if not is_from_discord: + raise RequestVerificationError("Request signature is invalid") + + # Default handlers + async def handle_interaction_ping(self, interaction: Interaction): + # Acknowledge ping + interaction.response = {"type": 1} diff --git a/nextcore/endpoint/interactions/errors.py b/nextcore/endpoint/interactions/errors.py new file mode 100644 index 000000000..df5289777 --- /dev/null +++ b/nextcore/endpoint/interactions/errors.py @@ -0,0 +1,27 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +class RequestVerificationError(Exception): + __slots__ = ("reason",) + def __init__(self, reason: str) -> None: + self.reason = reason + + super().__init__(reason) diff --git a/nextcore/endpoint/interactions/interaction.py b/nextcore/endpoint/interactions/interaction.py new file mode 100644 index 000000000..e84b4720f --- /dev/null +++ b/nextcore/endpoint/interactions/interaction.py @@ -0,0 +1,54 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from discord_typings import InteractionData, InteractionResponseData + from .request import InteractionRequest + from typing import Final + +class Interaction: + """A wrapper around a endpoint interaction to allow responses to a HTTP request + + **Example Usage** + + .. code-block:: python3 + + interaction = Interaction(request, json.loads(request.body)) + + interaction.response = {"type": 1} + + Attributes + ---------- + request: + The raw HTTP request made by Discord. + data: + The json data inside the HTTP request. + response: + What to respond to the HTTP request with. + """ + def __init__(self, request: InteractionRequest, data: InteractionData) -> None: + self.request: Final[InteractionRequest] = request + self.data: Final[InteractionData] = data + self.response: InteractionResponseData | None = None diff --git a/nextcore/endpoint/interactions/request.py b/nextcore/endpoint/interactions/request.py new file mode 100644 index 000000000..5e7f61180 --- /dev/null +++ b/nextcore/endpoint/interactions/request.py @@ -0,0 +1,40 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from multidict import CIMultiDictProxy + +class InteractionRequest: + """A model for a endpoint interaction request that is independent of which http-framework adapter you use. + + Parameters + ---------- + headers: + The request headers + body: + The body text + """ + def __init__(self, headers: CIMultiDictProxy[str], body: str) -> None: + self.headers: CIMultiDictProxy[str] = headers + self.body: str = body diff --git a/nextcore/endpoint/interactions/request_verifier.py b/nextcore/endpoint/interactions/request_verifier.py new file mode 100644 index 000000000..bf741bc29 --- /dev/null +++ b/nextcore/endpoint/interactions/request_verifier.py @@ -0,0 +1,79 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +from __future__ import annotations +from nacl.signing import VerifyKey +from nacl.exceptions import BadSignatureError +from typing import Final +from logging import getLogger + +logger = getLogger(__name__) + +class RequestVerifier: + """Helper class to verify requests from Discord + + Parameters + ---------- + public_key: + The public key from the developer portal. This can be a hex :class:`str`, or the hex :class:`str` converted to bytes. + + This will be converted to :class:`bytes` under the hood. + """ + def __init__(self, public_key: str | bytes) -> None: + if isinstance(public_key, str): + public_key = bytes.fromhex(public_key) + + self.verify_key: Final[VerifyKey] = VerifyKey(public_key) + + def is_valid(self, signature: str | bytes, timestamp: str, body: str) -> bool: + """Check if a request was made by Discord + + Parameters + ---------- + signature: + The signature from the ``X-Signature-Ed25519`` header. This can either be the raw header (a hex encoded :class:`str`) or it converted to :class:`bytes`. + + Returns + ------- + bool + If the signature was made from Discord. + + If this is :data:`False` you should reject the request with a ``401`` status code. + """ + + if isinstance(signature, str): + try: + signature = bytes.fromhex(signature) + except ValueError: + logger.debug("Request did not pass check because of signature not being valid HEX") + return False + + payload = f"{timestamp}{body}".encode() + try: + self.verify_key.verify(payload, signature) + except BadSignatureError: + logger.debug("Request did not pass check because of verify_key.verify") + return False + except ValueError: + logger.debug("Request did not pass check because of ValueError. This is probably due to wrong length") + + return True + diff --git a/nextcore/endpoint/interactions/response.py b/nextcore/endpoint/interactions/response.py new file mode 100644 index 000000000..5c85388ab --- /dev/null +++ b/nextcore/endpoint/interactions/response.py @@ -0,0 +1,25 @@ +# The MIT License (MIT) +# Copyright (c) 2021-present tag-epic +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +class InteractionResponse: + def __init__(self, status_code: int, body: str) -> None: + self.status_code: int = status_code + self.body: str = body diff --git a/pyproject.toml b/pyproject.toml index b30d5e3ea..abd74b3e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,11 @@ typing-extensions = "^4.1.1" # Same as above orjson = {version = "^3.6.8", optional = true} types-orjson = {version = "^3.6.2", optional = true} discord-typings = "^0.5.0" +pynacl = "^1.5.0" +fastapi = {version = "^0.100.0", optional = true} # TODO: Complain to devs about zerover. +pydantic = {version = "^2.0.2", optional = true} +multidict = {version = "^6.0.4", optional = true} + [tool.poetry.group.dev.dependencies] Sphinx = "^5.0.0" @@ -63,6 +68,8 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.extras] speed = ["orjson", "types-orjson"] +endpoint = ["multidict"] +endpoint-adapter-fastapi = ["fastapi", "pydantic"] # Tools [tool.taskipy.tasks] diff --git a/tests/common/test_dispatcher.py b/tests/common/test_dispatcher.py index 9c6c823f7..bce967d21 100644 --- a/tests/common/test_dispatcher.py +++ b/tests/common/test_dispatcher.py @@ -1,9 +1,8 @@ from __future__ import annotations -from asyncio import Future -from asyncio import TimeoutError as AsyncioTimeoutError -from asyncio import create_task, get_running_loop, wait_for +from asyncio import TimeoutError as AsyncioTimeoutError, Future, sleep, create_task, get_running_loop, wait_for from typing import TYPE_CHECKING +from tests.utils import match_time from pytest import mark, raises @@ -192,3 +191,15 @@ def false_callback(event: str | None = None) -> bool: assert error_count == 0, "Logged errors where present" dispatcher.close() + +@mark.asyncio +@match_time(1, 0.1) +async def test_dispatch_wait(): + dispatcher: Dispatcher[str] = Dispatcher() + + async def handler(): + await sleep(1) + + dispatcher.add_listener(handler, "test") + + await dispatcher.dispatch("test", wait=True)