diff --git a/constants/constants.py b/constants/constants.py index 18ab263..e4b3645 100644 --- a/constants/constants.py +++ b/constants/constants.py @@ -28,10 +28,6 @@ "Colours", "Channels", "ForumTags", - "PAPIWebsocketSubscriptions", - "PAPIWebsocketCloseCodes", - "PAPIWebsocketNotificationTypes", - "PAPIWebsocketOPCodes", ) @@ -76,32 +72,3 @@ class ForumTags(CONSTANTS): DISCORDPY = 1006716972802789457 OTHER = 1006717008613740596 RESOLVED = 1006769269201195059 - - -class PAPIWebsocketCloseCodes(CONSTANTS): - NORMAL: int = 1000 - ABNORMAL: int = 1006 - - -class PAPIWebsocketOPCodes(CONSTANTS): - # Received from Pythonista API... - HELLO: int = 0 - EVENT: int = 1 - NOTIFICATION: int = 2 - - # Sent to Pythonista API... - SUBSCRIBE: str = "subscribe" - UNSUBSCRIBE: str = "unsubscribe" - - -class PAPIWebsocketSubscriptions(CONSTANTS): - DPY_MODLOG: str = "dpy_modlog" - - -class PAPIWebsocketNotificationTypes(CONSTANTS): - # Subscriptions... - SUBSCRIPTION_ADDED: str = "subscription_added" - SUBSCRIPTION_REMOVED: str = "subscription_removed" - - # Failures... - UNKNOWN_OP: str = "unknown_op" diff --git a/core/utils/logging.py b/core/utils/logging.py index a0ff762..ca5513f 100644 --- a/core/utils/logging.py +++ b/core/utils/logging.py @@ -24,14 +24,6 @@ def emit(self, record: logging.LogRecord) -> None: self.bot.logging_queue.put_nowait(record) -class PAPILoggingFilter(logging.Filter): - def __init__(self) -> None: - super().__init__(name="modules.api") - - def filter(self, record: logging.LogRecord) -> bool: - return not ("Received HELLO" in record.msg or "added our subscription" in record.msg) - - class LogHandler: def __init__(self, *, bot: Bot, stream: bool = True) -> None: self.log: logging.Logger = logging.getLogger() @@ -54,7 +46,6 @@ def __enter__(self: Self) -> Self: logging.getLogger("discord.http").setLevel(logging.INFO) logging.getLogger("discord.state").setLevel(logging.WARNING) logging.getLogger("discord.gateway").setLevel(logging.WARNING) - logging.getLogger("modules.api").addFilter(PAPILoggingFilter()) self.log.setLevel(logging.INFO) handler = RotatingFileHandler( diff --git a/launcher.py b/launcher.py index 612daa7..96a88a1 100644 --- a/launcher.py +++ b/launcher.py @@ -26,10 +26,14 @@ import aiohttp import asyncpg import mystbin +import uvicorn import core from core.utils import LogHandler from modules import EXTENSIONS +from server.application import Application + +tasks: set[asyncio.Task[None]] = set() async def main() -> None: @@ -59,8 +63,12 @@ async def main() -> None: extension.name, ) - await bot.start(core.CONFIG["TOKENS"]["bot"]) + app: Application = Application(bot=bot) + config: uvicorn.Config = uvicorn.Config(app, port=2332) + server: uvicorn.Server = uvicorn.Server(config) + tasks.add(asyncio.create_task(bot.start(core.CONFIG["TOKENS"]["bot"]))) + await server.serve() try: asyncio.run(main()) diff --git a/modules/api.py b/modules/api.py deleted file mode 100644 index fe033c1..0000000 --- a/modules/api.py +++ /dev/null @@ -1,179 +0,0 @@ -"""MIT License - -Copyright (c) 2021-Present PythonistaGuild - -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 - -import asyncio -import logging -from typing import Any - -import aiohttp -from discord.backoff import ExponentialBackoff - -import core -from constants import ( - PAPIWebsocketCloseCodes, - PAPIWebsocketNotificationTypes, - PAPIWebsocketOPCodes, - PAPIWebsocketSubscriptions, -) - -LOGGER = logging.getLogger(__name__) - - -WS_URL: str = "wss://api.pythonista.gg/v1/websocket" - - -class API(core.Cog): - def __init__(self, bot: core.Bot, *, pythonista_api_key: str) -> None: - self.bot = bot - self._auth: str = pythonista_api_key - - self.session: aiohttp.ClientSession | None = None - self.backoff: ExponentialBackoff[bool] = ExponentialBackoff() - self.websocket: aiohttp.ClientWebSocketResponse | None = None - - self.connection_task: asyncio.Task[None] | None = None - self.keep_alive_task: asyncio.Task[None] | None = None - - @property - def headers(self) -> dict[str, Any]: - return {"Authorization": self._auth} - - async def cog_load(self) -> None: - self.session = aiohttp.ClientSession(headers=self.headers) - self.connection_task = asyncio.create_task(self.connect()) - - async def cog_unload(self) -> None: - if self.connection_task: - try: - self.connection_task.cancel() - except Exception as e: - LOGGER.error('Unable to cancel Pythonista API connection_task in "cog_unload": %s', e) - - if self.is_connected(): - assert self.websocket - await self.websocket.close(code=PAPIWebsocketCloseCodes.NORMAL) - - if self.keep_alive_task: - try: - self.keep_alive_task.cancel() - except Exception as e: - LOGGER.error('Unable to cancel Pythonista API keep_alive_task in "cog_unload": %s', e) - - def dispatch(self, *, data: dict[str, Any]) -> None: - subscription: str = data["subscription"] - self.bot.dispatch(f"papi_{subscription}", data) - - def is_connected(self) -> bool: - return self.websocket is not None and not self.websocket.closed - - async def connect(self) -> None: - token: str | None = core.CONFIG["TOKENS"].get("pythonista") - - if not token: - self.connection_task = None - return - - if self.keep_alive_task: - try: - self.keep_alive_task.cancel() - except Exception as e: - LOGGER.warning("Failed to cancel Pythonista API Websocket keep alive. This is likely not a problem: %s", e) - - while True: - try: - self.websocket = await self.session.ws_connect(url=WS_URL) # type: ignore - except Exception as e: - if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 403: - LOGGER.critical("Unable to connect to Pythonista API Websocket, due to an incorrect token.") - return - else: - LOGGER.error("Unable to connect to Pythonista API Websocket: %s.", e) - - if self.is_connected(): - break - else: - delay: float = self.backoff.delay() # type: ignore - LOGGER.warning("Retrying Pythonista API Websocket connection in '%s' seconds.", delay) - - await asyncio.sleep(delay) - - self.connection_task = None - self.keep_alive_task = asyncio.create_task(self.keep_alive()) - - async def keep_alive(self) -> None: - assert self.websocket - - initial: dict[str, Any] = { - "op": PAPIWebsocketOPCodes.SUBSCRIBE, - "subscriptions": [PAPIWebsocketSubscriptions.DPY_MODLOG], - } - await self.websocket.send_json(data=initial) - - while True: - message: aiohttp.WSMessage = await self.websocket.receive() - - closing: tuple[aiohttp.WSMsgType, aiohttp.WSMsgType, aiohttp.WSMsgType] = ( - aiohttp.WSMsgType.CLOSED, - aiohttp.WSMsgType.CLOSING, - aiohttp.WSMsgType.CLOSE, - ) - if message.type in closing: # pyright: ignore[reportUnknownMemberType] - LOGGER.debug("Received a CLOSING/CLOSED/CLOSE message type from Pythonista API.") - - self.connection_task = asyncio.create_task(self.connect()) - return - - data: dict[str, Any] = message.json() - op: int | None = data.get("op") - - if op == PAPIWebsocketOPCodes.HELLO: - LOGGER.debug("Received HELLO from Pythonista API: user=%s", data["user_id"]) - - elif op == PAPIWebsocketOPCodes.EVENT: - self.dispatch(data=data) - - elif op == PAPIWebsocketOPCodes.NOTIFICATION: - type_: str = data["type"] - - if type_ == PAPIWebsocketNotificationTypes.SUBSCRIPTION_ADDED: - subscribed: str = ", ".join(data["subscriptions"]) - LOGGER.info("Pythonista API added our subscription, currently subscribed: `%s`", subscribed) - elif type_ == PAPIWebsocketNotificationTypes.SUBSCRIPTION_REMOVED: - subscribed: str = ", ".join(data["subscriptions"]) - LOGGER.info("Pythonista API removed our subscription, currently subscribed: `%s`", subscribed) - elif type_ == PAPIWebsocketNotificationTypes.UNKNOWN_OP: - LOGGER.info("We sent an UNKNOWN OP to Pythonista API: `%s`", data["received"]) - - else: - LOGGER.info("Received an UNKNOWN OP from Pythonista API.") - - -async def setup(bot: core.Bot) -> None: - pythonista_api_key = core.CONFIG["TOKENS"].get("pythonista") - if not pythonista_api_key: - LOGGER.warning("Not enabling %r due to missing config key.", __file__) - return - - await bot.add_cog(API(bot, pythonista_api_key=pythonista_api_key)) diff --git a/modules/moderation.py b/modules/moderation.py index 80f1ce5..fae315d 100644 --- a/modules/moderation.py +++ b/modules/moderation.py @@ -30,7 +30,7 @@ import logging import re from textwrap import shorten -from typing import TYPE_CHECKING, Any, Self, TypeAlias +from typing import TYPE_CHECKING, Any, Self import discord import mystbin @@ -43,12 +43,12 @@ if TYPE_CHECKING: from core.context import Interaction - from types_.papi import ModLogPayload, PythonistaAPIWebsocketPayload + from types_.papi import ModLogPayload - ModLogType: TypeAlias = PythonistaAPIWebsocketPayload[ModLogPayload] logger = logging.getLogger(__name__) + BASE_BADBIN_RE = r"https://(?P{domains})/(?P[a-zA-Z0-9]+)[.]?(?P[a-z]{{1,8}})?" TOKEN_RE = re.compile(r"[a-zA-Z0-9_-]{23,28}\.[a-zA-Z0-9_-]{6,7}\.[a-zA-Z0-9_-]{27}") PROSE_LOOKUP = { @@ -264,21 +264,20 @@ async def find_badbins(self, message: discord.Message) -> None: await message.reply(msg, mention_author=False) @commands.Cog.listener() - async def on_papi_dpy_modlog(self, payload: ModLogType, /) -> None: - moderation_payload = payload["payload"] - moderation_event = core.DiscordPyModerationEvent(moderation_payload["moderation_event_type"]) + async def on_papi_dpy_modlog(self, payload: ModLogPayload, /) -> None: + moderation_event = core.DiscordPyModerationEvent(payload["moderation_event_type"]) embed = discord.Embed( title=f"Discord.py Moderation Event: {moderation_event.name.title()}", colour=random_pastel_colour(), ) - target_id = moderation_payload["target_id"] + target_id = payload["target_id"] target = await self.bot.get_or_fetch_user(target_id) - moderation_reason = moderation_payload["reason"] + moderation_reason = payload["reason"] - moderator_id = moderation_payload["author_id"] + moderator_id = payload["author_id"] moderator = self.dpy_mod_cache.get(moderator_id) or await self.bot.get_or_fetch_user( moderator_id, cache=self.dpy_mod_cache ) @@ -301,7 +300,7 @@ async def on_papi_dpy_modlog(self, payload: ModLogType, /) -> None: embed.description = moderator_format + target_format - when = datetime.datetime.fromisoformat(moderation_payload["event_time"]) + when = datetime.datetime.fromisoformat(payload["event_time"]) embed.timestamp = when guild = self.bot.get_guild(490948346773635102) diff --git a/poetry.lock b/poetry.lock index 0da8b9e..c52332c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "aiohttp" @@ -109,6 +109,26 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + [[package]] name = "astunparse" version = "1.6.3" @@ -264,10 +284,10 @@ files = [ [[package]] name = "discord.py" -version = "2.4.0a4888+ge6a0dc5bc" +version = "2.4.0a5041+g0e58a927d" description = "A Python wrapper for the Discord API" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.8" files = [] develop = false @@ -275,16 +295,16 @@ develop = false aiohttp = ">=3.7.4,<4" [package.extras] -docs = ["sphinx (==4.4.0)", "sphinxcontrib-websupport", "sphinxcontrib_trio (==1.1.2)", "typing-extensions (>=4.3,<5)"] +docs = ["sphinx (==4.4.0)", "sphinx-inline-tabs (==2023.4.21)", "sphinxcontrib-applehelp (==1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (==2.0.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport (==1.2.4)", "typing-extensions (>=4.3,<5)"] speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7)", "orjson (>=3.5.4)"] -test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)"] +test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)", "tzdata"] voice = ["PyNaCl (>=1.3.0,<1.6)"] [package.source] type = "git" url = "https://github.com/Rapptz/discord.py.git" -reference = "e6a0dc5bc0ba8e739b0def446378088bea65d1df" -resolved_reference = "e6a0dc5bc0ba8e739b0def446378088bea65d1df" +reference = "HEAD" +resolved_reference = "0e58a927ddbc300a17ef0137d948faa659565313" [[package]] name = "frozenlist" @@ -372,6 +392,17 @@ files = [ {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + [[package]] name = "idna" version = "3.7" @@ -400,6 +431,17 @@ astunparse = ">=1.6.3,<2.0.0" [package.extras] test = ["pytest", "pytest-cov"] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "jishaku" version = "2.5.2" @@ -539,6 +581,24 @@ files = [ docs = ["furo", "sphinx (>=4.0.0,<5.0.0)", "sphinxcontrib-trio"] speed = ["aiohttp (>=3.8,<4.0)", "aiohttp[speedups] (>=3.8,<4.0)"] +[[package]] +name = "redis" +version = "5.0.5" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.7" +files = [ + {file = "redis-5.0.5-py3-none-any.whl", hash = "sha256:30b47d4ebb6b7a0b9b40c1275a19b87bb6f46b3bed82a89012cf56dea4024ada"}, + {file = "redis-5.0.5.tar.gz", hash = "sha256:3417688621acf6ee368dec4a04dd95881be24efd34c79f00d31f62bb528800ae"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>=1.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"] + [[package]] name = "ruff" version = "0.4.4" @@ -576,6 +636,58 @@ files = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "0.37.2" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.8" +files = [ + {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"}, + {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"}, +] + +[package.dependencies] +anyio = ">=3.4.0,<5" + +[package.extras] +full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] + +[[package]] +name = "Starlette-Plus" +version = "1.0.0" +description = "Additional features, utilities and helpers for Starlette." +optional = false +python-versions = ">=3.11" +files = [] +develop = false + +[package.dependencies] +itsdangerous = ">=2.1.2" +redis = ">=5.0.3" +starlette = ">=0.37.2" + +[package.extras] +dev = ["isort", "pyright", "ruff"] +docs = ["mkdocs-material", "mkdocstrings", "mkdocstrings-python"] + +[package.source] +type = "git" +url = "https://github.com/PythonistaGuild/StarlettePlus" +reference = "HEAD" +resolved_reference = "f21169a02b02459fc5bc48491e7330ae447f870f" + [[package]] name = "toml" version = "0.10.2" @@ -598,6 +710,24 @@ files = [ {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, ] +[[package]] +name = "uvicorn" +version = "0.30.1" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.8" +files = [ + {file = "uvicorn-0.30.1-py3-none-any.whl", hash = "sha256:cd17daa7f3b9d7a24de3617820e634d0933b69eed8e33a516071174427238c81"}, + {file = "uvicorn-0.30.1.tar.gz", hash = "sha256:d46cd8e0fd80240baffbcd9ec1012a712938754afcf81bce56c024c1656aece8"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] + [[package]] name = "wheel" version = "0.43.0" @@ -718,4 +848,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "4569496765cb6153da0b047b42f124b1687646ab70e95dabf199b0138ce3456c" +content-hash = "81c627a436b8a190d4613600d5eab2fcee68601be72108b7cfe2608223c0ff44" diff --git a/pyproject.toml b/pyproject.toml index 002b1a1..56211b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,12 +12,14 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" -"discord.py" = { git = "https://github.com/Rapptz/discord.py.git", rev = "e6a0dc5bc0ba8e739b0def446378088bea65d1df" } +"discord.py" = { git = "https://github.com/Rapptz/discord.py.git"} aiohttp = "*" asyncpg = "*" toml = "*" "mystbin.py" = "*" jishaku = "*" +uvicorn = "*" +starlette-plus = { git = "https://github.com/PythonistaGuild/StarlettePlus" } [tool.poetry.group.dev.dependencies] ruff = "*" diff --git a/requirements.txt b/requirements.txt index f1ed98e..81cf615 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ -discord.py @ git+https://github.com/Rapptz/discord.py@e6a0dc5 +discord.py @ git+https://github.com/Rapptz/discord.py aiohttp~=3.7.3 -asyncpg~=0.27.0 +asyncpg~=0.29.0 toml>=0.10.2 asyncpg-stubs mystbin.py jishaku +starlette-plus @ git+https://github.com/PythonistaGuild/StarlettePlus +uvicorn \ No newline at end of file diff --git a/server/application.py b/server/application.py new file mode 100644 index 0000000..ad44f0e --- /dev/null +++ b/server/application.py @@ -0,0 +1,60 @@ +"""MIT License + +Copyright (c) 2021-Present PythonistaGuild + +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 typing import Any + +import starlette_plus + +from core.bot import Bot +from core.core import CONFIG + + +class Application(starlette_plus.Application): + + def __init__(self, *, bot: Bot) -> None: + self.bot: Bot = bot + self.__auth: str | None = CONFIG["TOKENS"].get("pythonista") + + super().__init__() + + @starlette_plus.route("/dpy/modlog", methods=["POST"], prefix=False, include_in_schema=False) + async def dpy_modlog(self, request: starlette_plus.Request) -> starlette_plus.Response: + if not self.__auth: + return starlette_plus.Response("Unable to process request: Missing Auth (Server)", status_code=503) + + auth: str | None = request.headers.get("authorization", None) + if not auth: + return starlette_plus.Response("Forbidden", status_code=403) + + if auth != self.__auth: + return starlette_plus.Response("Unauthorized", status_code=401) + + try: + data: dict[str, Any] = await request.json() + except Exception as e: + return starlette_plus.Response(f"Invalid payload: {e}", status_code=400) + + if not data: + return starlette_plus.Response("Invalid payload: Empty payload provided", status_code=400) + + self.bot.dispatch("papi_dpy_modlog", data) + return starlette_plus.Response(status_code=204)