From e2c748c77bd31af17f975caee1133a9b93e61508 Mon Sep 17 00:00:00 2001 From: TAG-Epic Date: Fri, 14 Jul 2023 01:28:32 +0200 Subject: [PATCH 1/6] feat(endpoint): somewhat of a structure for webhook-based responses This is not called webhook because of potential confusion between this and the channel webhooks. This is for anything that receives HTTP requests originating from Discord's servers. My structure for this is going to be to - Create a adapter per web-framework that sets up a app and HTTP paths, and passes the processed data from the request to the main class - A main class, unsure of a name that takes the data from the adapters and dispatches it and does logic stuff. Unsure of how the different features like interactions and oauth are going to fit into this as there is quite a lot of features to cram into one class, however I don't know any better way to structure it. --- nextcore/endpoint/__init__.py | 0 nextcore/endpoint/interactions/__init__.py | 20 +++++ nextcore/endpoint/interactions/primitives.py | 79 ++++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 100 insertions(+) create mode 100644 nextcore/endpoint/__init__.py create mode 100644 nextcore/endpoint/interactions/__init__.py create mode 100644 nextcore/endpoint/interactions/primitives.py diff --git a/nextcore/endpoint/__init__.py b/nextcore/endpoint/__init__.py new file mode 100644 index 000000000..e69de29bb 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/primitives.py b/nextcore/endpoint/interactions/primitives.py new file mode 100644 index 000000000..d3b696501 --- /dev/null +++ b/nextcore/endpoint/interactions/primitives.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 check(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/pyproject.toml b/pyproject.toml index b30d5e3ea..14f208d6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ 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" [tool.poetry.group.dev.dependencies] Sphinx = "^5.0.0" From d11cf59fb87725d5145dadce2a39773eac86a9f4 Mon Sep 17 00:00:00 2001 From: TAG-Epic Date: Sat, 15 Jul 2023 17:31:56 +0200 Subject: [PATCH 2/6] feat(endpoint): Somewhat of a finalized structure The fastapi adapter is broken, please look at the aiohttp adapter of how I want things structured. --- nextcore/endpoint/adapters/abc.py | 44 +++++++++ nextcore/endpoint/adapters/aiohttp/adapter.py | 94 +++++++++++++++++++ nextcore/endpoint/adapters/fastapi/adapter.py | 42 +++++++++ .../fastapi/models/interactions/error.py | 4 + nextcore/endpoint/adapters/openapi.py | 21 +++++ nextcore/endpoint/interactions/controller.py | 71 ++++++++++++++ nextcore/endpoint/interactions/errors.py | 27 ++++++ nextcore/endpoint/interactions/request.py | 40 ++++++++ .../{primitives.py => request_verifier.py} | 2 +- nextcore/endpoint/interactions/response.py | 25 +++++ pyproject.toml | 6 ++ 11 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 nextcore/endpoint/adapters/abc.py create mode 100644 nextcore/endpoint/adapters/aiohttp/adapter.py create mode 100644 nextcore/endpoint/adapters/fastapi/adapter.py create mode 100644 nextcore/endpoint/adapters/fastapi/models/interactions/error.py create mode 100644 nextcore/endpoint/adapters/openapi.py create mode 100644 nextcore/endpoint/interactions/controller.py create mode 100644 nextcore/endpoint/interactions/errors.py create mode 100644 nextcore/endpoint/interactions/request.py rename nextcore/endpoint/interactions/{primitives.py => request_verifier.py} (97%) create mode 100644 nextcore/endpoint/interactions/response.py 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/controller.py b/nextcore/endpoint/interactions/controller.py new file mode 100644 index 000000000..7db44e78b --- /dev/null +++ b/nextcore/endpoint/interactions/controller.py @@ -0,0 +1,71 @@ +# 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 ...common.maybe_coro import maybe_coro +from .request import InteractionRequest +from .response import InteractionResponse +from .request_verifier import RequestVerifier +from .errors import RequestVerificationError + +if TYPE_CHECKING: + from typing import Final, Callable, Awaitable, Union + from typing_extensions import TypeAlias + + RequestCheck: TypeAlias = Callable[[InteractionRequest], Union[Awaitable[None], None]] + +# TODO: The controller name is stupid. +class InteractionController: + """A class for handling endpoint interactions""" + def __init__(self, public_key: str | bytes) -> None: + self.request_verifier: Final[RequestVerifier] = RequestVerifier(public_key) + self.request_checks: list[RequestCheck] = [self.check_has_required_headers, self.check_has_valid_signature] + + async def handle_interaction_request(self, request: InteractionRequest) -> InteractionResponse: + try: + await self.verify_request(request) + except RequestVerificationError as error: + return InteractionResponse(401, error.reason) + + return InteractionResponse(status_code=200, body="{\"type\": 1}") + + async def verify_request(self, request: InteractionRequest) -> None: + for check in self.request_checks: + await maybe_coro(check, request) + + # Built-in checks + def check_has_required_headers(self, request: InteractionRequest) -> None: + signature = request.headers.get("X-Signature-Ed25519") + timestamp = request.headers.get("X-Signature-Timestamp") + + if signature is None: + raise RequestVerificationError("Missing X-Signature-Ed25519 header") + if timestamp is None: + raise RequestVerificationError("Missing X-Signature-Timestamp header") + + def check_has_valid_signature(self, request: InteractionRequest) -> None: + 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") 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/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/primitives.py b/nextcore/endpoint/interactions/request_verifier.py similarity index 97% rename from nextcore/endpoint/interactions/primitives.py rename to nextcore/endpoint/interactions/request_verifier.py index d3b696501..bf741bc29 100644 --- a/nextcore/endpoint/interactions/primitives.py +++ b/nextcore/endpoint/interactions/request_verifier.py @@ -43,7 +43,7 @@ def __init__(self, public_key: str | bytes) -> None: self.verify_key: Final[VerifyKey] = VerifyKey(public_key) - def check(self, signature: str | bytes, timestamp: str, body: str) -> bool: + def is_valid(self, signature: str | bytes, timestamp: str, body: str) -> bool: """Check if a request was made by Discord Parameters 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 14f208d6e..abd74b3e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,10 @@ 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" @@ -64,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] From 97eef72d543978966fcead6b8f11cb83f57f9344 Mon Sep 17 00:00:00 2001 From: TAG-Epic Date: Sat, 15 Jul 2023 17:46:30 +0200 Subject: [PATCH 3/6] docs(endpoint): improve interaction controller docs --- nextcore/endpoint/interactions/controller.py | 30 ++++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/nextcore/endpoint/interactions/controller.py b/nextcore/endpoint/interactions/controller.py index 7db44e78b..3b9efbd1b 100644 --- a/nextcore/endpoint/interactions/controller.py +++ b/nextcore/endpoint/interactions/controller.py @@ -54,15 +54,33 @@ async def verify_request(self, request: InteractionRequest) -> None: # Built-in checks def check_has_required_headers(self, request: InteractionRequest) -> None: - signature = request.headers.get("X-Signature-Ed25519") - timestamp = request.headers.get("X-Signature-Timestamp") + """A request check that makes sure the signature and timestamp headers are present - if signature is None: - raise RequestVerificationError("Missing X-Signature-Ed25519 header") - if timestamp is None: - raise RequestVerificationError("Missing X-Signature-Timestamp header") + .. 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) From d03c1611b223397dd799b9ceefa42d805ff397fb Mon Sep 17 00:00:00 2001 From: TAG-Epic Date: Sun, 16 Jul 2023 19:06:14 +0200 Subject: [PATCH 4/6] docs(endpoint/interactions): more docs --- nextcore/endpoint/interactions/controller.py | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/nextcore/endpoint/interactions/controller.py b/nextcore/endpoint/interactions/controller.py index 3b9efbd1b..6a6abf826 100644 --- a/nextcore/endpoint/interactions/controller.py +++ b/nextcore/endpoint/interactions/controller.py @@ -20,8 +20,10 @@ # DEALINGS IN THE SOFTWARE. from __future__ import annotations +from logging import getLogger from typing import TYPE_CHECKING from ...common.maybe_coro import maybe_coro +from ...common.json import json_dumps from .request import InteractionRequest from .response import InteractionResponse from .request_verifier import RequestVerifier @@ -33,6 +35,8 @@ 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""" @@ -41,14 +45,48 @@ def __init__(self, public_key: str | bytes) -> None: self.request_checks: list[RequestCheck] = [self.check_has_required_headers, self.check_has_valid_signature] 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. + + """ try: await self.verify_request(request) except RequestVerificationError as error: return InteractionResponse(401, error.reason) + except: + 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)) return InteractionResponse(status_code=200, body="{\"type\": 1}") 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) From 6a6c8037b0fa189aa128c7ee720206811698d011 Mon Sep 17 00:00:00 2001 From: TAG-Epic Date: Sun, 16 Jul 2023 21:01:24 +0200 Subject: [PATCH 5/6] feat(common): add support for waiting for a dispatch to complete. This also waits for error handlers. Conflicts with #253 is to be expected, sorry! --- nextcore/common/dispatcher.py | 22 ++++++++++++++++------ tests/common/test_dispatcher.py | 17 ++++++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) 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/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) From 5de0ecfe5504830f67c685a1a09ddd3915baefb7 Mon Sep 17 00:00:00 2001 From: TAG-Epic Date: Sun, 16 Jul 2023 22:16:30 +0200 Subject: [PATCH 6/6] feat(endpoint/interactions): implement the giant mess that is endpoint interactions --- nextcore/endpoint/interactions/controller.py | 48 +++++++++++++++-- nextcore/endpoint/interactions/interaction.py | 54 +++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 nextcore/endpoint/interactions/interaction.py diff --git a/nextcore/endpoint/interactions/controller.py b/nextcore/endpoint/interactions/controller.py index 6a6abf826..d86b2f6a5 100644 --- a/nextcore/endpoint/interactions/controller.py +++ b/nextcore/endpoint/interactions/controller.py @@ -22,28 +22,48 @@ 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 +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 + 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""" + """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 @@ -61,16 +81,29 @@ async def handle_interaction_request(self, request: InteractionRequest) -> Inter 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: + 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) - return InteractionResponse(status_code=200, body="{\"type\": 1}") + 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 @@ -125,3 +158,8 @@ def check_has_valid_signature(self, request: InteractionRequest) -> None: 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/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