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/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)